From 48a6276e1272aea339205903b3fc0066b4f80fb4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sat, 27 Jun 2026 22:25:05 +0300 Subject: [PATCH 01/43] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index ef9bcda031..0f32ca5313 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.39-1575" +tangemBlockchainSdk = "releases-5.39-1589" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.39-623" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 9b6ed88d78652f6d2f561363e6854bbe31424bdf Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 09:42:11 +0200 Subject: [PATCH 02/43] Updated on 2026-08-14 --- .../tangem/scenarios/MultiWalletScenarios.kt | 156 ++++++++ .../screens/AddToPortfolioPageObject.kt | 27 ++ .../tangem/tests/transfer/AppTransfersTest.kt | 332 ++++++++++++++++++ .../sdk/mocks/content/WalletMockContent.kt | 62 ++++ .../core/ui/test/BuyTokenScreenTestTags.kt | 1 + .../core/ui/test/DetailsScreenTestTags.kt | 1 + .../impl/choosetoken/ui/ChooseTokenScreen.kt | 1 + .../details/ui/UserWalletListBlock.kt | 2 +- 8 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/AddToPortfolioPageObject.kt diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt new file mode 100644 index 0000000000..76eb6ba809 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt @@ -0,0 +1,156 @@ +package com.tangem.scenarios + +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeRight +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.core.ui.test.BuyTokenScreenTestTags +import com.tangem.core.ui.test.DetailsScreenTestTags +import com.tangem.core.ui.test.MainScreenTestTags +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 + +// The 'Add Wallet' button sits in a nested LazyColumn and rejects touch injection, so invoke OnClick directly. +private fun BaseTestCase.clickViaSemantics(matcher: SemanticsMatcher, useUnmergedTree: Boolean = false) { + composeTestRule.onNode(matcher, useUnmergedTree).performSemanticsAction(SemanticsActions.OnClick) +} + +// Both pager pages stay mounted; the off-screen one ignores swipes, so always swipe the on-screen wallet card. +private fun BaseTestCase.swipeDisplayedWallet(toPrevious: Boolean) { + val nodes = composeTestRule.onAllNodes(hasTestTag(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) break + } + waitForIdle() +} + +/** + * Adds a second hardware (card) wallet from a wallet-restricted state (a hot wallet already exists): + * 'Add Wallet' scans a card immediately — there is no wallet-type chooser — so the scan mock must be + * set before the click. After saving, the app returns to Main; the added card lands as an off-screen + * pager page, so this swipes to it and syncs its missing addresses before returning. + */ +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)") { + clickViaSemantics(hasTestTag(DetailsScreenTestTags.ADD_WALLET_BUTTON)) + } + // Details pops back to Main only after the freshly scanned wallet finishes its initial load over many (some failing) RPCs. + // Gate on the top-bar More button, not the screen 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) swipeDisplayedWallet(toPrevious = false) + shown + } + // Let the pager fling settle before clicking — the wait above releases as soon as the prompt has bounds, and a + // click landing mid-animation is eaten by the button's clickableSingle debounce, so the prompt never clears. + 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 + } + } +} + +/** Taps the on-screen token row by [tokenName]; the wallet pager keeps both pages mounted, so click the displayed copy. */ +fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) { + val matcher = hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) and hasAnyDescendant(hasText(tokenName)) + step("Click on token '$tokenName' on the visible wallet") { + // Both pager pages mount the same token; click the one currently on-screen once the swipe has settled. + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + val nodes = composeTestRule.onAllNodes(matcher, useUnmergedTree = true) + (0 until nodes.fetchSemanticsNodes().size).any { i -> + runCatching { + nodes[i].assertIsDisplayed() + nodes[i].performClick() + }.isSuccess + } + } + } +} + +/** Swipes the wallet card to the previous wallet (the one added before the current page). */ +fun BaseTestCase.switchToPreviousWallet() { + step("Swipe wallet card to the previous wallet") { + swipeDisplayedWallet(toPrevious = true) + } +} + +/** Opens the receive selector and picks identical [token] located on a different wallet [walletName] via its wallet tab. */ +fun BaseTestCase.selectReceiveTokenOnWallet(token: String, walletName: String) { + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + // The wallet tab is a custom clickable Row that Kakao's performClick can't hit (autoscroll fails); invoke OnClick directly. + step("Select wallet tab '$walletName'") { + clickViaSemantics( + hasTestTag(BuyTokenScreenTestTags.WALLET_TAB) and hasAnyDescendant(hasText(walletName)), + useUnmergedTree = true, + ) + } + step("Click on token with name '$token'") { + clickViaSemantics( + hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) and hasAnyDescendant(hasText(token)), + useUnmergedTree = true, + ) + } +} + +/** Searches the receive selector for [token], adds it to recipient wallet [recipientWalletName] via the market result. */ +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() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AddToPortfolioPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AddToPortfolioPageObject.kt new file mode 100644 index 0000000000..9046abeb11 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/AddToPortfolioPageObject.kt @@ -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(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) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt index 14e9fb1557..6c91d75161 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt @@ -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,334 @@ 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() } + } + } + + // [REDACTED_TASK_KEY]: 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) + } + // Assert the top-bar 'Transfer' title (unambiguous transfer-mode signal) + no provider block; the + // withdraw-entry swap keeps recalculating, so 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") diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index aa7881a6db..a8c47499e7 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -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, + ), ), ), diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/BuyTokenScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/BuyTokenScreenTestTags.kt index af41f67814..d42256592d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/BuyTokenScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/BuyTokenScreenTestTags.kt @@ -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" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/DetailsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/DetailsScreenTestTags.kt index ec91e0b88a..7ef23e03ea 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/DetailsScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/DetailsScreenTestTags.kt @@ -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" } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt index 18fbcd8716..de5b55289b 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt @@ -252,6 +252,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, diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt index df93d20331..6f9876af34 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt @@ -130,7 +130,7 @@ private fun AddWalletButton( modifier: Modifier = Modifier, ) { BlockCard( - modifier = modifier, + modifier = modifier.testTag(DetailsScreenTestTags.ADD_WALLET_BUTTON), onClick = onClick, enabled = !isInProgress, ) { From a434e954df7bc3ea8226df927fccd930b5129d1a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 11:16:41 +0200 Subject: [PATCH 03/43] Updated on 2026-08-14 --- .../kotlin/com/tangem/scenarios/SwapScenarios.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index ad101a5545..5634025ac7 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -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). */ +/** Holds the Transfer confirm button; the 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() } From de41c92d35f847090cf583838f25437a1af05c96 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 11:50:47 +0200 Subject: [PATCH 04/43] Updated on 2026-08-14 --- .../tangem/scenarios/MultiWalletScenarios.kt | 23 ++++++------------- .../com/tangem/scenarios/SwapScenarios.kt | 2 +- .../tangem/tests/transfer/AppTransfersTest.kt | 5 ++-- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt index 76eb6ba809..8551ce9af5 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt @@ -24,7 +24,7 @@ import com.tangem.tap.domain.sdk.mocks.MockContent import com.tangem.tap.domain.sdk.mocks.MockProvider import io.qameta.allure.kotlin.Allure.step -// The 'Add Wallet' button sits in a nested LazyColumn and rejects touch injection, so invoke OnClick directly. +// Custom/nested clickables (Add-Wallet row, wallet tab) reject touch injection — drive OnClick directly. private fun BaseTestCase.clickViaSemantics(matcher: SemanticsMatcher, useUnmergedTree: Boolean = false) { composeTestRule.onNode(matcher, useUnmergedTree).performSemanticsAction(SemanticsActions.OnClick) } @@ -42,12 +42,7 @@ private fun BaseTestCase.swipeDisplayedWallet(toPrevious: Boolean) { waitForIdle() } -/** - * Adds a second hardware (card) wallet from a wallet-restricted state (a hot wallet already exists): - * 'Add Wallet' scans a card immediately — there is no wallet-type chooser — so the scan mock must be - * set before the click. After saving, the app returns to Main; the added card lands as an off-screen - * pager page, so this swipes to it and syncs its missing addresses before returning. - */ +/** '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() } @@ -56,8 +51,7 @@ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { step("Click on 'Add Wallet' button (scans a new hardware wallet)") { clickViaSemantics(hasTestTag(DetailsScreenTestTags.ADD_WALLET_BUTTON)) } - // Details pops back to Main only after the freshly scanned wallet finishes its initial load over many (some failing) RPCs. - // Gate on the top-bar More button, not the screen container — the bottom Markets sheet can leave the container un-"displayed". + // 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 @@ -71,8 +65,7 @@ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { if (!shown) swipeDisplayedWallet(toPrevious = false) shown } - // Let the pager fling settle before clicking — the wait above releases as soon as the prompt has bounds, and a - // click landing mid-animation is eaten by the button's clickableSingle debounce, so the prompt never clears. + // 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). @@ -84,11 +77,10 @@ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { } } -/** Taps the on-screen token row by [tokenName]; the wallet pager keeps both pages mounted, so click the displayed copy. */ +/** Both pager pages mount the same token; clicks the [tokenName] copy currently on-screen. */ fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) { val matcher = hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) and hasAnyDescendant(hasText(tokenName)) step("Click on token '$tokenName' on the visible wallet") { - // Both pager pages mount the same token; click the one currently on-screen once the swipe has settled. composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { val nodes = composeTestRule.onAllNodes(matcher, useUnmergedTree = true) (0 until nodes.fetchSemanticsNodes().size).any { i -> @@ -101,14 +93,13 @@ fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) { } } -/** Swipes the wallet card to the previous wallet (the one added before the current page). */ fun BaseTestCase.switchToPreviousWallet() { step("Swipe wallet card to the previous wallet") { swipeDisplayedWallet(toPrevious = true) } } -/** Opens the receive selector and picks identical [token] located on a different wallet [walletName] via its wallet tab. */ +/** 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() } @@ -128,7 +119,7 @@ fun BaseTestCase.selectReceiveTokenOnWallet(token: String, walletName: String) { } } -/** Searches the receive selector for [token], adds it to recipient wallet [recipientWalletName] via the market result. */ +/** 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() } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index 5634025ac7..4dc9ab8da6 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -490,7 +490,7 @@ fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) { } } -/** Holds the Transfer confirm button; 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() { composeTestRule.onNode( hasTestTag(BaseButtonTestTags.BUTTON) and diff --git a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt index 6c91d75161..1904bd4c09 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt @@ -850,7 +850,7 @@ class AppTransfersTest : BaseTestCase() { } } - // [REDACTED_TASK_KEY]: blockchain SDK TonProvidersBuilder drops public providers, so TON has no provider in the mocked build. + // 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") @@ -1016,8 +1016,7 @@ class AppTransfersTest : BaseTestCase() { step("Choose identical receive token '$token' from '$receiveAccountName'") { chooseIdenticalReceiveToken(tokenName = token, receiveAccountName = receiveAccountName) } - // Assert the top-bar 'Transfer' title (unambiguous transfer-mode signal) + no provider block; the - // withdraw-entry swap keeps recalculating, so use flakySafely rather than assertTransferReady's waitUntil. + // 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() } From ca91ddb27fe058df75a4fde6c434fb11b4ef89db Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 13:36:46 +0300 Subject: [PATCH 05/43] Updated on 2026-08-14 --- .../network/solana/WcSolanaSignAndSendTransactionUseCase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaSignAndSendTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaSignAndSendTransactionUseCase.kt index 897db70e35..362c98fe80 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaSignAndSendTransactionUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaSignAndSendTransactionUseCase.kt @@ -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 -> From f642bd1e28b59ee88c2b2bcedaa2ef65393a238a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 13:01:36 +0300 Subject: [PATCH 06/43] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 3 ++- core/res/src/main/res/values-fr/strings.xml | 1 + core/res/src/main/res/values-ja/strings.xml | 1 + core/res/src/main/res/values-zh-rCN/strings.xml | 1 + core/res/src/main/res/values/strings.xml | 14 ++++++++++++++ 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index f4e0b41e2d..e37cc25476 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -566,7 +566,7 @@ Dynamische Adressen deaktivieren Dynamische Adressen deaktivieren Dynamische Adressen deaktiviert - Einige Adressen fehlen + Dynamische Adressen aktiviert Verwenden Sie für jede Transaktion eine neue Adresse, um die Rückverfolgbarkeit zu verringern und den Datenschutz in der Kette zu verbessern. Verbesserter Datenschutz Einfacher Geldempfang in UTXO-basierten Netzwerken mit automatischer Adressgenerierung - keine manuelle Adressverwaltung erforderlich. @@ -1550,6 +1550,7 @@ Staking aktiviert Zurzeit sind keine Validierer verfügbar. Bitte versuche es später noch einmal. Staking nicht verfügbar + Staking ist in Ihrer Region nicht verfügbar. Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Du die Verwendung Deines Tokens für das Staking genehmigst. Indem Du die Staking-Funktionalität nutzt, stimmst Du den %1$s und %2$s des Anbieters zu. Gesperrt diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index ec381bcad1..c1d9e544ef 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1421,6 +1421,7 @@ Staking activé Aucun validateur disponible pour le moment. Veuillez réessayer plus tard. Staking indisponible + Le staking est indisponible dans votre région Le réseau facturera des frais d’approbation de jeton pour vérifier que vous autorisez l’utilisation de votre jeton pour le jalonnement. En utilisant la fonctionnalité de staking, vous acceptez les %1$s et %2$s du fournisseur Bloqué diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index b851b50576..1a9a334e93 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -1520,6 +1520,7 @@ ステーキングが有効です 現在、利用可能なバリデーターは見つかりません。しばらくしてからもう一度お試しください。 ステーキングは利用できません + お住まいの地域ではステーキングをご利用いただけません ネットワークは、ステーキングのためにトークンの使用を承認していることを確認するために、トークン承認手数料を請求します。 ステーキング機能を使用すると、プロバイダーの%1$sと%2$sに同意したことになります ロック中 diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index d74f43edfc..e1ea9ae35f 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -1512,6 +1512,7 @@ 已启用质押 目前没有可用的验证节点。请稍后再试。 质押功能不可用 + 您所在的地区暂不支持质押功能 网络将收取代币批准费,以验证您是否授权使用您的代币进行质押。 使用质押功能即表示您同意提供商的 %1$s 和 %2$s 已锁定 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index e644cefa16..0f66cd5146 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -115,10 +115,17 @@ Enter address Invalid address Keep editing + You can not create more than 20 addresses. Delete one to add new. + Can\'t add new address + Contact name is required + Contact name contains invalid characters + Contact name must not exceed 50 characters + That name is already taken on this wallet New contact No contacts yet Contacts added will appear here Remove address + Save to Wallet This contact will be linked to this wallet’s address book. Select network Address book @@ -352,6 +359,7 @@ Get token Go to provider Go to token + Go to verification Got it Hide Hold to %s @@ -410,6 +418,7 @@ Privacy Policy %1$s-%2$s %1$s — %2$s + Rate Read more Receive Received @@ -701,6 +710,8 @@ Tangem feedback Can\'t send a transaction Coin description error + Review portfolio and explore earn opportunities + For You Update now Update the app to its latest version to ensure proper functionality Update needed @@ -732,6 +743,8 @@ Key Generation All cryptographic operations happen inside the secure chip, certified against cloning and physical tampering. Hardware-Level Security + Network activity is high. You can continue now or try again later when fees may be lower. + Network fee is higher than usual Add Existing Wallet Create New Wallet Order Tangem @@ -1402,6 +1415,7 @@ Memo Check your network connection Network fee info unreachable + from %1$s in %2$s You send From %s Gas limit From 392234221b7b07e206221e6098247c9bb21f1baa Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 14:47:35 +0200 Subject: [PATCH 07/43] Updated on 2026-08-14 --- .../tangem/scenarios/MultiWalletScenarios.kt | 61 ++----------------- .../com/tangem/screens/BuyTokenPageObject.kt | 14 +++++ .../com/tangem/screens/DetailsPageObject.kt | 4 ++ .../tangem/screens/MainScreenPageObject.kt | 22 +++++++ 4 files changed, 46 insertions(+), 55 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt index 8551ce9af5..dc3ef704c7 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt @@ -1,47 +1,15 @@ package com.tangem.scenarios -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasAnyDescendant -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performSemanticsAction -import androidx.compose.ui.test.performTouchInput -import androidx.compose.ui.test.swipeLeft -import androidx.compose.ui.test.swipeRight 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.core.ui.test.BuyTokenScreenTestTags -import com.tangem.core.ui.test.DetailsScreenTestTags -import com.tangem.core.ui.test.MainScreenTestTags 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 -// Custom/nested clickables (Add-Wallet row, wallet tab) reject touch injection — drive OnClick directly. -private fun BaseTestCase.clickViaSemantics(matcher: SemanticsMatcher, useUnmergedTree: Boolean = false) { - composeTestRule.onNode(matcher, useUnmergedTree).performSemanticsAction(SemanticsActions.OnClick) -} - -// Both pager pages stay mounted; the off-screen one ignores swipes, so always swipe the on-screen wallet card. -private fun BaseTestCase.swipeDisplayedWallet(toPrevious: Boolean) { - val nodes = composeTestRule.onAllNodes(hasTestTag(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) break - } - waitForIdle() -} - /** '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") { @@ -49,7 +17,7 @@ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { } MockProvider.setMocks(mockContent) step("Click on 'Add Wallet' button (scans a new hardware wallet)") { - clickViaSemantics(hasTestTag(DetailsScreenTestTags.ADD_WALLET_BUTTON)) + 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") { @@ -62,7 +30,7 @@ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { var shown = false onMainScreen { shown = synchronizeAddressesButton.isDisplayedSafely() } - if (!shown) swipeDisplayedWallet(toPrevious = false) + if (!shown) onMainScreen { swipeToAdjacentWallet(toPrevious = false) } shown } // Let the pager fling settle — a click mid-animation is eaten by the button's clickableSingle debounce. @@ -77,25 +45,15 @@ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { } } -/** Both pager pages mount the same token; clicks the [tokenName] copy currently on-screen. */ fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) { - val matcher = hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) and hasAnyDescendant(hasText(tokenName)) step("Click on token '$tokenName' on the visible wallet") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - val nodes = composeTestRule.onAllNodes(matcher, useUnmergedTree = true) - (0 until nodes.fetchSemanticsNodes().size).any { i -> - runCatching { - nodes[i].assertIsDisplayed() - nodes[i].performClick() - }.isSuccess - } - } + onMainScreen { clickDisplayedToken(tokenName) } } } fun BaseTestCase.switchToPreviousWallet() { step("Swipe wallet card to the previous wallet") { - swipeDisplayedWallet(toPrevious = true) + onMainScreen { swipeToAdjacentWallet(toPrevious = true) } } } @@ -104,18 +62,11 @@ fun BaseTestCase.selectReceiveTokenOnWallet(token: String, walletName: String) { step("Click on 'Choose token' button") { onSwapTokenScreen { chooseTokenButton.performClick() } } - // The wallet tab is a custom clickable Row that Kakao's performClick can't hit (autoscroll fails); invoke OnClick directly. step("Select wallet tab '$walletName'") { - clickViaSemantics( - hasTestTag(BuyTokenScreenTestTags.WALLET_TAB) and hasAnyDescendant(hasText(walletName)), - useUnmergedTree = true, - ) + onBuyTokenScreen { walletTab(walletName).performClick() } } step("Click on token with name '$token'") { - clickViaSemantics( - hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) and hasAnyDescendant(hasText(token)), - useUnmergedTree = true, - ) + onBuyTokenScreen { tokenWithTitle(token).performClick() } } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenPageObject.kt index 1dbe1d3ba3..c3b236391e 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenPageObject.kt @@ -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(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 { + hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) + hasText(tokenTitle) + useUnmergedTree = true + } } internal fun BaseTestCase.onBuyTokenScreen(function: BuyTokenPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt index ff937e632d..110188ecbc 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt @@ -28,6 +28,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)) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 8e83fc81ea..c6b343f37b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -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 From 23f7514dc45ce3d47541115608cb53583ee8cf2f Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 22:08:13 +0200 Subject: [PATCH 08/43] Updated on 2026-08-14 --- .../converter/EarnTokenWithCurrencyToListItemUMConverter.kt | 1 + .../feed/model/search/converter/UserAssetSearchItemConverter.kt | 2 +- .../main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt | 2 +- .../kotlin/com/tangem/features/feed/ui/earn/state/EarnListUM.kt | 1 + .../com/tangem/features/feed/ui/feed/components/EarnBlock.kt | 2 +- 5 files changed, 5 insertions(+), 3 deletions(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/EarnTokenWithCurrencyToListItemUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/EarnTokenWithCurrencyToListItemUMConverter.kt index dfdd8f7610..6fbbecdc81 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/EarnTokenWithCurrencyToListItemUMConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/EarnTokenWithCurrencyToListItemUMConverter.kt @@ -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), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt index a57bd66a42..4c82149114 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt @@ -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, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt index a8bbac4a18..60dec31652 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt @@ -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, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/state/EarnListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/state/EarnListUM.kt index 763954d13f..02a9c37fff 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/state/EarnListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/state/EarnListUM.kt @@ -36,4 +36,5 @@ internal data class EarnListItemUM( val earnType: EarnType, val earnTypeTitle: TextReference, val onItemClick: () -> Unit, + val id: String = "$tokenName-$symbol-$network-$earnType", ) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/EarnBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/EarnBlock.kt index 087dbfa992..ddb450e65d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/EarnBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/EarnBlock.kt @@ -154,7 +154,7 @@ private fun EarnContentBlock(items: ImmutableList) { ) { items( items = items, - key = { item -> "${item.tokenName}-${item.network}" }, + key = { item -> item.id }, ) { item -> MostlyUsedCard( item = item, From ac907dd7b150346539cfed90a28929d93f68c1a8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 12:33:16 +0500 Subject: [PATCH 09/43] Updated on 2026-08-14 --- features/nft/impl/build.gradle.kts | 2 ++ .../nft/common/DefaultNFTComponent.kt | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/features/nft/impl/build.gradle.kts b/features/nft/impl/build.gradle.kts index 797564dd0e..7e48b26c5d 100644 --- a/features/nft/impl/build.gradle.kts +++ b/features/nft/impl/build.gradle.kts @@ -30,6 +30,8 @@ dependencies { /** Domain modules */ implementation(projects.domain.account.status) + implementation(projects.domain.card) + implementation(projects.domain.feedback) implementation(projects.domain.wallets) implementation(projects.domain.appCurrency.models) implementation(projects.domain.appCurrency) diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt index 2d94110b50..24eaf8bed2 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt @@ -20,6 +20,11 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.message.dialog.Dialogs +import com.tangem.domain.card.IsWalletBackupProblematicUseCase +import com.tangem.domain.feedback.SendBackupProblemEmailUseCase +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher @@ -51,6 +56,9 @@ internal class DefaultNFTComponent @AssistedInject constructor( private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, private val portfolioSelectorController: PortfolioSelectorController, portfolioFetcherFactory: PortfolioFetcher.Factory, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase, + private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase, ) : NFTComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -153,6 +161,8 @@ internal class DefaultNFTComponent @AssistedInject constructor( ) private fun onReceiveClick(route: NFTRoute.Collections) = componentScope.launch { + if (isTopUpBlockedByBackupError(route.userWalletId)) return@launch + portfolioSelectorController.selectAccount(null) portfolioFetcher.updateMode(mode = PortfolioFetcher.Mode.Wallet(route.userWalletId)) val portfolioData = portfolioFetcher.data.first() @@ -169,6 +179,18 @@ internal class DefaultNFTComponent @AssistedInject constructor( } }.saveIn(onReceiveClickJob) + private fun isTopUpBlockedByBackupError(userWalletId: UserWalletId): Boolean { + val userWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return false + if (!isWalletBackupProblematicUseCase(userWallet)) return false + + messageSender.send( + Dialogs.backupErrorAddFundsDisabled( + onContactSupport = { componentScope.launch { sendBackupProblemEmailUseCase(userWalletId) } }, + ), + ) + return true + } + private fun getReceiveComponent( factoryContext: AppComponentContext, route: NFTRoute.Receive, From ff2077bb9ca78a9be6472589dcd89f5a80675182 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 12:33:28 +0500 Subject: [PATCH 10/43] Updated on 2026-08-14 --- .../GetAccountCurrencyByAddressUseCase.kt | 2 +- .../GetAccountCurrencyByAddressUseCaseTest.kt | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt index 4043b8821f..7007084f00 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt @@ -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 diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt index 9471a7d81f..1958437c62 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt @@ -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( From 9b101c423822b03ee74ab6ae2cab6712824c4168 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 10:33:11 +0100 Subject: [PATCH 11/43] Updated on 2026-08-14 --- .../common/ui/notifications/NotificationUM.kt | 13 +++- .../ui/notifications/NotificationsFactory.kt | 52 ++++++++++---- .../notifications/model/NotificationsModel.kt | 26 ++++++- .../AddStakingNotificationsTransformer.kt | 1 + .../swap/domain/models/ui/SwapState.kt | 1 + .../transfer/SwapTransferInteractorImpl.kt | 10 +++ .../SwapTransferInteractorImplTest.kt | 71 +++++++++++++++++++ .../feature/swap/analytics/SwapEvents.kt | 2 + .../tangem/feature/swap/model/SwapModel.kt | 7 +- .../swap/model/SwapNotificationsFactory.kt | 1 + .../SwapTransferNotificationsFactory.kt | 3 +- 11 files changed, 167 insertions(+), 20 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt index b7fc3cc35a..634a345c91 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt @@ -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( diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt index b52325095e..ca1bd6e16a 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt @@ -120,40 +120,66 @@ object NotificationsFactory { } } + @Suppress("LongParameterList") fun MutableList.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) { - // Try to send coin amount less than reserve amount (e.g. less than 1 XLM in Stellar) + private fun MutableList.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) + is CryptoCurrency.Coin -> add( + NotificationUM.Error.ReserveAmount( + reserveAmount.format { + crypto(feeCryptoCurrency ?: cryptoCurrency) + }, + ), + ) + // Try to send any token (e.g. USDC in Stellar, but account not funded -> user must send XLM at first) + is CryptoCurrency.Token -> { + checkNotNull(feeCryptoCurrency) add( - NotificationUM.Error.ReserveAmount( - reserveAmount.format { - crypto(feeCryptoCurrency ?: cryptoCurrency) + NotificationUM.Error.NetworkAccountNotFunded( + coinName = feeCryptoCurrency.name, + reserveAmount = reserveAmount.format { + crypto(symbol = "", decimals = feeCryptoCurrency.decimals) }, + reserveSymbol = feeCryptoCurrency.symbol, ), ) - } else { - checkNotNull(feeCryptoCurrency) - // Try to send any token (e.g. USDC in Stellar, but account not funded -> user must send XLM at first) - add(NotificationUM.Error.NetworkAccountNotFunded(coinName = feeCryptoCurrency.name)) } } - // TODO: check the RECEIVER account trustline before sending + } + + private fun MutableList.addTrustlineRequiredNotification() { + add(NotificationUM.Error.RequiredTrustline) } fun MutableList.addMinimumAmountErrorNotification( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt index e831713c99..6f1349f9c3 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt @@ -25,14 +25,17 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCase import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.notifications.GetTronFeeNotificationShowCountUseCase import com.tangem.domain.notifications.IncrementNotificationsShowCountUseCase +import com.tangem.domain.tokens.GetAssetRequirementsUseCase import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.features.send.api.SendNotificationsComponent @@ -70,9 +73,11 @@ internal class NotificationsModel @Inject constructor( private val validateTransactionUseCase: ValidateTransactionUseCase, private val getTronFeeNotificationShowCountUseCase: GetTronFeeNotificationShowCountUseCase, private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase, + private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase, + private val getAccountCurrencyByAddressUseCase: GetAccountCurrencyByAddressUseCase, private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger, private val notificationsUpdateListener: SendNotificationsUpdateListener, - private val analyticsEventHandler: AnalyticsEventHandler, + analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val params: SendNotificationsComponent.Params = paramsContainer.require() @@ -295,6 +300,7 @@ internal class NotificationsModel @Inject constructor( cryptoCurrency = currency, feeCryptoCurrency = feeCryptoCurrencyStatus.currency, isAccountFunded = currencyCheck.isAccountFunded, + hasRequiredTrustline = recipientRequiresTrustline(notificationData.destinationAddress), ) addMinimumAmountErrorNotification( minimumSendAmount = currencyCheck.minimumSendAmount, @@ -303,6 +309,24 @@ internal class NotificationsModel @Inject constructor( ) } + private suspend fun recipientRequiresTrustline(destinationAddress: String?): Boolean { + val recipientAccount = destinationAddress + ?.let { getAccountCurrencyByAddressUseCase(it).getOrNull() } + ?.account + ?: return false + val recipientCurrency = recipientAccount.cryptoCurrencies + .firstOrNull { it.isSameTokenAs(currency) } + ?: return false + return getAssetRequirementsUseCase( + userWalletId = recipientAccount.userWalletId, + currency = recipientCurrency, + ).getOrNull() is AssetRequirementsCondition.RequiredTrustline + } + + private fun CryptoCurrency.isSameTokenAs(other: CryptoCurrency): Boolean { + return id.rawNetworkId == other.id.rawNetworkId && id.contractAddress == other.id.contractAddress + } + private suspend fun MutableList.addWarningNotifications( destinationAddress: String?, memo: String?, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt index f4faa5db34..512f971c1d 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt @@ -257,6 +257,7 @@ internal class AddStakingNotificationsTransformer( cryptoCurrency = cryptoCurrency, feeCryptoCurrency = feeCryptoCurrencyStatus?.currency, isAccountFunded = false, + hasRequiredTrustline = false, ) } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 3884207e3c..091801a00e 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -60,6 +60,7 @@ sealed interface SwapState { val currencyCheck: CryptoCurrencyCheck? = null, val validationResult: Throwable? = null, val minAdaValue: BigDecimal? = null, + val hasRequiredTrustline: Boolean = false, ) : SwapState data class EmptyAmountState( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index e6def26c9f..2e2b19ef89 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -24,6 +24,7 @@ import com.tangem.domain.notifications.IncrementNotificationsShowCountUseCase import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tangempay.TangemPayWithdrawUseCase +import com.tangem.domain.tokens.GetAssetRequirementsUseCase import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase @@ -31,6 +32,7 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase @@ -66,8 +68,10 @@ class SwapTransferInteractorImpl @Inject constructor( private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, private val getTronFeeNotificationShowCountUseCase: GetTronFeeNotificationShowCountUseCase, private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase, + private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase, ) : SwapTransferInteractor { + @Suppress("LongMethod") override suspend fun updateTransfer( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, @@ -109,6 +113,7 @@ class SwapTransferInteractorImpl @Inject constructor( amount = fromTokenAmountValue, fee = warningsFee, feeCurrencyBalanceAfterTransaction = null, + recipientAddress = toSwapCurrencyStatus.destinationAddress(), ) val isAmountSubtractAvailable = isAmountSubtractAvailable( userWalletId = userWallet.walletId, @@ -130,6 +135,10 @@ class SwapTransferInteractorImpl @Inject constructor( ) } val tronFeeNotificationShowCount = getTronFeeNotificationShowCountUseCase() + val hasRequiredTrustline = getAssetRequirementsUseCase( + userWalletId = toSwapCurrencyStatus.userWalletId, + currency = toToken, + ).getOrNull() is AssetRequirementsCondition.RequiredTrustline return SwapState.Transfer( userWallet = userWallet, fromTokenInfo = fromTokenInfo, @@ -145,6 +154,7 @@ class SwapTransferInteractorImpl @Inject constructor( isAmountSubtractAvailable = isAmountSubtractAvailable, isSendingAmountLoading = coverageState.isSendingAmountLoading, currencyCheck = currencyCheck, + hasRequiredTrustline = hasRequiredTrustline, ) } diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index 6ff5e5270d..61779c4f4f 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -21,6 +21,7 @@ import com.tangem.domain.notifications.IncrementNotificationsShowCountUseCase import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tangempay.TangemPayWithdrawUseCase +import com.tangem.domain.tokens.GetAssetRequirementsUseCase import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase @@ -42,6 +43,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import java.math.BigDecimal @@ -65,6 +67,7 @@ internal class SwapTransferInteractorImplTest { private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk(relaxed = true) private val getTronFeeNotificationShowCountUseCase: GetTronFeeNotificationShowCountUseCase = mockk(relaxed = true) private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase = mockk(relaxed = true) + private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase = mockk() private val sut = SwapTransferInteractorImpl( swapFeatureToggles = swapFeatureToggles, @@ -82,8 +85,14 @@ internal class SwapTransferInteractorImplTest { getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase, getTronFeeNotificationShowCountUseCase = getTronFeeNotificationShowCountUseCase, incrementNotificationsShowCountUseCase = incrementNotificationsShowCountUseCase, + getAssetRequirementsUseCase = getAssetRequirementsUseCase, ) + @BeforeEach + fun setup() { + coEvery { getAssetRequirementsUseCase(any(), any()) } returns null.right() + } + @AfterEach fun tearDown() { clearAllMocks() @@ -429,6 +438,68 @@ internal class SwapTransferInteractorImplTest { assertThat(result.isSendingAmountLoading).isTrue() } + @Test + fun `GIVEN destination address WHEN updateTransfer THEN currency check requested with recipient and isAccountFunded flows through`() = + runTest { + // Arrange + val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") + val userWallet: UserWallet = mockk(relaxed = true) + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + fiatRate = BigDecimal.TEN, + amount = BigDecimal("1.6"), + userWallet = userWallet, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + userWallet = userWallet, + destinationAddress = DESTINATION_ADDRESS, + ) + every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right()) + every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false) + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false + // Stub matches only when the destination address is forwarded as recipientAddress; the + // returned check has isAccountFunded = true (buildCurrencyCheck default). + val fundedCheck = buildCurrencyCheck() + coEvery { + getCurrencyCheckUseCase( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = DESTINATION_ADDRESS, + ) + } returns fundedCheck + coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns false.right() + + // Act + val result = sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = "1,5", + feePaidCurrencyStatus = null, + fee = null, + ) as SwapState.Transfer + + // Assert + assertThat(result.currencyCheck?.isAccountFunded).isTrue() + coVerify { + getCurrencyCheckUseCase( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = DESTINATION_ADDRESS, + ) + } + } + // endregion // region loadFee diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index 24a1cb2319..c95d1a584f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -332,6 +332,7 @@ sealed class SwapEvents( fromCurrency: CryptoCurrency?, toCurrency: CryptoCurrency?, feeNetwork: Network, + isTangemPay: Boolean, ) : SwapEvents( event = "Transfer in Progress Screen Opened", params = mapOf( @@ -340,6 +341,7 @@ sealed class SwapEvents( RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(), "Receive Blockchain" to toCurrency?.network?.name.orEmpty(), "Network fee" to feeNetwork.name, + "Pay Account" to isTangemPay.toString(), ), ), AppsFlyerIncludedEvent diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 1929007a12..95c0ebfffb 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -1491,7 +1491,7 @@ internal class SwapModel @Inject constructor( } private fun updateTransferModeTangemPayState() { - sendTransferInProgressEvent() + sendTransferInProgressEvent(isTangemPay = true) uiState = swapTransferStateBuilder.createTangemPayWithdrawalSuccessState( uiState = uiState, dataState = dataState, @@ -1541,7 +1541,7 @@ internal class SwapModel @Inject constructor( "" } updateWalletBalance() - sendTransferInProgressEvent() + sendTransferInProgressEvent(isTangemPay = false) uiState = swapTransferStateBuilder.createSuccessState( uiState = uiState, dataState = dataState, @@ -1564,7 +1564,7 @@ internal class SwapModel @Inject constructor( ) } - private fun sendTransferInProgressEvent() { + private fun sendTransferInProgressEvent(isTangemPay: Boolean) { val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus analyticsEventHandler.send( @@ -1572,6 +1572,7 @@ internal class SwapModel @Inject constructor( fromCurrency = fromSwapCurrencyStatus?.currency, toCurrency = toSwapCurrencyStatus?.currency, feeNetwork = getFeeToken().network, + isTangemPay = isTangemPay, ), ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 1d2ca68bb9..67b1f7056c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -222,6 +222,7 @@ internal class SwapNotificationsFactory( cryptoCurrency = swapCurrencyStatus.currency, feeCryptoCurrency = feeCryptoCurrencyStatus?.currency, isAccountFunded = true, // consider the account is funded on the provider side + hasRequiredTrustline = false, ) addReduceAmountNotification( cryptoCurrencyStatus = swapCurrencyStatus.status, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt index 2a79befcf2..7a631e308c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt @@ -126,7 +126,8 @@ internal class SwapTransferNotificationsFactory @Inject constructor() { sendingAmount = amount.value, cryptoCurrency = swapCurrencyStatus.currency, feeCryptoCurrency = feeCryptoCurrencyStatus?.currency, - isAccountFunded = true, + isAccountFunded = state.currencyCheck?.isAccountFunded == true, + hasRequiredTrustline = state.hasRequiredTrustline, ) addReduceAmountNotification( cryptoCurrencyStatus = swapCurrencyStatus.status, From 50fa2697461d8c9fd1e96411eff055ab5f1a87e5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 14:04:06 +0300 Subject: [PATCH 12/43] Updated on 2026-08-14 --- .../core/ui/ds2/shimmers/TangemShimmer.kt | 144 +++++++++--------- .../tangem/core/ui/res/TangemThemeRedesign.kt | 7 +- .../setup/TangemPayCardLimitSetupScreenV2.kt | 9 +- .../tangempay/ui/TangemPayDailyLimitBlock.kt | 13 +- .../tangempay/ui/TangemPayDetailsScreenV2.kt | 10 +- .../ui/TangemPayReissueCardContentV2.kt | 9 +- .../tangempay/ui/TangemPayTxHistoryUiV2.kt | 41 +---- .../storybook/entity/StoryBookPage.kt | 24 ++- .../storybook/page/ds/shimmer/Build.kt | 7 +- .../page/ds/shimmer/TangemShimmerStory.kt | 63 +++++--- 10 files changed, 161 insertions(+), 166 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/shimmers/TangemShimmer.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/shimmers/TangemShimmer.kt index ce0f10ab05..93725819d4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/shimmers/TangemShimmer.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/shimmers/TangemShimmer.kt @@ -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, + ) + } +} + +/** Public API namespace for [TangemShimmer]. */ +object TangemShimmer { + + /** Default corner radius of the rectangle shimmer. */ + val DefaultRadius: Dp = 6.dp + + /** Per-typography sizing for the [TangemShimmer] text overload. */ + internal enum class TextPreset(val widthFraction: Float, val verticalPadding: Dp, val radius: Dp) { + Display(widthFraction = 0.5f, verticalPadding = 4.dp, radius = 12.dp), + HeadingMedium(widthFraction = 0.7f, verticalPadding = 2.dp, radius = 8.dp), + HeadingSmall(widthFraction = 0.6f, verticalPadding = 2.dp, radius = 16.dp), + Body(widthFraction = 0.5f, verticalPadding = 2.dp, radius = 16.dp), + Subheading(widthFraction = 0.4f, verticalPadding = 2.dp, radius = 16.dp), + Caption(widthFraction = 0.3f, verticalPadding = 2.dp, radius = 16.dp), + ; + + companion object { + @Composable + @ReadOnlyComposable + fun forStyle(style: TextStyle): TextPreset { + val typography = TangemTheme.typography3 + return when (style) { + typography.display.medium -> Display + typography.heading.medium -> HeadingMedium + typography.heading.small -> HeadingSmall + typography.subheading.medium -> Subheading + typography.caption.medium -> Caption + else -> Body + } + } + } + } } /** - * Typography preset for [TextShimmer]. Each preset maps to a [TangemTheme.typography3] style - * 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), -} - -@Composable -@ReadOnlyComposable -private fun TextShimmerStyle.toTextStyle(): TextStyle = when (this) { - TextShimmerStyle.DISPLAY -> TangemTheme.typography3.display.medium - TextShimmerStyle.HEADING_MEDIUM -> TangemTheme.typography3.heading.medium - TextShimmerStyle.HEADING_SMALL -> TangemTheme.typography3.heading.small - TextShimmerStyle.BODY -> TangemTheme.typography3.body.medium - TextShimmerStyle.SUBHEADING -> TangemTheme.typography3.subheading.medium - TextShimmerStyle.CAPTION -> TangemTheme.typography3.caption.medium -} - -/** - * Wraps [content] so every [RectangleShimmer] / [TextShimmer] inside reuses a single shimmer - * animation driver. Without this provider each shimmer creates its own - * [rememberInfiniteTransition] — that scales poorly in lists and lets sweeps drift out of phase. - * Safe to nest; safe to omit (each shimmer falls back to its own driver). + * 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) } } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt index 8dc5a78cec..cc4967aeb3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt @@ -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,8 +49,10 @@ fun TangemThemeRedesign(content: @Composable () -> Unit) { CompositionLocalProvider( LocalTextSelectionColors provides TangemTextSelectionColors2, ) { - ProvideHaze { - content() + ProvideTangemShimmer { + ProvideHaze { + content() + } } } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreenV2.kt index 4a14ae9223..24a093cf73 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreenV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreenV2.kt @@ -25,8 +25,7 @@ import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTr 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.TextShimmer -import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle +import com.tangem.core.ui.ds2.shimmers.TangemShimmer import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme @@ -105,11 +104,7 @@ private fun AmountBlock(state: TangemPayCardLimitSetupUM, modifier: Modifier = M color = TangemTheme.colors3.text.tertiary, ) if (state.isInitialDataLoading) { - TextShimmer( - style = TextShimmerStyle.HEADING_MEDIUM, - text = "$ 10000", - radius = TangemTheme.dimens2.x25, - ) + TangemShimmer(style = TangemTheme.typography3.heading.medium) } else { val colors = TangemAmountTextFieldColors.copy( textColor = TangemTheme.colors3.text.primary, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDailyLimitBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDailyLimitBlock.kt index 29433dc21d..77d497c2ff 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDailyLimitBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDailyLimitBlock.kt @@ -28,9 +28,7 @@ import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.row.TangemRowContainer import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.ds2.button.TangemButton -import com.tangem.core.ui.ds2.shimmers.RectangleShimmer -import com.tangem.core.ui.ds2.shimmers.TextShimmer -import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle +import com.tangem.core.ui.ds2.shimmers.TangemShimmer import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.* @@ -180,7 +178,7 @@ private fun CurrentLimitBlockV2(state: TangemPayDailyLimitBlockState, modifier: ) } TangemPayDailyLimitBlockState.Loading -> { - RectangleShimmer( + TangemShimmer( modifier = Modifier .padding(start = TangemTheme.dimens2.x3) .layoutId(TangemRowLayoutId.TAIL) @@ -273,12 +271,7 @@ private fun SubtitleLimit(state: TangemPayDailyLimitBlockState, modifier: Modifi ) } TangemPayDailyLimitBlockState.Loading -> { - TextShimmer( - radius = TangemTheme.dimens2.x25, - modifier = modifier, - style = TextShimmerStyle.BODY, - text = "$50,000", - ) + TangemShimmer(modifier = modifier, style = TangemTheme.typography3.body.medium) } } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt index faa9c8cce9..af810d7679 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt @@ -44,8 +44,7 @@ import com.tangem.core.ui.ds.message.TangemMessage import com.tangem.core.ui.ds.message.TangemMessageEffect import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds2.button.TangemButton -import com.tangem.core.ui.ds2.shimmers.TextShimmer -import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle +import com.tangem.core.ui.ds2.shimmers.TangemShimmer import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.extensions.resourceReference @@ -343,11 +342,8 @@ private fun BalanceBlock( }, ) { animatedState -> when (animatedState) { - is TangemPayDetailsBalanceBlockState.Loading -> TextShimmer( - modifier = Modifier.size(width = 160.dp, height = 56.dp), - text = "1234.00", - style = TextShimmerStyle.HEADING_MEDIUM, - radius = TangemTheme.dimens2.x25, + is TangemPayDetailsBalanceBlockState.Loading -> TangemShimmer( + style = TangemTheme.typography3.heading.medium, ) is TangemPayDetailsBalanceBlockState.Content -> Text( modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContentV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContentV2.kt index bcb076ed11..e567d483d2 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContentV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContentV2.kt @@ -31,8 +31,7 @@ import com.tangem.core.ui.ds2.row.TangemRow import com.tangem.core.ui.ds2.row.TangemRowContentLead import com.tangem.core.ui.ds2.row.TangemRowText import com.tangem.core.ui.ds2.row.TangemRowTextRole -import com.tangem.core.ui.ds2.shimmers.TextShimmer -import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle +import com.tangem.core.ui.ds2.shimmers.TangemShimmer import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -174,11 +173,7 @@ private fun FeeInfoRow(titleRes: Int, value: String, showDivider: Boolean = fals }, valueSlot = { if (value.isEmpty()) { - TextShimmer( - text = "$ 0.00", - style = TextShimmerStyle.BODY, - radius = 48.dp, - ) + TangemShimmer(style = TangemTheme.typography3.body.medium) } else { TangemRowText( text = value, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUiV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUiV2.kt index ba14336023..7ed83f195e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUiV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUiV2.kt @@ -29,9 +29,7 @@ import com.tangem.core.ui.ds2.row.TangemRow import com.tangem.core.ui.ds2.row.TangemRowText import com.tangem.core.ui.ds2.row.TangemRowTextRole import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment -import com.tangem.core.ui.ds2.shimmers.RectangleShimmer -import com.tangem.core.ui.ds2.shimmers.TextShimmer -import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle +import com.tangem.core.ui.ds2.shimmers.TangemShimmer import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme @@ -175,12 +173,7 @@ private fun GroupTitleBlock( .fillMaxWidth() .padding(top = 6.dp, start = 16.dp), ) { - TextShimmer( - modifier = Modifier.width(TangemTheme.dimens2.x10), - text = state.title, - style = TextShimmerStyle.SUBHEADING, - radius = TangemTheme.dimens2.x25, - ) + TangemShimmer(style = TangemTheme.typography3.subheading.medium) } } else { Text( @@ -224,7 +217,7 @@ private fun Icon(state: TangemPayTransactionState, modifier: Modifier = Modifier iconState = state.iconV2, modifier = modifier, ) - is TangemPayTransactionState.Loading -> RectangleShimmer( + is TangemPayTransactionState.Loading -> TangemShimmer( modifier = modifier.size(TangemTheme.dimens2.x10), radius = TangemTheme.dimens2.x25, ) @@ -266,12 +259,7 @@ private fun Title(state: TangemPayTransactionState, modifier: Modifier = Modifie TangemRowText(text = state.title.resolveReference(), role = TangemRowTextRole.Title) } is TangemPayTransactionState.Loading -> { - TextShimmer( - modifier = modifier, - text = "Transfer", - radius = TangemTheme.dimens2.x25, - style = TextShimmerStyle.BODY, - ) + TangemShimmer(modifier = modifier, style = TangemTheme.typography3.body.medium) } } } @@ -283,12 +271,7 @@ private fun Subtitle(state: TangemPayTransactionState, modifier: Modifier = Modi TangemRowText(text = state.subtitle.resolveReference(), role = TangemRowTextRole.Subtitle) } is TangemPayTransactionState.Loading -> { - TextShimmer( - modifier = modifier, - text = "Transfer", - radius = TangemTheme.dimens2.x25, - style = TextShimmerStyle.CAPTION, - ) + TangemShimmer(modifier = modifier, style = TangemTheme.typography3.caption.medium) } } } @@ -306,12 +289,7 @@ private fun Amount(state: TangemPayTransactionState, isBalanceHidden: Boolean, m ) } is TangemPayTransactionState.Loading -> { - TextShimmer( - modifier = modifier, - text = "10000", - radius = TangemTheme.dimens2.x25, - style = TextShimmerStyle.BODY, - ) + TangemShimmer(modifier = modifier, style = TangemTheme.typography3.body.medium) } } } @@ -323,12 +301,7 @@ private fun Timestamp(state: TangemPayTransactionState, modifier: Modifier = Mod TangemRowText(text = state.time, role = TangemRowTextRole.Subvalue) } is TangemPayTransactionState.Loading -> { - TextShimmer( - modifier = modifier, - text = "00:00", - radius = TangemTheme.dimens2.x25, - style = TextShimmerStyle.CAPTION, - ) + TangemShimmer(modifier = modifier, style = TangemTheme.typography3.caption.medium) } } } diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt index 1d461a65c9..2e1b42692f 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt @@ -12,7 +12,6 @@ import com.tangem.core.ui.ds2.fade.TangemFade import com.tangem.core.ui.ds2.loader.TangemLoaderSize import com.tangem.core.ui.ds2.row.TangemRowContentLead import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment -import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation internal sealed interface StoryBookPage @@ -113,16 +112,35 @@ internal data class TangemLoaderStory( @Immutable internal data class TangemShimmerStory( - val textStyle: TextShimmerStyle, + val textStyle: TextStyleOption, + val textPosition: TextPositionOption, val radius: RadiusOption, val rectangleWidth: RectangleWidthOption, val rectangleHeight: RectangleHeightOption, - val onTextStyleChange: (TextShimmerStyle) -> Unit, + val onTextStyleChange: (TextStyleOption) -> Unit, + val onTextPositionChange: (TextPositionOption) -> Unit, val onRadiusChange: (RadiusOption) -> Unit, val onRectangleWidthChange: (RectangleWidthOption) -> Unit, val onRectangleHeightChange: (RectangleHeightOption) -> Unit, ) : DsStoryBookPage { + /** Selectable typography preset for the `TangemShimmer` text variant. */ + enum class TextStyleOption { + DISPLAY, + HEADING_MEDIUM, + HEADING_SMALL, + BODY, + SUBHEADING, + CAPTION, + } + + /** Horizontal position of the `TangemShimmer` text block within the parent width. */ + enum class TextPositionOption(val label: String) { + START("Start"), + CENTER("Center"), + END("End"), + } + /** Selectable corner radius (matches `borderRadius` tokens). */ enum class RadiusOption(val label: String) { R4("4dp"), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/Build.kt index 89745a686a..6804eb3fb2 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/Build.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/Build.kt @@ -1,19 +1,22 @@ package com.tangem.feature.tester.presentation.storybook.page.ds.shimmer -import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle import com.tangem.feature.tester.presentation.storybook.entity.TangemShimmerStory import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory internal fun StateUpdater.build(): TangemShimmerStory { return TangemShimmerStory( - textStyle = TextShimmerStyle.BODY, + textStyle = TangemShimmerStory.TextStyleOption.BODY, + textPosition = TangemShimmerStory.TextPositionOption.START, radius = TangemShimmerStory.RadiusOption.R24, rectangleWidth = TangemShimmerStory.RectangleWidthOption.W240, rectangleHeight = TangemShimmerStory.RectangleHeightOption.H24, onTextStyleChange = { textStyle -> updateStory { it.copy(textStyle = textStyle) } }, + onTextPositionChange = { position -> + updateStory { it.copy(textPosition = position) } + }, onRadiusChange = { radius -> updateStory { it.copy(radius = radius) } }, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/TangemShimmerStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/TangemShimmerStory.kt index fa4e84e5a5..e526c53cb7 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/TangemShimmerStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/TangemShimmerStory.kt @@ -12,9 +12,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.tangem.core.ui.ds2.shimmers.RectangleShimmer -import com.tangem.core.ui.ds2.shimmers.TextShimmer -import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import com.tangem.core.ui.ds2.shimmers.TangemShimmer import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.tester.presentation.storybook.entity.TangemShimmerStory import com.tangem.feature.tester.presentation.storybook.entity.TangemShimmerStory.* @@ -38,12 +38,20 @@ internal fun TangemShimmerStory(state: TangemShimmerStory, modifier: Modifier = ) { ChipSection(label = "Text style") { ChipGrid( - items = TextShimmerStyle.entries, + items = TextStyleOption.entries, label = { it.chipLabel() }, isSelected = { it == state.textStyle }, onSelect = state.onTextStyleChange, ) } + ChipSection(label = "Text position") { + ChipGrid( + items = TextPositionOption.entries, + label = { it.label }, + isSelected = { it == state.textPosition }, + onSelect = state.onTextPositionChange, + ) + } ChipSection(label = "Radius") { ChipGrid( items = RadiusOption.entries, @@ -89,18 +97,17 @@ private fun ComponentPreview(state: TangemShimmerStory) { horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth(), ) { - PreviewLabel(text = "RectangleShimmer") + PreviewLabel(text = "TangemShimmer · rectangle") RectangleShimmerPreview( width = state.rectangleWidth, height = state.rectangleHeight, radius = radius, ) - PreviewLabel(text = "TextShimmer · ${state.textStyle.chipLabel()}") - TextShimmer( - text = SAMPLE_TEXT, - style = state.textStyle, - radius = radius, + PreviewLabel(text = "TangemShimmer · text · ${state.textStyle.chipLabel()}") + TangemShimmer( + style = state.textStyle.toTextStyle(), + textAlign = state.textPosition.toTextAlign(), ) } } @@ -113,7 +120,7 @@ private fun RectangleShimmerPreview(width: RectangleWidthOption, height: Rectang else -> Modifier.width(width.value()) }.height(height.value()) - RectangleShimmer( + TangemShimmer( modifier = sizeModifier, radius = radius, ) @@ -194,13 +201,29 @@ private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier // endregion -private fun TextShimmerStyle.chipLabel(): String = when (this) { - TextShimmerStyle.DISPLAY -> "Display" - TextShimmerStyle.HEADING_MEDIUM -> "Head.M" - TextShimmerStyle.HEADING_SMALL -> "Head.S" - TextShimmerStyle.BODY -> "Body" - TextShimmerStyle.SUBHEADING -> "Sub.H" - TextShimmerStyle.CAPTION -> "Caption" +private fun TextStyleOption.chipLabel(): String = when (this) { + TextStyleOption.DISPLAY -> "Display" + TextStyleOption.HEADING_MEDIUM -> "Head.M" + TextStyleOption.HEADING_SMALL -> "Head.S" + TextStyleOption.BODY -> "Body" + TextStyleOption.SUBHEADING -> "Sub.H" + TextStyleOption.CAPTION -> "Caption" +} + +@Composable +private fun TextStyleOption.toTextStyle(): TextStyle = when (this) { + TextStyleOption.DISPLAY -> TangemTheme.typography3.display.medium + TextStyleOption.HEADING_MEDIUM -> TangemTheme.typography3.heading.medium + TextStyleOption.HEADING_SMALL -> TangemTheme.typography3.heading.small + TextStyleOption.BODY -> TangemTheme.typography3.body.medium + TextStyleOption.SUBHEADING -> TangemTheme.typography3.subheading.medium + TextStyleOption.CAPTION -> TangemTheme.typography3.caption.medium +} + +private fun TextPositionOption.toTextAlign(): TextAlign = when (this) { + TextPositionOption.START -> TextAlign.Start + TextPositionOption.CENTER -> TextAlign.Center + TextPositionOption.END -> TextAlign.End } private fun RadiusOption.value(): Dp = when (this) { @@ -224,6 +247,4 @@ private fun RectangleHeightOption.value(): Dp = when (this) { RectangleHeightOption.H24 -> 24.dp RectangleHeightOption.H40 -> 40.dp RectangleHeightOption.H64 -> 64.dp -} - -private const val SAMPLE_TEXT = "Sample shimmer text" \ No newline at end of file +} \ No newline at end of file From de3cdf0c474cb18a367990f14681ccd86442b29d Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 15:21:16 +0200 Subject: [PATCH 13/43] Updated on 2026-08-14 --- .../datasource/api/ethpool/P2PEthPoolApi.kt | 17 ++ .../request/P2PEthPoolAccountsListRequest.kt | 16 ++ .../P2PEthPoolAccountsListResponse.kt | 26 ++++ .../response/P2PEthPoolErrorResponse.kt | 2 +- .../P2PEthPoolAccountsListResponseTest.kt | 68 ++++++++ .../DefaultMultiStakingBalanceFetcher.kt | 52 +------ .../multi/P2PEthPoolAccountsFetcher.kt | 102 ++++++++++++ .../DefaultMultiStakingBalanceFetcherTest.kt | 145 +++++++++++++++++- 8 files changed, 380 insertions(+), 48 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolAccountsListRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountsListResponse.kt create mode 100644 core/datasource/src/test/kotlin/com/tangem/datasource/api/ethpool/P2PEthPoolAccountsListResponseTest.kt create mode 100644 data/staking/src/main/java/com/tangem/data/staking/multi/P2PEthPoolAccountsFetcher.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt index f76e0a7bfa..b0dacd4aed 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt @@ -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> + + /** + * 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> } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolAccountsListRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolAccountsListRequest.kt new file mode 100644 index 0000000000..25762a5bae --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolAccountsListRequest.kt @@ -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, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountsListResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountsListResponse.kt new file mode 100644 index 0000000000..552497b306 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountsListResponse.kt @@ -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, +) + +@JsonClass(generateAdapter = true) +data class P2PEthPoolAccountListItem( + @Json(name = "delegatorAddress") + val delegatorAddress: String, + @Json(name = "account") + val account: P2PEthPoolAccountResponse?, + @Json(name = "error") + val error: P2PEthPoolErrorDetailsDTO?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolErrorResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolErrorResponse.kt index c99f726d91..a376482c5b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolErrorResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolErrorResponse.kt @@ -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? = null, // Optional validation errors array ) \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/ethpool/P2PEthPoolAccountsListResponseTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/ethpool/P2PEthPoolAccountsListResponseTest.kt new file mode 100644 index 0000000000..dd96bd7ac5 --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/ethpool/P2PEthPoolAccountsListResponseTest.kt @@ -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>( + 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() + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcher.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcher.kt index d329e2bb77..2560fbfbeb 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcher.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcher.kt @@ -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 { TangemLogger.i("Start fetching staking balances for params:\n$params") @@ -195,50 +198,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( vaults: List, addresses: Set, ): Set { - val responses = mutableSetOf() - - 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) { diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/P2PEthPoolAccountsFetcher.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/P2PEthPoolAccountsFetcher.kt new file mode 100644 index 0000000000..1b491ca10f --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/P2PEthPoolAccountsFetcher.kt @@ -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, addresses: Set): Set = + 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>, + ): List { + 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() + } + } + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcherTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcherTest.kt index 3290286794..4b285df8bc 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcherTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcherTest.kt @@ -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, + ) + }, + ), + ), + ) } } \ No newline at end of file From 4cb147b27e66bfc39372dd3d486f654404617cf9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 16:06:33 +0100 Subject: [PATCH 14/43] Updated on 2026-08-14 --- .../send/confirm/model/SendConfirmModel.kt | 9 ++ ...dConfirmationNotificationsTransformerV2.kt | 16 ++- ...firmationNotificationsTransformerV2Test.kt | 110 +++++++++++++++++- .../transfer/SwapTransferInteractorImpl.kt | 28 +++++ .../SwapTransferInteractorImplTest.kt | 55 +++++++++ .../ui/transfer/SwapTransferStateBuilder.kt | 36 +++++- .../transfer/SwapTransferStateBuilderTest.kt | 101 +++++++++++++++- 7 files changed, 345 insertions(+), 10 deletions(-) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt index 90b2c60464..eb9c469636 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt @@ -515,6 +515,13 @@ internal class SendConfirmModel @Inject constructor( feeCryptoCurrencyStatus = getCurrencyStatusForFeePayment(), ), ) + val balance = cryptoCurrencyStatus.value.amount.orZero() + val feeValue = confirmData.fee?.amount?.value.orZero() + val isTotalSendingMoreThanBalance = confirmData.enteredAmount.orZero() + feeValue > balance + val isFeeSubtractedFromAmount = isAmountSubtractAvailable && isTotalSendingMoreThanBalance + // Fee alone can't be covered by the balance → nothing can be sent, footer must show $0. + val isFeeExceedingBalance = isAmountSubtractAvailable && feeValue > balance + _uiState.update { state -> state.copy( confirmUM = SendConfirmationNotificationsTransformerV2( @@ -524,6 +531,8 @@ internal class SendConfirmModel @Inject constructor( cryptoCurrency = cryptoCurrencyStatus.currency, appCurrency = appCurrency, analyticsCategoryName = params.analyticsCategoryName, + isFeeSubtractedFromAmount = isFeeSubtractedFromAmount, + isFeeExceedingBalance = isFeeExceedingBalance, ).transform(uiState.value.confirmUM), ) } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt index 141bcab743..8b1cf5dff5 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt @@ -21,7 +21,9 @@ import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.impl.R import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.toPersistentList +import java.math.BigDecimal +@Suppress("LongParameterList") internal class SendConfirmationNotificationsTransformerV2( private val feeSelectorUM: FeeSelectorUM, private val amountUM: AmountState, @@ -29,6 +31,8 @@ internal class SendConfirmationNotificationsTransformerV2( private val cryptoCurrency: CryptoCurrency, private val appCurrency: AppCurrency, private val analyticsCategoryName: String, + private val isFeeSubtractedFromAmount: Boolean, + private val isFeeExceedingBalance: Boolean, ) : Transformer { override fun transform(prevState: ConfirmUM): ConfirmUM { val state = prevState as? ConfirmUM.Content ?: return prevState @@ -73,10 +77,14 @@ internal class SendConfirmationNotificationsTransformerV2( val isFeeConvertibleToFiat = feeSelectorUM.feeExtraInfo.isFeeConvertibleToFiat - val fiatSendingValue = if (isFeeConvertibleToFiat) { - fiatFeeValue?.let { fiatAmountValue?.plus(it) } - } else { - fiatAmountValue + val fiatSendingValue = when { + !isFeeConvertibleToFiat -> fiatAmountValue + // Fee alone exceeds the balance → the transaction can't go through, nothing is sent. + isFeeExceedingBalance -> BigDecimal.ZERO + // When the fee is subtracted from the amount, the entered amount is the gross that already + // includes the fee, so it must not be added again — that double-counts it. + isFeeSubtractedFromAmount -> fiatAmountValue + else -> fiatFeeValue?.let { fiatAmountValue?.plus(it) } } val fiatSending = fiatSendingValue.format { diff --git a/features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt b/features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt index 9694974592..c7a9edc59d 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt @@ -1,4 +1,4 @@ -package com.tangem.features.send.send.confirm.model.transformers +package com.tangem.features.send.v2.send.confirm.model.transformers import com.google.common.truth.Truth.assertThat import com.tangem.blockchain.common.Amount @@ -8,6 +8,11 @@ import com.tangem.common.ui.amountScreen.models.AmountFieldModel import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -18,7 +23,9 @@ import com.tangem.features.send.api.entity.FeeFiatRateUM import com.tangem.features.send.api.entity.FeeItem import com.tangem.features.send.api.entity.FeeNonce import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.utils.formatFooterFiatFee import com.tangem.features.send.common.ui.state.ConfirmUM +import com.tangem.features.send.impl.R import com.tangem.features.send.send.confirm.model.transformers.SendConfirmationNotificationsTransformerV2 import io.mockk.mockk import io.mockk.verify @@ -77,6 +84,8 @@ class SendConfirmationNotificationsTransformerV2Test { cryptoCurrency = cryptoCurrency, appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, + isFeeSubtractedFromAmount = false, + isFeeExceedingBalance = false, ) val initialState: ConfirmUM = ConfirmUM.Empty @@ -99,6 +108,8 @@ class SendConfirmationNotificationsTransformerV2Test { cryptoCurrency = cryptoCurrency, appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, + isFeeSubtractedFromAmount = false, + isFeeExceedingBalance = false, ) val initialState = createTestConfirmUM() @@ -121,6 +132,8 @@ class SendConfirmationNotificationsTransformerV2Test { cryptoCurrency = cryptoCurrency, appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, + isFeeSubtractedFromAmount = false, + isFeeExceedingBalance = false, ) val initialState = createTestConfirmUM() @@ -134,6 +147,58 @@ class SendConfirmationNotificationsTransformerV2Test { assertThat(content.sendingFooter).isNotEqualTo(initialState.sendingFooter) } + @Test + fun `GIVEN fee subtracted from amount WHEN transform THEN footer sending excludes the fee`() = runTest { + // GIVEN: fee is taken out of the entered amount, so the footer must show the amount alone (not amount + fee). + val feeSelectorUM = createFiatConvertibleFeeSelectorUM(feeValue = BigDecimal("0.001")) + val amountUM = createTestAmountUM() + val transformer = SendConfirmationNotificationsTransformerV2( + feeSelectorUM = feeSelectorUM, + amountUM = amountUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + isFeeSubtractedFromAmount = true, + isFeeExceedingBalance = false, + ) + + // WHEN + val result = transformer.transform(createTestConfirmUM()) + + // THEN: sending = entered fiat amount (50.00), fee NOT added on top. + val content = result as ConfirmUM.Content + assertThat(content.sendingFooter).isEqualTo( + expectedFiatFooter(sendingValue = BigDecimal("50.00"), feeSelectorUM = feeSelectorUM), + ) + } + + @Test + fun `GIVEN fee exceeds balance WHEN transform THEN footer sending is zero`() = runTest { + // GIVEN: the fee alone exceeds the balance → nothing can be sent. + val feeSelectorUM = createFiatConvertibleFeeSelectorUM(feeValue = BigDecimal("0.001")) + val amountUM = createTestAmountUM() + val transformer = SendConfirmationNotificationsTransformerV2( + feeSelectorUM = feeSelectorUM, + amountUM = amountUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + isFeeSubtractedFromAmount = true, + isFeeExceedingBalance = true, + ) + + // WHEN + val result = transformer.transform(createTestConfirmUM()) + + // THEN: sending = $0. + val content = result as ConfirmUM.Content + assertThat(content.sendingFooter).isEqualTo( + expectedFiatFooter(sendingValue = BigDecimal.ZERO, feeSelectorUM = feeSelectorUM), + ) + } + @Test fun `GIVEN fee too high WHEN transform THEN returns state with too high notification`() = runTest { // GIVEN @@ -146,6 +211,8 @@ class SendConfirmationNotificationsTransformerV2Test { cryptoCurrency = cryptoCurrency, appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, + isFeeSubtractedFromAmount = false, + isFeeExceedingBalance = false, ) val initialState = createTestConfirmUM() @@ -171,6 +238,8 @@ class SendConfirmationNotificationsTransformerV2Test { cryptoCurrency = cryptoCurrency, appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, + isFeeSubtractedFromAmount = false, + isFeeExceedingBalance = false, ) val initialState = createTestConfirmUM() @@ -197,6 +266,8 @@ class SendConfirmationNotificationsTransformerV2Test { cryptoCurrency = cryptoCurrency, appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, + isFeeSubtractedFromAmount = false, + isFeeExceedingBalance = false, ) val initialState = createTestConfirmUM() @@ -264,6 +335,43 @@ class SendConfirmationNotificationsTransformerV2Test { ) } + private fun createFiatConvertibleFeeSelectorUM(feeValue: BigDecimal): FeeSelectorUM.Content { + val fee = Fee.Common(amount = Amount(currencySymbol = "SOL", value = feeValue, decimals = 8)) + return FeeSelectorUM.Content( + isPrimaryButtonEnabled = true, + fees = TransactionFee.Single(fee), + feeItems = persistentListOf(FeeItem.Market(fee)), + selectedFeeItem = FeeItem.Market(fee), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = false, + isFeeConvertibleToFiat = true, + isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, + ), + feeFiatRateUM = FeeFiatRateUM(rate = BigDecimal("50000"), appCurrency = appCurrency), + feeNonce = FeeNonce.Nonce(nonce = BigInteger.ZERO, onNonceChange = {}), + ) + } + + /** Builds the expected footer reference for a fiat-convertible fee, mirroring the transformer's formatting. */ + private fun expectedFiatFooter(sendingValue: BigDecimal, feeSelectorUM: FeeSelectorUM.Content): TextReference { + val fee = feeSelectorUM.selectedFeeItem.fee + val fiatFeeValue = fee.amount.value?.multiply(feeSelectorUM.feeFiatRateUM!!.rate) + val sending = sendingValue.format { + fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) + } + val feeText = formatFooterFiatFee( + amount = fee.amount.copy(value = fiatFeeValue), + isFeeConvertibleToFiat = true, + isFeeApproximate = feeSelectorUM.feeExtraInfo.isFeeApproximate, + appCurrency = appCurrency, + ) + return resourceReference( + id = R.string.send_summary_transaction_description, + formatArgs = wrappedList(sending, feeText), + ) + } + private fun createNormalFeeSelectorUM(): FeeSelectorUM.Content { val fee = Fee.Common( amount = Amount( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index 2e2b19ef89..d8d6560555 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -37,6 +37,7 @@ import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase import com.tangem.domain.utils.convertToSdkAmount @@ -69,6 +70,7 @@ class SwapTransferInteractorImpl @Inject constructor( private val getTronFeeNotificationShowCountUseCase: GetTronFeeNotificationShowCountUseCase, private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase, private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase, + private val validateTransactionUseCase: ValidateTransactionUseCase, ) : SwapTransferInteractor { @Suppress("LongMethod") @@ -139,6 +141,13 @@ class SwapTransferInteractorImpl @Inject constructor( userWalletId = toSwapCurrencyStatus.userWalletId, currency = toToken, ).getOrNull() is AssetRequirementsCondition.RequiredTrustline + val validationResult = manageTransactionValidationWarnings( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + destinationAddress = toSwapCurrencyStatus.destinationAddress(), + amount = fromTokenInfo.tokenAmount, + fee = fee, + ) + val minAdaValue = (fee as? Fee.CardanoToken)?.minAdaValue return SwapState.Transfer( userWallet = userWallet, fromTokenInfo = fromTokenInfo, @@ -154,10 +163,29 @@ class SwapTransferInteractorImpl @Inject constructor( isAmountSubtractAvailable = isAmountSubtractAvailable, isSendingAmountLoading = coverageState.isSendingAmountLoading, currencyCheck = currencyCheck, + validationResult = validationResult, + minAdaValue = minAdaValue, hasRequiredTrustline = hasRequiredTrustline, ) } + private suspend fun manageTransactionValidationWarnings( + fromSwapCurrencyStatus: SwapCurrencyStatus, + destinationAddress: String?, + amount: SwapAmount, + fee: Fee?, + ): Throwable? { + destinationAddress ?: return null + return validateTransactionUseCase( + amount = amount.value.convertToSdkAmount(fromSwapCurrencyStatus.status), + fee = fee, + memo = null, + destination = destinationAddress, + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = fromSwapCurrencyStatus.currency.network, + ).leftOrNull() + } + private suspend fun getCryptoCurrencyWarning( feeValue: BigDecimal, userWallet: UserWallet, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index 61779c4f4f..b01d73b13d 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -31,6 +31,7 @@ import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase import com.tangem.feature.swap.domain.fee.TransactionFeeResult @@ -68,6 +69,7 @@ internal class SwapTransferInteractorImplTest { private val getTronFeeNotificationShowCountUseCase: GetTronFeeNotificationShowCountUseCase = mockk(relaxed = true) private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase = mockk(relaxed = true) private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase = mockk() + private val validateTransactionUseCase: ValidateTransactionUseCase = mockk() private val sut = SwapTransferInteractorImpl( swapFeatureToggles = swapFeatureToggles, @@ -86,11 +88,13 @@ internal class SwapTransferInteractorImplTest { getTronFeeNotificationShowCountUseCase = getTronFeeNotificationShowCountUseCase, incrementNotificationsShowCountUseCase = incrementNotificationsShowCountUseCase, getAssetRequirementsUseCase = getAssetRequirementsUseCase, + validateTransactionUseCase = validateTransactionUseCase, ) @BeforeEach fun setup() { coEvery { getAssetRequirementsUseCase(any(), any()) } returns null.right() + coEvery { validateTransactionUseCase(any(), any(), any(), any(), any(), any()) } returns Unit.right() } @AfterEach @@ -500,6 +504,57 @@ internal class SwapTransferInteractorImplTest { } } + @Test + fun `GIVEN Cardano token fee WHEN updateTransfer THEN minAdaValue flows through to Transfer state`() = runTest { + // Arrange + val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") + val userWallet: UserWallet = mockk(relaxed = true) + val expectedMinAdaValue = BigDecimal("1.444443") + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + fiatRate = BigDecimal.TEN, + amount = BigDecimal("1.6"), + userWallet = userWallet, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + userWallet = userWallet, + ) + val fee: Fee.CardanoToken = mockk(relaxed = true) { + every { amount.value } returns BigDecimal("0.2") + every { minAdaValue } returns expectedMinAdaValue + } + every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right()) + every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false) + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false + coEvery { + getCurrencyCheckUseCase( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any(), + ) + } returns buildCurrencyCheck() + coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns false.right() + + // Act + val result = sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = "1,5", + feePaidCurrencyStatus = null, + fee = fee, + ) as SwapState.Transfer + + // Assert + assertThat(result.minAdaValue).isEqualTo(expectedMinAdaValue) + } + // endregion // region loadFee diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index e292f5585f..1897a8d200 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -364,6 +364,8 @@ internal class SwapTransferStateBuilder @Inject constructor( fee = fee, tokenSwapInfo = transferState.fromTokenInfo, appCurrency = transferState.appCurrency, + isFeeSubtractedFromAmount = isFeeSubtractedFromAmount(transferState, fee), + isFeeExceedingBalance = isFeeExceedingBalance(transferState, fee), ), ) } @@ -384,11 +386,34 @@ internal class SwapTransferStateBuilder @Inject constructor( } } + private fun isFeeSubtractedFromAmount(transferState: SwapState.Transfer, fee: Fee?): Boolean { + if (!transferState.isAmountSubtractAvailable || fee == null) return false + val swapCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus + val balance = swapCurrencyStatus.status.value.amount.orZero() + val amountValue = transferState.fromTokenInfo.tokenAmount.value + return amountValue + fee.amount.value.orZero() > balance + } + + /** + * True when the fee alone exceeds the balance: nothing can be sent (not even enough to cover the fee), so + * the footer must show $0 instead of a positive total. Only meaningful when the fee is paid from the same + * balance (subtraction available). + */ + private fun isFeeExceedingBalance(transferState: SwapState.Transfer, fee: Fee?): Boolean { + if (!transferState.isAmountSubtractAvailable || fee == null) return false + val swapCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus + val balance = swapCurrencyStatus.status.value.amount.orZero() + return fee.amount.value.orZero() > balance + } + + @Suppress("LongParameterList") private fun getSendingFooterText( dataState: SwapProcessDataState, fee: Fee?, tokenSwapInfo: TokenSwapInfo, appCurrency: AppCurrency, + isFeeSubtractedFromAmount: Boolean, + isFeeExceedingBalance: Boolean, ): TextReference? { if (fee == null) return null @@ -398,10 +423,13 @@ internal class SwapTransferStateBuilder @Inject constructor( val fiatFeeValue = value?.fiatRate?.multiply(fee.amount.value) val isFeeConvertibleToFiat = status.currency.network.hasFiatFeeRate - val fiatSendingValue = if (isFeeConvertibleToFiat) { - fiatFeeValue?.let { fiatAmountValue.plus(it) } - } else { - fiatAmountValue + val fiatSendingValue = when { + !isFeeConvertibleToFiat -> fiatAmountValue + // Fee alone exceeds the balance → the transaction can't go through, nothing is sent. + isFeeExceedingBalance -> BigDecimal.ZERO + // Fee is taken out of the entered amount → it already includes the fee, don't add it again. + isFeeSubtractedFromAmount -> fiatAmountValue + else -> fiatFeeValue?.let { fiatAmountValue.plus(it) } } val fiatSending = fiatSendingValue.format { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt index 9b0c42f5dd..930fa03b39 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -590,6 +590,104 @@ internal class SwapTransferStateBuilderTest { ) } + @Test + fun `GIVEN fee subtracted from amount WHEN updateTransferButtonEnableState THEN footer sending excludes the fee`() = + runTest { + // Arrange: subtraction available + amount + fee exceeds balance (1.0), but fee (0.5) <= balance. + // The entered amount is the gross that already includes the fee, so the footer must not add it again. + val fromAmount = BigDecimal("1.0") + val transferState = buildTransferState( + fromAmount = fromAmount, + toAmount = fromAmount, + isAccountsMode = false, + isAmountSubtractAvailable = true, + ) + val feePaidStatus = buildSwapCurrencyStatus(coldWallet) + val feePaidRate = feePaidStatus.status.value.fiatRate!! + val dataState = SwapProcessDataState( + fromSwapCurrencyStatus = buildStatusWithNetwork(hasFiatFeeRate = true), + feePaidCryptoCurrency = feePaidStatus.status, + ) + val feeValue = BigDecimal("0.5") + val fee = Fee.Common(amount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18)) + val appCurrency = transferState.appCurrency + // Sending is the entered amount only — the fee is NOT added on top. + val expectedFiatSending = (fromAmount * QUOTE).format { + fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) + } + val expectedFiatFee = feePaidRate.multiply(feeValue).format { + fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) + } + + // Act + val result = sut.updateTransferButtonEnableState( + dataState = dataState, + transferState = transferState, + actions = actions, + uiStateHolder = baseStateHolder(), + feePaidCryptoCurrencyStatus = null, + fee = fee, + isTangemPayWithdrawal = false, + feeSelectorUM = null, + ) + + // Assert + assertThat(result.transferFooter).isEqualTo( + resourceReference( + id = com.tangem.features.send.impl.R.string.send_summary_transaction_description, + formatArgs = wrappedList(expectedFiatSending, expectedFiatFee), + ), + ) + } + + @Test + fun `GIVEN fee exceeds balance WHEN updateTransferButtonEnableState THEN footer sending is zero`() = + runTest { + // Arrange: subtraction available + fee (2.0) exceeds balance (1.0) → nothing can be sent. + val fromAmount = BigDecimal("1.0") + val transferState = buildTransferState( + fromAmount = fromAmount, + toAmount = fromAmount, + isAccountsMode = false, + isAmountSubtractAvailable = true, + ) + val feePaidStatus = buildSwapCurrencyStatus(coldWallet) + val feePaidRate = feePaidStatus.status.value.fiatRate!! + val dataState = SwapProcessDataState( + fromSwapCurrencyStatus = buildStatusWithNetwork(hasFiatFeeRate = true), + feePaidCryptoCurrency = feePaidStatus.status, + ) + val feeValue = BigDecimal("2.0") + val fee = Fee.Common(amount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18)) + val appCurrency = transferState.appCurrency + val expectedFiatSending = BigDecimal.ZERO.format { + fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) + } + val expectedFiatFee = feePaidRate.multiply(feeValue).format { + fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) + } + + // Act + val result = sut.updateTransferButtonEnableState( + dataState = dataState, + transferState = transferState, + actions = actions, + uiStateHolder = baseStateHolder(), + feePaidCryptoCurrencyStatus = null, + fee = fee, + isTangemPayWithdrawal = false, + feeSelectorUM = null, + ) + + // Assert + assertThat(result.transferFooter).isEqualTo( + resourceReference( + id = com.tangem.features.send.impl.R.string.send_summary_transaction_description, + formatArgs = wrappedList(expectedFiatSending, expectedFiatFee), + ), + ) + } + @Test fun `GIVEN non-Tron fee and non-fiat-convertible network WHEN updateTransferButtonEnableState THEN transferFooter uses no-fiat-fee description`() = runTest { @@ -919,6 +1017,7 @@ internal class SwapTransferStateBuilderTest { isInsufficientBalance: Boolean = false, isFeeCoverage: Boolean = false, isSendingAmountLoading: Boolean = false, + isAmountSubtractAvailable: Boolean = false, ): SwapState.Transfer { val fromInfo = TokenSwapInfo( tokenAmount = SwapAmount(value = fromAmount, decimals = fromCurrencyStatus.currency.decimals), @@ -942,7 +1041,7 @@ internal class SwapTransferStateBuilderTest { isFeeCoverage = isFeeCoverage, sendingAmount = toAmount, tronFeeNotificationShowCount = 0, - isAmountSubtractAvailable = false, + isAmountSubtractAvailable = isAmountSubtractAvailable, isSendingAmountLoading = isSendingAmountLoading, ) } From e0c6728675959d3be9085020bb5e7b40f0748d6a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 18:37:50 +0200 Subject: [PATCH 15/43] Updated on 2026-08-14 --- .../tangempay/ui/TangemPayCardDetailsBlock.kt | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index c6a024f59e..f20d332c8d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -115,6 +115,7 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M @Suppress("LongMethod", "DestructuringDeclarationWithTooManyEntries") @Composable private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) { + val isRedesignEnabled = LocalVisaRedesignEnabled.current Box(modifier = modifier.fillMaxSize()) { TangemPayCardBackground( modifier = Modifier @@ -158,20 +159,24 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif ) when (state.cardFrozenState) { - TangemPayCardFrozenState.Frozen -> Icon( - modifier = Modifier - .constrainAs(frozenIconRef) { - start.linkTo(cardNumberRef.end, margin = 4.dp) - top.linkTo(cardNumberRef.top) - bottom.linkTo(cardNumberRef.bottom) - } - .padding(bottom = 8.dp) - .size(16.dp) - .testTag(TangemPayTestTags.CARD_FROZEN_BADGE), - painter = painterResource(id = R.drawable.ic_snow_24), - contentDescription = null, - tint = TangemTheme.colors.icon.constant, - ) + TangemPayCardFrozenState.Frozen -> { + if (!isRedesignEnabled) { + Icon( + modifier = Modifier + .constrainAs(frozenIconRef) { + start.linkTo(cardNumberRef.end, margin = 4.dp) + top.linkTo(cardNumberRef.top) + bottom.linkTo(cardNumberRef.bottom) + } + .padding(bottom = 8.dp) + .size(16.dp) + .testTag(TangemPayTestTags.CARD_FROZEN_BADGE), + painter = painterResource(id = R.drawable.ic_snow_24), + contentDescription = null, + tint = TangemTheme.colors.icon.constant, + ) + } + } TangemPayCardFrozenState.Pending -> CircularProgressIndicator( modifier = Modifier .constrainAs(frozenIconRef) { @@ -194,7 +199,7 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif bottom.linkTo(parent.bottom) } .testTag(TangemPayTestTags.CARD_DETAILS_SHOW_BUTTON), - visible = !LocalVisaRedesignEnabled.current || + visible = !isRedesignEnabled || state.isLoading || state.shouldShowCardDetailsButtonOnCard, ) { From 79431d6b0a28cf33c2e3da81d5c6e21a272370d4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 18:38:15 +0200 Subject: [PATCH 16/43] Updated on 2026-08-14 --- .../DefaultMarketsTokenDetailsComponent.kt | 78 ++--------- .../details/MarketsTokenDetailsModel.kt | 14 ++ .../detailed/MarketsTokenDetailsTitle.kt | 122 ++++++++++++++++++ .../preview/MarketsTokenDetailsPreview.kt | 4 + .../detailed/state/MarketsTokenDetailsUM.kt | 2 + 5 files changed, 150 insertions(+), 70 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsTitle.kt diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt index 32e69dc413..efbc99093b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt @@ -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 @@ -187,58 +171,12 @@ internal class DefaultMarketsTokenDetailsComponent( @Composable override fun Title(bottomSheetState: State) { 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() }, - isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED, - shouldShowPriceSubtitle = state.shouldShowPriceSubtitle, - tokenName = state.tokenName, - tokenPrice = state.priceText, - backgroundColor = background, - onShareClick = state.onShareClick, - ) - } + MarketsTokenDetailsTitle( + state = state, + backgroundColor = LocalMainBottomSheetColor.current.value, + isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED, + onBackClick = { params.onBackClicked() }, + ) } @Composable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt index b8b2361bed..a87365998c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -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) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsTitle.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsTitle.kt new file mode 100644 index 0000000000..95f163f3dc --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsTitle.kt @@ -0,0 +1,122 @@ +package com.tangem.features.feed.ui.market.detailed + +import androidx.compose.animation.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.haze.hazeEffectTangem +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.LocalRedesignEnabled +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_sign_plus_24 +import com.tangem.features.feed.ui.LocalIsOpenedInBottomSheet +import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM + +@Composable +internal fun MarketsTokenDetailsTitle( + state: MarketsTokenDetailsUM, + backgroundColor: Color, + isBackButtonEnabled: Boolean, + onBackClick: () -> Unit, +) { + if (LocalRedesignEnabled.current) { + MarketsTokenDetailsRedesignTopBar( + isAddToPortfolioButtonVisible = state.isAddToPortfolioButtonVisible, + onAddToPortfolioClick = state.onAddToPortfolioClick, + onShareClick = state.onShareClick, + isBackButtonEnabled = isBackButtonEnabled, + onBackClick = onBackClick, + ) + } else { + MarketsTokenDetailsTopBar( + onBackClick = onBackClick, + isBackButtonEnabled = isBackButtonEnabled, + shouldShowPriceSubtitle = state.shouldShowPriceSubtitle, + tokenName = state.tokenName, + tokenPrice = state.priceText, + backgroundColor = backgroundColor, + onShareClick = state.onShareClick, + ) + } +} + +@Composable +private fun MarketsTokenDetailsRedesignTopBar( + isAddToPortfolioButtonVisible: Boolean, + onAddToPortfolioClick: () -> Unit, + onShareClick: () -> Unit, + isBackButtonEnabled: Boolean, + onBackClick: () -> Unit, +) { + TangemTopBar( + startContent = { + TopBarHazeIconButton( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28), + onClick = onBackClick, + enabled = isBackButtonEnabled, + contentPadding = TangemTheme.dimens2.x2, + ) + }, + endContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AnimatedVisibility( + visible = isAddToPortfolioButtonVisible, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut(), + ) { + TopBarHazeIconButton( + imageVector = Icons.ic_sign_plus_24, + onClick = onAddToPortfolioClick, + enabled = isBackButtonEnabled, + contentPadding = TangemTheme.dimens2.x2_5, + ) + } + TopBarHazeIconButton( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_share_new_24), + onClick = onShareClick, + enabled = isBackButtonEnabled, + contentPadding = TangemTheme.dimens2.x2_5, + ) + } + }, + type = if (LocalIsOpenedInBottomSheet.current) { + TangemTopBarType.BottomSheet + } else { + TangemTopBarType.Default + }, + ) +} + +@Composable +private fun TopBarHazeIconButton(imageVector: ImageVector, onClick: () -> Unit, enabled: Boolean, contentPadding: Dp) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primary, + modifier = Modifier + .size(TangemTheme.dimens2.x11) + .clip(CircleShape) + .hazeEffectTangem { blurRadius = 8.dp } + .clickableSingle(onClick = onClick, enabled = enabled) + .padding(contentPadding), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt index c73cfa497c..c36104bfdc 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt @@ -51,6 +51,8 @@ internal object MarketsTokenDetailsPreview { onScroll = {}, ), onShareClick = {}, + isAddToPortfolioButtonVisible = false, + onAddToPortfolioClick = {}, priceAnnotated = stringReference("$0.00000000324"), ) @@ -147,6 +149,8 @@ internal object MarketsTokenDetailsPreview { onScroll = {}, ), onShareClick = {}, + isAddToPortfolioButtonVisible = false, + onAddToPortfolioClick = {}, priceAnnotated = stringReference("$0.00000000324"), ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt index a9e4a602a4..08786be8d9 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt @@ -32,6 +32,8 @@ internal data class MarketsTokenDetailsUM( val onShouldShowPriceSubtitleChange: (Boolean) -> Unit, val relatedNews: RelatedNews, val onShareClick: () -> Unit, + val isAddToPortfolioButtonVisible: Boolean, + val onAddToPortfolioClick: () -> Unit, val scrollToSection: StateEvent = consumedEvent(), ) { From dc00adfa0f878d03aff1174d2151de113c75c885 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 18:38:40 +0200 Subject: [PATCH 17/43] Updated on 2026-08-14 --- .../market/detailed/MarketsTokenDetailsContent.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt index 09ac1d2352..d6f674062c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt @@ -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) From 0029b514bd8ba797eafdafdfa03a4e051cb5ef51 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 22:44:22 +0200 Subject: [PATCH 18/43] Updated on 2026-08-14 --- .../status/di/AccountStatusUseCaseModule.kt | 4 +- .../usecase/IsAccountsModeEnabledUseCase.kt | 41 ++-- .../IsAccountsModeEnabledUseCaseTest.kt | 182 +++++++----------- 3 files changed, 90 insertions(+), 137 deletions(-) diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt index 859541ef01..9be91f042e 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt @@ -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, ) } diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt index fc345758ff..5433fc0409 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt @@ -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 { + private fun createFlow(): Flow { 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 { + 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" } } \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt index 1bbc3b9ff4..c679f4bcee 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt @@ -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()) - 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()) - 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()) - 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()) - 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()) - 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()) - - val actual = useCase.invokeSync() + every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list)) + mockPaymentStatus(WALLET_ID_1, mockk()) + 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()) + @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 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()) - - val actual = useCase.invokeSync() - - Truth.assertThat(actual).isTrue() - } - - @Test - fun `returns true when multiple wallets and one has loaded payment`() = runTest { - val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio())) - val list2 = createAccountList(WALLET_ID_2, listOf(mockCryptoPortfolio(), mockPaymentAccount())) - coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list1, list2) - mockPaymentStatusSync(WALLET_ID_2, mockk()) - - 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 { every { this@mockk.value } returns value } - every { paymentAccountStatusSupplier.invoke(walletId) } returns flowOf(status) - } - - private fun mockPaymentStatusSync(walletId: UserWalletId, value: PaymentAccountStatusValue) { - val status = mockk { 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): AccountList = mockk { From 3524a3bd860265178bee465e758f291cb70eb87c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 12:48:45 +0400 Subject: [PATCH 19/43] Updated on 2026-08-14 --- .../fetcher/DefaultWalletAccountsFetcher.kt | 6 ++++++ .../FetchWalletAccountsErrorHandler.kt | 16 ++++++++++++++-- .../wallet/DefaultWalletServerBinder.kt | 15 ++++++++++++++- .../viewmodels/FeatureTogglesViewModel.kt | 19 +++++++++++++++++-- .../intents/WalletWarningsClickIntents.kt | 13 +++++++------ .../domain/GetMultiWalletWarningsFactory.kt | 5 ++++- .../domain/GetWalletNotificationsFactory.kt | 5 ++++- 7 files changed, 66 insertions(+), 13 deletions(-) diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt index f7901863e9..a13d84a125 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt @@ -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 } diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt index 361622055b..38bb45edde 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -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 } } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinder.kt b/data/common/src/main/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinder.kt index 57272ffb53..a77d02d27a 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinder.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinder.kt @@ -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? { - 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()}", + ) } } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt index c97999b6f1..cc090e4f0e 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt @@ -11,10 +11,11 @@ import com.tangem.core.navigation.finisher.AppFinisher import com.tangem.feature.tester.impl.R import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWithRefreshUM import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureToggleGroupUM -import com.tangem.feature.tester.presentation.featuretoggles.state.TesterFeatureToggleUM import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureTogglesScreenUM +import com.tangem.feature.tester.presentation.featuretoggles.state.TesterFeatureToggleUM import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter import com.tangem.utils.info.AppInfoProvider +import com.tangem.utils.logging.TangemLogger import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -40,7 +41,7 @@ internal class FeatureTogglesViewModel @Inject constructor( ) : ViewModel() { // Declared before `state` so it is initialized before `initState()` runs in the field initializer. - private val appVersion: Version? = Version.create(appInfoProvider.appVersion) + private val appVersion: Version? = createAppVersion() val state: StateFlow field = MutableStateFlow(initState()) @@ -137,6 +138,20 @@ internal class FeatureTogglesViewModel @Inject constructor( .toImmutableList() } + private fun createAppVersion(): Version? { + val rawVersion = appInfoProvider.appVersion + // Drop the build-type suffix (e.g. "6.0-internal" -> "6.0") so the version parses, mirroring + // DefaultVersionProvider; otherwise every toggle falls back to the "Not planned yet" group. + val sanitizedVersion = rawVersion.substringBefore(delimiter = '-') + val version = Version.create(sanitizedVersion) + + if (version == null) { + TangemLogger.e("Failed to parse app version: raw=$rawVersion, sanitized=$sanitizedVersion") + } + + return version + } + private fun statusOf(toggleVersion: String): TesterFeatureToggleUM.Status { val toggle = parseToggleVersion(toggleVersion) ?: return TesterFeatureToggleUM.Status.UNDEFINED val app = appVersion ?: return TesterFeatureToggleUM.Status.UNDEFINED diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index 8f98d6d139..5325ece800 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -60,7 +60,7 @@ internal interface WalletWarningsClickIntents { fun onCloseAlreadySignedHashesWarningClick() - fun onGenerateMissedAddressesClick(missedAddressCurrencies: List) + fun onGenerateMissedAddressesClick(userWalletId: UserWalletId, missedAddressCurrencies: List) fun onOpenUnlockWalletsBottomSheetClick() @@ -164,19 +164,20 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( } } - override fun onGenerateMissedAddressesClick(missedAddressCurrencies: List) { + override fun onGenerateMissedAddressesClick( + userWalletId: UserWalletId, + missedAddressCurrencies: List, + ) { analyticsEventHandler.send(MainScreen.NoticeScanYourCardTapped()) modelScope.launch { - val userWallet = getSelectedUserWallet() ?: return@launch - derivePublicKeysUseCase( - userWalletId = userWallet.walletId, + userWalletId = userWalletId, currencies = missedAddressCurrencies, ).fold( ifLeft = { TangemLogger.e("Failed to derive public keys", it) }, ifRight = { - fetchCryptoCurrencies(userWalletId = userWallet.walletId, currencies = missedAddressCurrencies) + fetchCryptoCurrencies(userWalletId = userWalletId, currencies = missedAddressCurrencies) }, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index 4b9931244c..b488d4e170 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -336,7 +336,10 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( tangemIcon = walletInterationIcon(userWallet), missingAddressesCount = currencies.count(), onGenerateClick = { - clickIntents.onGenerateMissedAddressesClick(missedAddressCurrencies = currencies) + clickIntents.onGenerateMissedAddressesClick( + userWalletId = userWallet.walletId, + missedAddressCurrencies = currencies, + ) }, ), condition = currencies.isNotEmpty(), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt index 5085257879..a8b6e590d5 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt @@ -215,7 +215,10 @@ internal class GetWalletNotificationsFactory @Inject constructor( tangemIcon = walletInterationIcon(userWallet), missingAddressesCount = currencies.count(), onGenerateClick = { - clickIntents.onGenerateMissedAddressesClick(missedAddressCurrencies = currencies) + clickIntents.onGenerateMissedAddressesClick( + userWalletId = userWallet.walletId, + missedAddressCurrencies = currencies, + ) }, ), condition = currencies.isNotEmpty(), From d11e99d01ed93232dc62ac584afa0958d8bd043e Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 11:49:36 +0100 Subject: [PATCH 20/43] Updated on 2026-08-14 --- .../transfer/SwapTransferInteractorImpl.kt | 23 +- .../SwapTransferInteractorImplTest.kt | 207 ++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index d8d6560555..0c1371e0ad 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -114,7 +114,12 @@ class SwapTransferInteractorImpl @Inject constructor( feeCurrencyStatus = feePaidCurrencyStatus, amount = fromTokenAmountValue, fee = warningsFee, - feeCurrencyBalanceAfterTransaction = null, + feeCurrencyBalanceAfterTransaction = getFeeCurrencyBalanceAfterTx( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + feePaidCurrencyStatus = feePaidCurrencyStatus, + sendingAmount = fromTokenAmountValue, + feeValue = fee?.amount?.value, + ), recipientAddress = toSwapCurrencyStatus.destinationAddress(), ) val isAmountSubtractAvailable = isAmountSubtractAvailable( @@ -169,6 +174,22 @@ class SwapTransferInteractorImpl @Inject constructor( ) } + private fun getFeeCurrencyBalanceAfterTx( + fromSwapCurrencyStatus: SwapCurrencyStatus, + feePaidCurrencyStatus: CryptoCurrencyStatus?, + sendingAmount: BigDecimal, + feeValue: BigDecimal?, + ): BigDecimal? { + val feeCurrencyBalance = feePaidCurrencyStatus?.value as? CryptoCurrencyStatus.Loaded ?: return null + if (feeValue == null) return null + val isFeeInFromToken = feePaidCurrencyStatus.currency.id == fromSwapCurrencyStatus.currency.id + return if (isFeeInFromToken) { + feeCurrencyBalance.amount - sendingAmount - feeValue + } else { + feeCurrencyBalance.amount - feeValue + } + } + private suspend fun manageTransactionValidationWarnings( fromSwapCurrencyStatus: SwapCurrencyStatus, destinationAddress: String?, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index b01d73b13d..d5c42f5ef9 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -555,6 +555,175 @@ internal class SwapTransferInteractorImplTest { assertThat(result.minAdaValue).isEqualTo(expectedMinAdaValue) } + @Test + fun `GIVEN fee paid in the from-currency WHEN updateTransfer THEN feeCurrencyBalanceAfterTx is balance minus amount minus fee`() = + runTest { + // Arrange + val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") + val userWallet: UserWallet = mockk(relaxed = true) + val feeBalance = BigDecimal("2.0") + val feeValue = BigDecimal("0.1") + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + fiatRate = BigDecimal.TEN, + amount = feeBalance, + userWallet = userWallet, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + userWallet = userWallet, + ) + // fee is paid in the same currency as the from-token → identical currency id → the sending + // amount is deducted from the fee balance too. + val feePaidCurrencyStatus = buildFeeCurrencyStatus( + currency = fromCurrencyStatus.currency, + amount = feeBalance, + ) + val fee: Fee = mockk(relaxed = true) { every { amount.value } returns feeValue } + stubBaseFlows(appCurrency) + coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns false.right() + coEvery { getBalanceNotEnoughForFeeWarningUseCase(any(), any(), any(), any()) } returns null.right() + val feeBalances = mutableListOf() + stubGetCurrencyCheckCapturingFeeBalance(feeBalances) + + // Act + sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = "1.5", + feePaidCurrencyStatus = feePaidCurrencyStatus, + fee = fee, + ) + + // Assert + assertThat(feeBalances.single()).isEqualTo(feeBalance - BigDecimal("1.5") - feeValue) + } + + @Test + fun `GIVEN fee paid in a different currency WHEN updateTransfer THEN feeCurrencyBalanceAfterTx is fee balance minus fee only`() = + runTest { + // Arrange + val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") + val userWallet: UserWallet = mockk(relaxed = true) + val feeBalance = BigDecimal("2.0") + val feeValue = BigDecimal("0.1") + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + fiatRate = BigDecimal.TEN, + amount = BigDecimal("5.0"), + userWallet = userWallet, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + userWallet = userWallet, + ) + // fee is paid in a separate currency (distinct id) → the sending amount must NOT be + // deducted from the fee balance. + val feePaidCurrencyStatus = buildFeeCurrencyStatus( + currency = buildDistinctCoin(), + amount = feeBalance, + ) + val fee: Fee = mockk(relaxed = true) { every { amount.value } returns feeValue } + stubBaseFlows(appCurrency) + coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns false.right() + coEvery { getBalanceNotEnoughForFeeWarningUseCase(any(), any(), any(), any()) } returns null.right() + val feeBalances = mutableListOf() + stubGetCurrencyCheckCapturingFeeBalance(feeBalances) + + // Act + sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = "1.5", + feePaidCurrencyStatus = feePaidCurrencyStatus, + fee = fee, + ) + + // Assert + assertThat(feeBalances.single()).isEqualTo(feeBalance - feeValue) + } + + @Test + fun `GIVEN no fee currency status WHEN updateTransfer THEN feeCurrencyBalanceAfterTx is null`() = runTest { + // Arrange + val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") + val userWallet: UserWallet = mockk(relaxed = true) + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + fiatRate = BigDecimal.TEN, + amount = BigDecimal("2.0"), + userWallet = userWallet, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + userWallet = userWallet, + ) + val fee: Fee = mockk(relaxed = true) { every { amount.value } returns BigDecimal("0.1") } + stubBaseFlows(appCurrency) + coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns false.right() + val feeBalances = mutableListOf() + stubGetCurrencyCheckCapturingFeeBalance(feeBalances) + + // Act + sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = "1.5", + feePaidCurrencyStatus = null, + fee = fee, + ) + + // Assert + assertThat(feeBalances.single()).isNull() + } + + @Test + fun `GIVEN fee currency present but fee not loaded WHEN updateTransfer THEN feeCurrencyBalanceAfterTx is null`() = + runTest { + // Arrange + val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") + val userWallet: UserWallet = mockk(relaxed = true) + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + fiatRate = BigDecimal.TEN, + amount = BigDecimal("2.0"), + userWallet = userWallet, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + userWallet = userWallet, + ) + val feePaidCurrencyStatus = buildFeeCurrencyStatus( + currency = buildDistinctCoin(), + amount = BigDecimal("2.0"), + ) + stubBaseFlows(appCurrency) + coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns false.right() + coEvery { getBalanceNotEnoughForFeeWarningUseCase(any(), any(), any(), any()) } returns null.right() + val feeBalances = mutableListOf() + stubGetCurrencyCheckCapturingFeeBalance(feeBalances) + + // Act: fee not loaded yet → feeValue is null → balance-after-tx cannot be computed + sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = "1.5", + feePaidCurrencyStatus = feePaidCurrencyStatus, + fee = null, + ) + + // Assert + assertThat(feeBalances.single()).isNull() + } + // endregion // region loadFee @@ -1123,6 +1292,44 @@ internal class SwapTransferInteractorImplTest { } } + private fun stubBaseFlows(appCurrency: AppCurrency) { + every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right()) + every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false) + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false + } + + private fun stubGetCurrencyCheckCapturingFeeBalance(feeBalances: MutableList) { + coEvery { + getCurrencyCheckUseCase( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = captureNullable(feeBalances), + recipientAddress = any(), + ) + } returns buildCurrencyCheck() + } + + private fun buildFeeCurrencyStatus(currency: CryptoCurrency, amount: BigDecimal): CryptoCurrencyStatus { + val loadedValue: CryptoCurrencyStatus.Loaded = mockk { + every { this@mockk.amount } returns amount + } + return mockk { + every { this@mockk.value } returns loadedValue + every { this@mockk.currency } returns currency + } + } + + private fun buildDistinctCoin(): CryptoCurrency.Coin { + val currencyId: CryptoCurrency.ID = mockk() + return mockk { + every { this@mockk.id } returns currencyId + every { this@mockk.network } returns mockk() + } + } + private fun buildCurrencyCheck( existentialDeposit: BigDecimal? = null, dustValue: BigDecimal? = null, From 34ee61553d20a486ce1cc7b26d14f8542e92230b Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 16:51:35 +0200 Subject: [PATCH 21/43] Updated on 2026-08-14 --- .../WalletBalanceExitUntilCollapsedScrollBehavior.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/collapsing/WalletBalanceExitUntilCollapsedScrollBehavior.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/collapsing/WalletBalanceExitUntilCollapsedScrollBehavior.kt index 99f7321253..06819c6755 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/collapsing/WalletBalanceExitUntilCollapsedScrollBehavior.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/collapsing/WalletBalanceExitUntilCollapsedScrollBehavior.kt @@ -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 { From aecd523c70a0b1f030554e710686e809cbe005bd Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 16:52:19 +0200 Subject: [PATCH 22/43] Updated on 2026-08-14 --- .../ui/ds2/button/TangemButtonInternal.kt | 32 +- .../src/main/res/drawable/ic_card_pin_24.xml | 15 - core/ui/src/main/res/drawable/ic_close_20.xml | 15 - .../src/main/res/drawable/ic_edit_card_20.xml | 15 - .../ui/src/main/res/drawable/ic_freeze_24.xml | 15 - .../src/main/res/drawable/ic_limit_new_20.xml | 15 - core/ui/src/main/res/drawable/ic_mail_20.xml | 15 - .../main/res/drawable/ic_rating_star_24.xml | 15 - .../res/drawable/ic_visa_card_details_24.xml | 15 - .../main/res/drawable/ic_visa_in_banner.xml | 15 - .../ui/src/main/res/drawable/ic_visa_logo.xml | 15 - .../src/main/res/drawable/ic_warning_20.xml | 15 - .../component/DefaultTokenReceiveComponent.kt | 24 +- .../component/TokenReceiveAssetsComponent.kt | 8 +- .../component/TokenReceiveQrCodeComponent.kt | 8 +- .../component/TokenReceiveWarningComponent.kt | 8 +- .../tokenreceive/entity/ReceiveAddress.kt | 1 + .../ui/TokenReceiveAssetsContent.kt | 916 +++++++----------- .../ui/TokenReceiveAssetsContentLegacy.kt | 672 +++++++++++++ .../tokenreceive/ui/TokenReceiveContent.kt | 118 ++- .../ui/TokenReceiveContentLegacy.kt | 94 ++ .../ui/TokenReceiveQrCodeContent.kt | 152 ++- .../ui/TokenReceiveQrCodeContentLegacy.kt | 231 +++++ .../ui/TokenReceiveWarningContent.kt | 87 +- .../ui/TokenReceiveWarningContentLegacy.kt | 139 +++ 25 files changed, 1720 insertions(+), 935 deletions(-) create mode 100644 features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContentLegacy.kt create mode 100644 features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveContentLegacy.kt create mode 100644 features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContentLegacy.kt create mode 100644 features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContentLegacy.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/button/TangemButtonInternal.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/button/TangemButtonInternal.kt index 6ab5a5c86f..8e67699426 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/button/TangemButtonInternal.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/button/TangemButtonInternal.kt @@ -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, + ), ) } } diff --git a/core/ui/src/main/res/drawable/ic_card_pin_24.xml b/core/ui/src/main/res/drawable/ic_card_pin_24.xml index be0bd72728..0678b0492c 100644 --- a/core/ui/src/main/res/drawable/ic_card_pin_24.xml +++ b/core/ui/src/main/res/drawable/ic_card_pin_24.xml @@ -1,18 +1,3 @@ - diff --git a/core/ui/src/main/res/drawable/ic_visa_card_details_24.xml b/core/ui/src/main/res/drawable/ic_visa_card_details_24.xml index 26a48d4fd1..579644aaa1 100644 --- a/core/ui/src/main/res/drawable/ic_visa_card_details_24.xml +++ b/core/ui/src/main/res/drawable/ic_visa_card_details_24.xml @@ -1,18 +1,3 @@ - + key(address.value) { + EnsItem( + modifier = Modifier.padding(horizontal = 16.dp), + onCopyClick = { + assetsUM.onCopyClick(address) + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + coroutineScope.launch { + topSnackbarHostState.showSnackbar( + SnackbarMessage( + startIconId = R.drawable.ic_check_24, + message = resourceReference(R.string.wallet_notification_address_copied), + ), + ) + } + }, + address = address.value, + ) + SpacerH8() + } + } + + PrimaryAddressesItems( + addresses = assetsUM.addresses.fastFilter { it.type is ReceiveAddress.Type.Primary }.toImmutableList(), + currencyIconState = assetsUM.currencyIconState, + onShareClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + assetsUM.onShareClick(it) + }, + onCopyClick = { address -> + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + coroutineScope.launch { + topSnackbarHostState.showSnackbar( + SnackbarMessage(message = resourceReference(R.string.wallet_notification_address_copied)), + ) + } + assetsUM.onCopyClick(address) + }, + onOpenQrCodeClick = { address -> + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + assetsUM.onOpenQrCodeClick(address) + }, + ) +} + +@Suppress("LongParameterList") +@Composable +private fun PrimaryAddressesItems( + addresses: ImmutableList, + currencyIconState: CurrencyIconState, + onShareClick: (String) -> Unit, + onCopyClick: (ReceiveAddress) -> Unit, + onOpenQrCodeClick: (String) -> Unit, +) { + if (addresses.isEmpty()) return + val pagerState = rememberPagerState( + initialPage = 0, + initialPageOffsetFraction = 0f, + pageCount = addresses::count, + ) + + HorizontalPager( + state = pagerState, + contentPadding = PaddingValues(horizontal = 16.dp), + pageSpacing = 16.dp, + ) { page -> + val address = addresses[page] + AddressItem( + currencyIconState = currencyIconState, + onOpenQrCodeClick = { onOpenQrCodeClick(address.value) }, + onCopyClick = { onCopyClick(address) }, + onShareClick = { onShareClick(address.value) }, + primaryType = address.type as ReceiveAddress.Type.Primary, + address = address.value, + isDynamicAddress = address.type is ReceiveAddress.Type.Primary.Dynamic, + ) + } + SpacerH(8.dp) + if (addresses.size > 1) { + TangemPagerIndicator(pagerState = pagerState) + } +} + +@Suppress("LongParameterList") +@Composable +private fun AddressItem( + currencyIconState: CurrencyIconState, + onOpenQrCodeClick: () -> Unit, + onCopyClick: () -> Unit, + onShareClick: () -> Unit, + primaryType: ReceiveAddress.Type.Primary, + address: String, + isDynamicAddress: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clickableSingle(onClick = onOpenQrCodeClick) + .padding(horizontal = 12.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CurrencyIcon( + modifier = Modifier.size(60.dp), + state = currencyIconState, + shouldDisplayNetwork = true, + iconSize = 56.dp, + networkBadgeSize = 20.dp, + ) + + SpacerH(12.dp) + + if (isDynamicAddress) { + DynamicAddressBadge(modifier = Modifier.padding(vertical = 4.dp)) + SpacerH(12.dp) + } + + Text( + text = primaryType.displayName.resolveReference(), + color = TangemTheme.colors3.text.primary, + style = TangemTheme.typography3.body.medium, + textAlign = TextAlign.Center, + ) + + SpacerH(4.dp) + + Text( + modifier = Modifier + .heightIn(min = 40.dp) + .padding(horizontal = 16.dp), + text = address, + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.caption.medium, + textAlign = TextAlign.Center, + ) + + SpacerH(8.dp) + + Row( + modifier = Modifier + .clickableSingle(onClick = onOpenQrCodeClick) + .padding(10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_qrcode_new_24), + tint = TangemTheme.colors3.icon.primary, + contentDescription = null, + ) + + SpacerW(6.dp) + + Text( + text = stringResourceSafe(R.string.token_receive_show_qr_code_title), + style = TangemTheme.typography3.body.medium, + color = TangemTheme.colors3.text.primary, + ) + } + + SpacerH(20.dp) + + ButtonsBlock( + onCopyClick = onCopyClick, + onShareClick = onShareClick, + ) + } +} + +@Composable +private fun ButtonsBlock(onCopyClick: () -> Unit, onShareClick: () -> Unit) { + val hapticFeedback = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + val topSnackbarHostState = LocalTopSnackbarHostState.current + + Row( + modifier = Modifier.width(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TangemButton( + modifier = Modifier.weight(1f), + onClick = { + onCopyClick() + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + coroutineScope.launch { + topSnackbarHostState.showSnackbar( + SnackbarMessage( + startIconId = R.drawable.ic_check_24, + message = resourceReference(R.string.wallet_notification_address_copied), + ), + ) + } + }, + iconStart = TangemIconUM.Icon(imageVector = Icons.ic_copy_24), + variant = TangemButton.Variant.Secondary, + size = TangemButton.Size.X12, + text = TextReference.Res(id = R.string.common_copy), + ) + + TangemButton( + modifier = Modifier.weight(1f), + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onShareClick() + }, + iconStart = TangemIconUM.Icon(imageVector = Icons.ic_share_android_24), + variant = TangemButton.Variant.Secondary, + size = TangemButton.Size.X12, + text = TextReference.Res(id = R.string.common_share), + ) + } +} + +@Composable +private fun EnsItem(onCopyClick: () -> Unit, address: String, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = TangemTheme.colors3.bg.opaque.primary), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + modifier = Modifier.size(36.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_ens_36), + contentDescription = null, + ) + SpacerW(12.dp) + + Text( + modifier = Modifier.weight(1f), + text = address, + color = TangemTheme.colors3.text.primary, + style = TangemTheme.typography3.body.medium, + overflow = TextOverflow.MiddleEllipsis, + maxLines = 1, + ) + + TangemButton( + modifier = Modifier.padding(start = 12.dp), + onClick = onCopyClick, + iconStart = TangemIconUM.Icon(imageVector = Icons.ic_share_android_24), + size = TangemButton.Size.X9, + variant = TangemButton.Variant.Secondary, + ) + } + } +} + +@Composable +private fun DynamicAddressBadge(modifier: Modifier = Modifier) { + TangemBadge( + modifier = modifier, + text = resourceReference(R.string.dynamic_addresses_receive_badge), + iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_dynamic_addresses_badge_16), + status = TangemBadge.Status.Info, + ) +} + @Composable private fun Info( currencyIconState: CurrencyIconState, @@ -110,227 +392,52 @@ private fun Info( .padding(horizontal = 18.dp) .fillMaxWidth(), text = stringResourceSafe(R.string.receive_bottom_sheet_no_memo_required_message), - style = TangemTheme.typography.caption1, - color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.tertiary, textAlign = TextAlign.Center, ) } notificationConfigs.fastForEach { notificationConfig -> key(notificationConfig.hashCode()) { - // TODO remove after new design system if (notificationConfig is NotificationUM.Warning.YieldSupplyIsActive) { - YieldSupplyDepositedWarning( - currencyIconState = currencyIconState, - title = stringResourceSafe( - R.string.yield_module_balance_info_sheet_title, - notificationConfig.tokenName, + TangemMessage( + title = resourceReference( + id = R.string.yield_module_balance_info_sheet_title, + formatArgs = wrappedList(notificationConfig.tokenName), ), - subtitle = stringResourceSafe(R.string.yield_module_balance_info_sheet_subtitle), - ) - } else { - Notification(config = notificationConfig.config) - } - } - } - } -} - -@Composable -private fun YieldSupplyDepositedWarning( - currencyIconState: CurrencyIconState, - title: String, - subtitle: String, - modifier: Modifier = Modifier, -) { - Surface( - modifier = modifier - .defaultMinSize(minHeight = TangemTheme.dimens.size44) - .fillMaxWidth(), - shape = TangemTheme.shapes.roundedCornersXMedium, - color = TangemTheme.colors.button.disabled, - ) { - Row( - modifier = Modifier - .padding(all = TangemTheme.dimens.spacing12), - ) { - Box( - modifier = Modifier - .height(22.dp) - .width(22.dp), - contentAlignment = Alignment.TopStart, - ) { - CurrencyIcon( - modifier = Modifier - .align(Alignment.TopStart) - .size(13.dp), - state = currencyIconState, - shouldDisplayNetwork = false, - iconSize = 13.dp, - ) - - Image( - modifier = Modifier - .background(TangemTheme.colors.background.tertiary, RoundedCornerShape(15.dp)) - .padding(1.dp) - .size(15.dp) - .align(Alignment.BottomEnd), - imageVector = ImageVector.vectorResource(R.drawable.img_aave_22), - contentDescription = null, - ) - } - - SpacerW(width = TangemTheme.dimens.spacing8) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.button, - ) - - SpacerH(height = TangemTheme.dimens.spacing2) - - Text( - text = subtitle, - color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography.caption2, - ) - } - } - } -} - -@Composable -private fun AddressBlock(assetsUM: ReceiveAssetsUM, snackbarHostState: SnackbarHostState) { - val hapticFeedback = LocalHapticFeedback.current - val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - val resources = context.resources - val isRedesignEnabled = LocalRedesignEnabled.current - val topSnackbarHostState = LocalTopSnackbarHostState.current - - assetsUM.addresses - .fastFilter { it.type is ReceiveAddress.Type.Ens } - .fastForEach { address -> - key(address.value) { - EnsItem( - modifier = Modifier.padding(horizontal = 16.dp), - onCopyClick = { - assetsUM.onCopyClick(address) - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - coroutineScope.launch { - if (isRedesignEnabled) { - topSnackbarHostState.showSnackbar( - SnackbarMessage( - startIconId = R.drawable.ic_check_24, - message = resourceReference(R.string.wallet_notification_address_copied), - ), + subtitle = resourceReference(R.string.yield_module_balance_info_sheet_subtitle), + leadingContent = { + Box( + modifier = Modifier + .height(22.dp) + .width(22.dp), + contentAlignment = Alignment.TopStart, + ) { + CurrencyIcon( + modifier = Modifier + .align(Alignment.TopStart) + .size(13.dp), + state = currencyIconState, + shouldDisplayNetwork = false, + iconSize = 13.dp, ) - } else { - snackbarHostState.showSnackbar( - message = resources.getStringSafe( - R.string.wallet_notification_address_copied, - ), + Image( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary, RoundedCornerShape(15.dp)) + .padding(1.dp) + .size(15.dp) + .align(Alignment.BottomEnd), + imageVector = ImageVector.vectorResource(R.drawable.img_aave_22), + contentDescription = null, ) } - } - }, - address = address.value, - ) - SpacerH8() - } - } - - PrimaryAddressesItems( - addresses = assetsUM.addresses.fastFilter { it.type is ReceiveAddress.Type.Primary }.toImmutableList(), - currencyIconState = assetsUM.currencyIconState, - snackbarHostState = snackbarHostState, - onShareClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - assetsUM.onShareClick(it) - }, - onCopyClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - coroutineScope.launch { - snackbarHostState.showSnackbar( - message = resources.getStringSafe( - R.string.wallet_notification_address_copied, - ), - ) - } - assetsUM.onCopyClick(it) - }, - onOpenQrCodeClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - assetsUM.onOpenQrCodeClick(it) - }, - ) -} - -@Suppress("LongParameterList") -@Composable -private fun PrimaryAddressesItems( - addresses: ImmutableList, - currencyIconState: CurrencyIconState, - snackbarHostState: SnackbarHostState, - onShareClick: (String) -> Unit, - onCopyClick: (ReceiveAddress) -> Unit, - onOpenQrCodeClick: (String) -> Unit, -) { - if (addresses.isEmpty()) return - var selectedAddress by remember { mutableStateOf(addresses.first()) } - val pagerState = rememberPagerState( - initialPage = 0, - initialPageOffsetFraction = 0f, - pageCount = addresses::count, - ) - LaunchedEffect(key1 = pagerState.currentPage) { - selectedAddress = addresses[pagerState.currentPage] - } - - HorizontalPager( - state = pagerState, - contentPadding = PaddingValues(horizontal = 16.dp), - pageSpacing = 16.dp, - ) { - AddressItem( - currencyIconState = currencyIconState, - onOpenQrCodeClick = { onOpenQrCodeClick(selectedAddress.value) }, - onCopyClick = { onCopyClick(selectedAddress) }, - onShareClick = { onShareClick(selectedAddress.value) }, - primaryType = selectedAddress.type as ReceiveAddress.Type.Primary, - address = selectedAddress.value, - isDynamicAddress = selectedAddress.type is ReceiveAddress.Type.Primary.Dynamic, - snackbarHostState = snackbarHostState, - ) - } - - SpacerH(4.dp) - - if (pagerState.pageCount > 1) { - val indicatorState = rememberLazyListState() - val selectedColor = TangemTheme.colors.icon.primary1 - val unselectedColor = TangemTheme.colors.icon.informative - - LazyRow( - modifier = Modifier.height(20.dp), - state = indicatorState, - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - repeat(pagerState.pageCount) { iteration -> - item(key = iteration) { - val color by animateColorAsState( - targetValue = if (pagerState.currentPage == iteration) selectedColor else unselectedColor, - label = "", + }, ) - - Box( - modifier = Modifier - .padding(horizontal = 4.dp, vertical = 6.dp) - .background(color = color, shape = CircleShape) - .size(7.dp), + } else { + TangemMessage( + config = notificationConfig.config, + contentColor = Color.Transparent, ) } } @@ -338,308 +445,13 @@ private fun PrimaryAddressesItems( } } -@Suppress("LongParameterList") -@Composable -private fun AddressItem( - currencyIconState: CurrencyIconState, - onOpenQrCodeClick: () -> Unit, - onCopyClick: () -> Unit, - onShareClick: () -> Unit, - primaryType: ReceiveAddress.Type.Primary, - address: String, - isDynamicAddress: Boolean, - snackbarHostState: SnackbarHostState, - modifier: Modifier = Modifier, -) { - Card( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = TangemTheme.colors.background.action), - onClick = onOpenQrCodeClick, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - CurrencyIcon( - modifier = Modifier.size(56.dp), - state = currencyIconState, - shouldDisplayNetwork = true, - iconSize = 56.dp, - ) - - SpacerH(12.dp) - - if (isDynamicAddress) { - DynamicAddressBadge() - SpacerH(8.dp) - } - - Text( - text = primaryType.displayName.resolveReference(), - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.subtitle1, - textAlign = TextAlign.Center, - ) - - Text( - modifier = Modifier - .heightIn(min = 40.dp) - .padding(horizontal = 16.dp), - text = address, - color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography.body2, - textAlign = TextAlign.Center, - ) - - SpacerH8() - - Row( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onOpenQrCodeClick) - .padding(vertical = 6.dp, horizontal = 12.dp), - horizontalArrangement = Arrangement.Center, - ) { - Icon( - modifier = Modifier.size(16.dp), - imageVector = ImageVector.vectorResource(R.drawable.ic_qrcode_new_24), - contentDescription = null, - ) - - SpacerW(4.dp) - - Text( - text = stringResourceSafe(R.string.token_receive_show_qr_code_title), - style = TangemTheme.typography.caption1, - color = TangemTheme.colors.text.secondary, - ) - } - - SpacerH(20.dp) - - ButtonsBlock( - snackbarHostState = snackbarHostState, - onCopyClick = onCopyClick, - onShareClick = onShareClick, - ) - } - } -} - -@Composable -private fun ButtonsBlock(snackbarHostState: SnackbarHostState, onCopyClick: () -> Unit, onShareClick: () -> Unit) { - val hapticFeedback = LocalHapticFeedback.current - val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - val resources = context.resources - val isRedesignEnabled = LocalRedesignEnabled.current - val topSnackbarHostState = LocalTopSnackbarHostState.current - - Row( - modifier = Modifier.width(IntrinsicSize.Min), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - ActionButtonWithResizableText( - modifier = Modifier.weight(1f), - config = ActionButtonConfig( - text = TextReference.Res(id = R.string.common_copy), - iconResId = R.drawable.ic_copy_new_24, - onClick = { - onCopyClick() - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - coroutineScope.launch { - if (isRedesignEnabled) { - topSnackbarHostState.showSnackbar( - SnackbarMessage( - startIconId = R.drawable.ic_check_24, - message = resourceReference(R.string.wallet_notification_address_copied), - ), - ) - } else { - snackbarHostState.showSnackbar( - message = resources.getStringSafe(R.string.wallet_notification_address_copied), - ) - } - } - }, - ), - ) - - ActionButtonWithResizableText( - modifier = Modifier.weight(1f), - config = ActionButtonConfig( - text = TextReference.Res(id = R.string.common_share), - iconResId = R.drawable.ic_share_24, - onClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - onShareClick() - }, - ), - ) - } -} - -@Composable -private fun EnsItem(onCopyClick: () -> Unit, address: String, modifier: Modifier = Modifier) { - Card( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = TangemTheme.colors.background.action), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 14.dp, horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - modifier = Modifier.size(36.dp), - imageVector = ImageVector.vectorResource(R.drawable.ic_ens_36), - contentDescription = null, - ) - - SpacerW12() - - EllipsisText( - modifier = Modifier.weight(1f), - text = address, - ellipsis = TextEllipsis.Middle, - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.subtitle1, - ) - - TangemIconButton( - modifier = Modifier.size(TangemTheme.dimens.size28), - iconRes = R.drawable.ic_share_24, - innerPadding = 6.dp, - onClick = onCopyClick, - ) - } - } -} - -@Composable -private fun LoadingBlock(modifier: Modifier = Modifier) { - Card( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = TangemTheme.colors.background.action), - ) { - Box( - modifier = modifier - .fillMaxWidth() - .heightIn(min = 64.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = TangemTheme.colors.icon.informative, - modifier = Modifier.padding(TangemTheme.dimens.spacing8), - ) - } - } -} - -@Composable -private fun ActionButtonWithResizableText(config: ActionButtonConfig, modifier: Modifier = Modifier) { - ActionBaseButton( - config = config, - shape = RoundedCornerShape(size = TangemTheme.dimens.radius24), - content = { contentModifier -> - ActionButtonContent( - config = config, - text = { color -> - Text( - text = config.text.resolveReference(), - autoSize = TextAutoSize.StepBased( - minFontSize = 10.sp, - maxFontSize = TangemTheme.typography.button.fontSize, - ), - color = color, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - style = TangemTheme.typography.button, - ) - }, - modifier = contentModifier.padding(horizontal = 16.dp), - paddingBetweenIconAndText = 4.dp, - ) - }, - modifier = modifier, - color = TangemTheme.colors.button.secondary, - ) -} - -@Composable -private fun DynamicAddressBadge(modifier: Modifier = Modifier) { - Row( - modifier = modifier - .background( - color = TangemTheme.colors.text.accent.copy(alpha = 0.1f), - shape = RoundedCornerShape(percent = 50), - ) - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - painter = painterResource( - id = R.drawable.ic_dynamic_addresses_badge_16, - ), - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = TangemTheme.colors.icon.accent, - ) - Text( - text = stringResourceSafe(R.string.dynamic_addresses_receive_badge), - style = TangemTheme.typography.caption1, - color = TangemTheme.colors.text.accent, - ) - } -} - @Composable @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun Preview_TokenReceiveAssetsContent( @PreviewParameter(TokenReceiveAssetsContentProvider::class) params: ReceiveAssetsUM, ) { - TangemThemePreview { + TangemThemePreviewRedesign { TokenReceiveAssetsContent(assetsUM = params) } -} - -private class TokenReceiveAssetsContentProvider : PreviewParameterProvider { - val address = ReceiveAddress( - value = "0xe5178c7d4d0e861ed2e9414e045b501226b0de8d", - type = ReceiveAddress.Type.Primary.Default( - displayName = stringReference("Etherium address"), - ), - ) - private val config = ReceiveAssetsUM( - notificationConfigs = - persistentListOf( - NotificationUM.Warning( - title = stringReference("Send only XLM on the Ethereum network"), - subtitle = resourceReference(R.string.receive_bottom_sheet_warning_message_description), - ), - ), - addresses = persistentListOf( - address, - address, - address.copy(type = ReceiveAddress.Type.Ens, value = "papasha.eth"), - ), - showMemoDisclaimer = false, - onCopyClick = {}, - onOpenQrCodeClick = {}, - isEnsResultLoading = true, - network = "USDT", - currencyIconState = CurrencyIconState.Locked, - onShareClick = {}, - ) - - override val values: Sequence - get() = sequenceOf(config) } \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContentLegacy.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContentLegacy.kt new file mode 100644 index 0000000000..c052bb3c43 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContentLegacy.kt @@ -0,0 +1,672 @@ +package com.tangem.features.tokenreceive.ui + +import android.content.res.Configuration +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +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 +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastFilter +import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.res.getStringSafe +import com.tangem.core.ui.R +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.components.atoms.text.TextEllipsis +import com.tangem.core.ui.components.buttons.actions.ActionBaseButton +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.components.buttons.actions.ActionButtonContent +import com.tangem.core.ui.components.buttons.small.TangemIconButton +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.res.* +import com.tangem.features.tokenreceive.entity.ReceiveAddress +import com.tangem.features.tokenreceive.ui.state.ReceiveAssetsUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch + +@Composable +internal fun TokenReceiveAssetsContentLegacy(assetsUM: ReceiveAssetsUM) { + val snackbarHostState = remember(::SnackbarHostState) + + ContainerWithSnackbarHost(snackbarHostState = snackbarHostState) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = TangemTheme.colors.background.tertiary) + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AddressBlock( + assetsUM = assetsUM, + snackbarHostState = snackbarHostState, + ) + + if (assetsUM.isEnsResultLoading) { + SpacerH8() + LoadingBlock(modifier = Modifier.padding(horizontal = 16.dp)) + } + + SpacerH12() + + Info( + showMemoDisclaimer = assetsUM.showMemoDisclaimer, + notificationConfigs = assetsUM.notificationConfigs, + currencyIconState = assetsUM.currencyIconState, + ) + } + } +} + +@Composable +private fun Info( + currencyIconState: CurrencyIconState, + notificationConfigs: ImmutableList, + showMemoDisclaimer: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(space = 16.dp), + ) { + if (showMemoDisclaimer) { + Text( + modifier = Modifier + .padding(horizontal = 18.dp) + .fillMaxWidth(), + text = stringResourceSafe(R.string.receive_bottom_sheet_no_memo_required_message), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + } + + notificationConfigs.fastForEach { notificationConfig -> + key(notificationConfig.hashCode()) { + if (notificationConfig is NotificationUM.Warning.YieldSupplyIsActive) { + YieldSupplyDepositedWarning( + currencyIconState = currencyIconState, + title = stringResourceSafe( + R.string.yield_module_balance_info_sheet_title, + notificationConfig.tokenName, + ), + subtitle = stringResourceSafe(R.string.yield_module_balance_info_sheet_subtitle), + ) + } else { + Notification(config = notificationConfig.config) + } + } + } + } +} + +@Composable +private fun YieldSupplyDepositedWarning( + currencyIconState: CurrencyIconState, + title: String, + subtitle: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .defaultMinSize(minHeight = TangemTheme.dimens.size44) + .fillMaxWidth(), + shape = TangemTheme.shapes.roundedCornersXMedium, + color = TangemTheme.colors.button.disabled, + ) { + Row( + modifier = Modifier + .padding(all = TangemTheme.dimens.spacing12), + ) { + Box( + modifier = Modifier + .height(22.dp) + .width(22.dp), + contentAlignment = Alignment.TopStart, + ) { + CurrencyIcon( + modifier = Modifier + .align(Alignment.TopStart) + .size(13.dp), + state = currencyIconState, + shouldDisplayNetwork = false, + iconSize = 13.dp, + ) + + Image( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary, RoundedCornerShape(15.dp)) + .padding(1.dp) + .size(15.dp) + .align(Alignment.BottomEnd), + imageVector = ImageVector.vectorResource(R.drawable.img_aave_22), + contentDescription = null, + ) + } + + SpacerW(width = TangemTheme.dimens.spacing8) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.button, + ) + + SpacerH(height = TangemTheme.dimens.spacing2) + + Text( + text = subtitle, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption2, + ) + } + } + } +} + +@Composable +private fun AddressBlock(assetsUM: ReceiveAssetsUM, snackbarHostState: SnackbarHostState) { + val hapticFeedback = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val resources = context.resources + val isRedesignEnabled = LocalRedesignEnabled.current + val topSnackbarHostState = LocalTopSnackbarHostState.current + + assetsUM.addresses + .fastFilter { it.type is ReceiveAddress.Type.Ens } + .fastForEach { address -> + key(address.value) { + EnsItem( + modifier = Modifier.padding(horizontal = 16.dp), + onCopyClick = { + assetsUM.onCopyClick(address) + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + coroutineScope.launch { + if (isRedesignEnabled) { + topSnackbarHostState.showSnackbar( + SnackbarMessage( + startIconId = R.drawable.ic_check_24, + message = resourceReference(R.string.wallet_notification_address_copied), + ), + ) + } else { + snackbarHostState.showSnackbar( + message = resources.getStringSafe( + R.string.wallet_notification_address_copied, + ), + ) + } + } + }, + address = address.value, + ) + SpacerH8() + } + } + + PrimaryAddressesItems( + addresses = assetsUM.addresses.fastFilter { it.type is ReceiveAddress.Type.Primary }.toImmutableList(), + currencyIconState = assetsUM.currencyIconState, + snackbarHostState = snackbarHostState, + onShareClick = { address -> + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + assetsUM.onShareClick(address) + }, + onCopyClick = { address -> + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = resources.getStringSafe( + R.string.wallet_notification_address_copied, + ), + ) + } + assetsUM.onCopyClick(address) + }, + onOpenQrCodeClick = { address -> + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + assetsUM.onOpenQrCodeClick(address) + }, + ) +} + +@Suppress("LongParameterList") +@Composable +private fun PrimaryAddressesItems( + addresses: ImmutableList, + currencyIconState: CurrencyIconState, + snackbarHostState: SnackbarHostState, + onShareClick: (String) -> Unit, + onCopyClick: (ReceiveAddress) -> Unit, + onOpenQrCodeClick: (String) -> Unit, +) { + if (addresses.isEmpty()) return + var selectedAddress by remember { mutableStateOf(addresses.first()) } + val pagerState = rememberPagerState( + initialPage = 0, + initialPageOffsetFraction = 0f, + pageCount = addresses::count, + ) + LaunchedEffect(key1 = pagerState.currentPage) { + selectedAddress = addresses[pagerState.currentPage] + } + + HorizontalPager( + state = pagerState, + contentPadding = PaddingValues(horizontal = 16.dp), + pageSpacing = 16.dp, + ) { + AddressItem( + currencyIconState = currencyIconState, + onOpenQrCodeClick = { onOpenQrCodeClick(selectedAddress.value) }, + onCopyClick = { onCopyClick(selectedAddress) }, + onShareClick = { onShareClick(selectedAddress.value) }, + primaryType = selectedAddress.type as ReceiveAddress.Type.Primary, + address = selectedAddress.value, + isDynamicAddress = selectedAddress.type is ReceiveAddress.Type.Primary.Dynamic, + snackbarHostState = snackbarHostState, + ) + } + + SpacerH(4.dp) + + if (pagerState.pageCount > 1) { + val indicatorState = rememberLazyListState() + val selectedColor = TangemTheme.colors.icon.primary1 + val unselectedColor = TangemTheme.colors.icon.informative + + LazyRow( + modifier = Modifier.height(20.dp), + state = indicatorState, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(pagerState.pageCount) { iteration -> + item(key = iteration) { + val color by animateColorAsState( + targetValue = if (pagerState.currentPage == iteration) selectedColor else unselectedColor, + label = "", + ) + + Box( + modifier = Modifier + .padding(horizontal = 4.dp, vertical = 6.dp) + .background(color = color, shape = CircleShape) + .size(7.dp), + ) + } + } + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun AddressItem( + currencyIconState: CurrencyIconState, + onOpenQrCodeClick: () -> Unit, + onCopyClick: () -> Unit, + onShareClick: () -> Unit, + primaryType: ReceiveAddress.Type.Primary, + address: String, + isDynamicAddress: Boolean, + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = TangemTheme.colors.background.action), + onClick = onOpenQrCodeClick, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CurrencyIcon( + modifier = Modifier.size(56.dp), + state = currencyIconState, + shouldDisplayNetwork = true, + iconSize = 56.dp, + ) + + SpacerH(12.dp) + + if (isDynamicAddress) { + DynamicAddressBadge() + SpacerH(8.dp) + } + + Text( + text = primaryType.displayName.resolveReference(), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle1, + textAlign = TextAlign.Center, + ) + + Text( + modifier = Modifier + .heightIn(min = 40.dp) + .padding(horizontal = 16.dp), + text = address, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.body2, + textAlign = TextAlign.Center, + ) + + SpacerH8() + + Row( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onOpenQrCodeClick) + .padding(vertical = 6.dp, horizontal = 12.dp), + horizontalArrangement = Arrangement.Center, + ) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_qrcode_new_24), + contentDescription = null, + ) + + SpacerW(4.dp) + + Text( + text = stringResourceSafe(R.string.token_receive_show_qr_code_title), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.secondary, + ) + } + + SpacerH(20.dp) + + ButtonsBlock( + snackbarHostState = snackbarHostState, + onCopyClick = onCopyClick, + onShareClick = onShareClick, + ) + } + } +} + +@Composable +private fun ButtonsBlock(snackbarHostState: SnackbarHostState, onCopyClick: () -> Unit, onShareClick: () -> Unit) { + val hapticFeedback = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val resources = context.resources + val isRedesignEnabled = LocalRedesignEnabled.current + val topSnackbarHostState = LocalTopSnackbarHostState.current + + Row( + modifier = Modifier.width(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ActionButtonWithResizableText( + modifier = Modifier.weight(1f), + config = ActionButtonConfig( + text = TextReference.Res(id = R.string.common_copy), + iconResId = R.drawable.ic_copy_new_24, + onClick = { + onCopyClick() + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + coroutineScope.launch { + if (isRedesignEnabled) { + topSnackbarHostState.showSnackbar( + SnackbarMessage( + startIconId = R.drawable.ic_check_24, + message = resourceReference(R.string.wallet_notification_address_copied), + ), + ) + } else { + snackbarHostState.showSnackbar( + message = resources.getStringSafe(R.string.wallet_notification_address_copied), + ) + } + } + }, + ), + ) + + ActionButtonWithResizableText( + modifier = Modifier.weight(1f), + config = ActionButtonConfig( + text = TextReference.Res(id = R.string.common_share), + iconResId = R.drawable.ic_share_24, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onShareClick() + }, + ), + ) + } +} + +@Composable +private fun EnsItem(onCopyClick: () -> Unit, address: String, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = TangemTheme.colors.background.action), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 14.dp, horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + modifier = Modifier.size(36.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_ens_36), + contentDescription = null, + ) + + SpacerW12() + + EllipsisText( + modifier = Modifier.weight(1f), + text = address, + ellipsis = TextEllipsis.Middle, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle1, + ) + + TangemIconButton( + modifier = Modifier.size(TangemTheme.dimens.size28), + iconRes = R.drawable.ic_share_24, + innerPadding = 6.dp, + onClick = onCopyClick, + ) + } + } +} + +@Composable +private fun LoadingBlock(modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = TangemTheme.colors.background.action), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = TangemTheme.colors.icon.informative, + modifier = Modifier.padding(TangemTheme.dimens.spacing8), + ) + } + } +} + +@Composable +private fun ActionButtonWithResizableText(config: ActionButtonConfig, modifier: Modifier = Modifier) { + ActionBaseButton( + config = config, + shape = RoundedCornerShape(size = TangemTheme.dimens.radius24), + content = { contentModifier -> + ActionButtonContent( + config = config, + text = { color -> + Text( + text = config.text.resolveReference(), + autoSize = TextAutoSize.StepBased( + minFontSize = 10.sp, + maxFontSize = TangemTheme.typography.button.fontSize, + ), + color = color, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = TangemTheme.typography.button, + ) + }, + modifier = contentModifier.padding(horizontal = 16.dp), + paddingBetweenIconAndText = 4.dp, + ) + }, + modifier = modifier, + color = TangemTheme.colors.button.secondary, + ) +} + +@Composable +private fun DynamicAddressBadge(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .background( + color = TangemTheme.colors.text.accent.copy(alpha = 0.1f), + shape = RoundedCornerShape(percent = 50), + ) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + painter = painterResource( + id = R.drawable.ic_dynamic_addresses_badge_16, + ), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = TangemTheme.colors.icon.accent, + ) + Text( + text = stringResourceSafe(R.string.dynamic_addresses_receive_badge), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.accent, + ) + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_TokenReceiveAssetsContent( + @PreviewParameter(TokenReceiveAssetsContentProvider::class) params: ReceiveAssetsUM, +) { + TangemThemePreview { + TokenReceiveAssetsContentLegacy(assetsUM = params) + } +} + +internal class TokenReceiveAssetsContentProvider : PreviewParameterProvider { + val address = ReceiveAddress( + value = "0xe5178c7d4d0e861ed2e9414e045b501226b0de8d", + type = ReceiveAddress.Type.Primary.Default( + displayName = stringReference("Etherium address"), + ), + ) + private val dynamicAddress = ReceiveAddress( + value = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", + type = ReceiveAddress.Type.Primary.Dynamic( + displayName = stringReference("Ethereum address"), + ), + ) + private val tokenIconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = null, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ) + private val config = ReceiveAssetsUM( + notificationConfigs = + persistentListOf( + NotificationUM.Warning( + title = stringReference("Send only XLM on the Ethereum network"), + subtitle = resourceReference(R.string.receive_bottom_sheet_warning_message_description), + ), + ), + addresses = persistentListOf( + address, + address, + address.copy(type = ReceiveAddress.Type.Ens, value = "papasha.eth"), + ), + showMemoDisclaimer = false, + onCopyClick = {}, + onOpenQrCodeClick = {}, + isEnsResultLoading = true, + network = "USDT", + currencyIconState = CurrencyIconState.Locked, + onShareClick = {}, + ) + private val yieldSupplyIsActiveConfig = config.copy( + notificationConfigs = persistentListOf( + NotificationUM.Warning.YieldSupplyIsActive(tokenName = "USDT"), + ), + isEnsResultLoading = false, + currencyIconState = tokenIconState, + ) + private val dynamicAddressConfig = config.copy( + addresses = persistentListOf( + dynamicAddress, + address, + address.copy(type = ReceiveAddress.Type.Ens, value = "papasha.eth"), + ), + isEnsResultLoading = false, + notificationConfigs = persistentListOf(), + currencyIconState = tokenIconState, + ) + + override val values: Sequence + get() = sequenceOf(config, yieldSupplyIsActiveConfig, dynamicAddressConfig) +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveContent.kt index f4e495de55..5d9dc29fa8 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveContent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveContent.kt @@ -2,22 +2,32 @@ package com.tangem.features.tokenreceive.ui import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import com.arkivanov.decompose.extensions.compose.stack.Children import com.arkivanov.decompose.extensions.compose.stack.animation.fade import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import com.arkivanov.decompose.router.stack.ChildStack import com.tangem.core.ui.R +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet -import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.LocalHazeState import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_chevron_left_24 +import com.tangem.core.ui.res.generated.icons.ic_cross_24 import com.tangem.features.tokenreceive.route.TokenReceiveRoutes +import dev.chrisbanes.haze.rememberHazeState @Composable internal fun TokenReceiveContentSheet( @@ -26,59 +36,89 @@ internal fun TokenReceiveContentSheet( onBackClick: () -> Unit, contentStack: ChildStack, ) { - TangemModalBottomSheet( - config = TangemBottomSheetConfig( - isShown = true, - onDismissRequest = onCloseClick, - content = TangemBottomSheetConfigContent.Empty, - ), - onBack = onBackClick, - containerColor = TangemTheme.colors.background.tertiary, - title = { - Title( - route = route, - onBackClick = onBackClick, - onCloseClick = onCloseClick, - ) - }, - content = { - TokenReceiveContent( - stackState = contentStack, - modifier = Modifier, - ) - }, - ) + CompositionLocalProvider(LocalHazeState provides rememberHazeState()) { + TangemBottomSheet( + type = TangemBottomSheetType.Modal, + onBack = onBackClick, + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onCloseClick, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors3.bg.secondary, + title = { + Title( + route = route, + onBackClick = onBackClick, + onCloseClick = onCloseClick, + ) + }, + content = { + TokenReceiveContent( + stackState = contentStack, + modifier = Modifier, + ) + }, + ) + } } @Composable private fun Title(route: TokenReceiveRoutes, onBackClick: () -> Unit, onCloseClick: () -> Unit) { when (route) { is TokenReceiveRoutes.QrCode -> { - TangemModalBottomSheetTitle( - startIconRes = R.drawable.ic_back_24, - onStartClick = onBackClick, - endIconRes = R.drawable.ic_close_24, - onEndClick = onCloseClick, + TangemTopBar( + type = TangemTopBarType.BottomSheet, + endContent = { + TangemButton( + iconStart = TangemIconUM.Icon(Icons.ic_cross_24), + onClick = onCloseClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + startContent = { + TangemButton( + iconStart = TangemIconUM.Icon(imageVector = Icons.ic_chevron_left_24), + onClick = onBackClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, ) } TokenReceiveRoutes.ReceiveAssets -> { - TangemModalBottomSheetTitle( + TangemTopBar( title = resourceReference(R.string.domain_receive_assets_navigation_title), - endIconRes = R.drawable.ic_close_24, - onEndClick = onCloseClick, + type = TangemTopBarType.BottomSheet, + endContent = { + TangemButton( + iconStart = TangemIconUM.Icon(Icons.ic_cross_24), + onClick = onCloseClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, ) } TokenReceiveRoutes.Warning -> { - TangemModalBottomSheetTitle( - endIconRes = R.drawable.ic_close_24, - onEndClick = onCloseClick, + TangemTopBar( + type = TangemTopBarType.BottomSheet, + endContent = { + TangemButton( + iconStart = TangemIconUM.Icon(Icons.ic_cross_24), + onClick = onCloseClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, ) } } } @Composable -internal fun TokenReceiveContent( +private fun TokenReceiveContent( stackState: ChildStack, modifier: Modifier = Modifier, ) { @@ -86,9 +126,9 @@ internal fun TokenReceiveContent( stack = stackState, animation = stackAnimation(fade(animationSpec = tween(durationMillis = 100))), modifier = modifier - .fillMaxSize() + .fillMaxWidth() .animateContentSize(), ) { - it.instance.Content(Modifier.fillMaxSize()) + it.instance.Content(Modifier.fillMaxWidth()) } } \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveContentLegacy.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveContentLegacy.kt new file mode 100644 index 0000000000..8d35837ae5 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveContentLegacy.kt @@ -0,0 +1,94 @@ +package com.tangem.features.tokenreceive.ui + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.fade +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.R +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.tokenreceive.route.TokenReceiveRoutes + +@Composable +internal fun TokenReceiveContentSheetLegacy( + route: TokenReceiveRoutes, + onCloseClick: () -> Unit, + onBackClick: () -> Unit, + contentStack: ChildStack, +) { + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onCloseClick, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = onBackClick, + containerColor = TangemTheme.colors.background.tertiary, + title = { + Title( + route = route, + onBackClick = onBackClick, + onCloseClick = onCloseClick, + ) + }, + content = { + TokenReceiveContent( + stackState = contentStack, + modifier = Modifier, + ) + }, + ) +} + +@Composable +private fun Title(route: TokenReceiveRoutes, onBackClick: () -> Unit, onCloseClick: () -> Unit) { + when (route) { + is TokenReceiveRoutes.QrCode -> { + TangemModalBottomSheetTitle( + startIconRes = R.drawable.ic_back_24, + onStartClick = onBackClick, + endIconRes = R.drawable.ic_close_24, + onEndClick = onCloseClick, + ) + } + TokenReceiveRoutes.ReceiveAssets -> { + TangemModalBottomSheetTitle( + title = resourceReference(R.string.domain_receive_assets_navigation_title), + endIconRes = R.drawable.ic_close_24, + onEndClick = onCloseClick, + ) + } + TokenReceiveRoutes.Warning -> { + TangemModalBottomSheetTitle( + endIconRes = R.drawable.ic_close_24, + onEndClick = onCloseClick, + ) + } + } +} + +@Composable +private fun TokenReceiveContent( + stackState: ChildStack, + modifier: Modifier = Modifier, +) { + Children( + stack = stackState, + animation = stackAnimation(fade(animationSpec = tween(durationMillis = 100))), + modifier = modifier + .fillMaxSize() + .animateContentSize(), + ) { + it.instance.Content(Modifier.fillMaxSize()) + } +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt index 0c4a083cec..ae42b08d41 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt @@ -2,11 +2,9 @@ package com.tangem.features.tokenreceive.ui import android.content.res.Configuration import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -18,24 +16,25 @@ import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.tangem.core.res.getStringSafe -import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.extensions.* import com.tangem.core.ui.message.SnackbarMessage -import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.LocalTopSnackbarHostState import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_copy_24 +import com.tangem.core.ui.res.generated.icons.ic_share_android_24 import com.tangem.core.ui.test.TokenReceiveQrCodeBottomSheetTestTags import com.tangem.features.tokenreceive.impl.R import com.tangem.features.tokenreceive.ui.state.QrCodeUM @@ -43,34 +42,28 @@ import kotlinx.coroutines.launch @Composable internal fun TokenReceiveQrCodeContent(qrCodeUM: QrCodeUM) { - val snackbarHostState = remember(::SnackbarHostState) val qrCodePainter = rememberQrPainter(content = qrCodeUM.addressValue) - ContainerWithSnackbarHost(snackbarHostState = snackbarHostState) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(color = TangemTheme.colors.background.tertiary) - .padding(bottom = 16.dp) - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SpacerH8() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp, start = 16.dp, end = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + QrCodePage( + addressFullName = qrCodeUM.addressName, + addressValue = qrCodeUM.addressValue, + network = qrCodeUM.network, + qrCodePainter = qrCodePainter, + ) - QrCodePage( - addressFullName = qrCodeUM.addressName, - addressValue = qrCodeUM.addressValue, - network = qrCodeUM.network, - qrCodePainter = qrCodePainter, - ) - SpacerH24() + SpacerH(24.dp) - Buttons( - onShareClick = { qrCodeUM.onShareClick(qrCodeUM.addressValue) }, - onCopyClick = qrCodeUM.onCopyClick, - snackbarHostState = snackbarHostState, - ) - } + Buttons( + modifier = Modifier.padding(top = 8.dp), + onShareClick = { qrCodeUM.onShareClick(qrCodeUM.addressValue) }, + onCopyClick = qrCodeUM.onCopyClick, + ) } } @@ -81,7 +74,12 @@ private fun QrCodePage(addressFullName: TextReference, addressValue: String, net horizontalAlignment = Alignment.CenterHorizontally, ) { Column( - modifier = Modifier.padding(horizontal = TangemTheme.dimens.size36), + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 24.dp, + bottom = 16.dp, + ), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( @@ -90,22 +88,22 @@ private fun QrCodePage(addressFullName: TextReference, addressValue: String, net addressFullName.resolveReference(), network, ), - color = TangemTheme.colors.text.primary1, + color = TangemTheme.colors3.text.primary, textAlign = TextAlign.Center, - style = TangemTheme.typography.h3, + style = TangemTheme.typography3.heading.small, modifier = Modifier.testTag(TokenReceiveQrCodeBottomSheetTestTags.TITLE), ) - SpacerH(20.dp) + SpacerH(32.dp) Box( modifier = Modifier .border( - width = 8.dp, - color = TangemTheme.colors.icon.constant, - shape = RoundedCornerShape(8.dp), + width = 16.dp, + color = TangemTheme.colors3.bg.secondary, + shape = RoundedCornerShape(12.dp), ) - .padding(8.dp) + .padding(16.dp) .testTag(TokenReceiveQrCodeBottomSheetTestTags.QR_CODE), ) { @@ -116,79 +114,66 @@ private fun QrCodePage(addressFullName: TextReference, addressValue: String, net modifier = Modifier.sizeIn(minWidth = 148.dp), ) } - - SpacerH24() } Text( text = stringResourceSafe(R.string.wc_common_address), - color = TangemTheme.colors.text.tertiary, + color = TangemTheme.colors3.text.secondary, textAlign = TextAlign.Center, - style = TangemTheme.typography.subtitle2, + style = TangemTheme.typography3.caption.medium, ) - SpacerH2() + SpacerH(4.dp) Text( text = addressValue, - color = TangemTheme.colors.text.primary1, + color = TangemTheme.colors3.text.primary, textAlign = TextAlign.Center, - style = TangemTheme.typography.subtitle1, + style = TangemTheme.typography3.body.medium, modifier = Modifier.testTag(TokenReceiveQrCodeBottomSheetTestTags.ADDRESS), ) } } @Composable -private fun Buttons( - snackbarHostState: SnackbarHostState, - onShareClick: () -> Unit, - onCopyClick: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun Buttons(onShareClick: () -> Unit, onCopyClick: () -> Unit, modifier: Modifier = Modifier) { val hapticFeedback = LocalHapticFeedback.current val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - val resources = context.resources - - val isRedesignEnabled = LocalRedesignEnabled.current val tangemTopSnackbarHostState = LocalTopSnackbarHostState.current Row( modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - SecondaryButtonIconStart( + TangemButton( modifier = Modifier.weight(1f), - text = stringResourceSafe(id = R.string.common_copy), - iconResId = R.drawable.ic_copy_24, onClick = { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) onCopyClick() coroutineScope.launch { - if (isRedesignEnabled) { - tangemTopSnackbarHostState.showSnackbar( - SnackbarMessage( - startIconId = R.drawable.ic_check_24, - message = resourceReference(R.string.wallet_notification_address_copied), - ), - ) - } else { - snackbarHostState.showSnackbar( - message = resources.getStringSafe(R.string.wallet_notification_address_copied), - ) - } + tangemTopSnackbarHostState.showSnackbar( + SnackbarMessage( + startIconId = R.drawable.ic_check_24, + message = resourceReference(R.string.wallet_notification_address_copied), + ), + ) } }, + text = resourceReference(R.string.common_copy), + iconStart = TangemIconUM.Icon(Icons.ic_copy_24), + size = TangemButton.Size.X12, + variant = TangemButton.Variant.Secondary, ) - SecondaryButtonIconStart( + TangemButton( modifier = Modifier.weight(1f), - text = stringResourceSafe(id = R.string.common_share), - iconResId = R.drawable.ic_share_24, onClick = { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) onShareClick() }, + text = resourceReference(R.string.common_share), + iconStart = TangemIconUM.Icon(Icons.ic_share_android_24), + size = TangemButton.Size.X12, + variant = TangemButton.Variant.Secondary, ) } } @@ -212,20 +197,7 @@ private fun rememberQrPainter(content: String, size: Dp = 248.dp, padding: Dp = private fun Preview_TokenReceiveQrCodeContent( @PreviewParameter(TokenReceiveQrCodeContentPreviewProvider::class) qrCodeUM: QrCodeUM, ) { - TangemThemePreview { + TangemThemePreviewRedesign { TokenReceiveQrCodeContent(qrCodeUM = qrCodeUM) } -} - -private class TokenReceiveQrCodeContentPreviewProvider : PreviewParameterProvider { - private val config = QrCodeUM( - network = "Ethereum", - addressName = stringReference("Etherium"), - addressValue = "0xe5178c7d4d0e861ed2e9414e045b501226b0de8d", - onCopyClick = {}, - onShareClick = {}, - ) - - override val values: Sequence - get() = sequenceOf(config) } \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContentLegacy.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContentLegacy.kt new file mode 100644 index 0000000000..2e167ea81e --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContentLegacy.kt @@ -0,0 +1,231 @@ +package com.tangem.features.tokenreceive.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.res.getStringSafe +import com.tangem.core.ui.components.* +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.LocalTopSnackbarHostState +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TokenReceiveQrCodeBottomSheetTestTags +import com.tangem.features.tokenreceive.impl.R +import com.tangem.features.tokenreceive.ui.state.QrCodeUM +import kotlinx.coroutines.launch + +@Composable +internal fun TokenReceiveQrCodeContentLegacy(qrCodeUM: QrCodeUM) { + val snackbarHostState = remember(::SnackbarHostState) + val qrCodePainter = rememberQrPainter(content = qrCodeUM.addressValue) + + ContainerWithSnackbarHost(snackbarHostState = snackbarHostState) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = TangemTheme.colors.background.tertiary) + .padding(bottom = 16.dp) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SpacerH8() + + QrCodePage( + addressFullName = qrCodeUM.addressName, + addressValue = qrCodeUM.addressValue, + network = qrCodeUM.network, + qrCodePainter = qrCodePainter, + ) + SpacerH24() + + Buttons( + onShareClick = { qrCodeUM.onShareClick(qrCodeUM.addressValue) }, + onCopyClick = qrCodeUM.onCopyClick, + snackbarHostState = snackbarHostState, + ) + } + } +} + +@Composable +private fun QrCodePage(addressFullName: TextReference, addressValue: String, network: String, qrCodePainter: Painter) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier.padding(horizontal = TangemTheme.dimens.size36), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResourceSafe( + R.string.receive_bottom_sheet_warning_message_compact, + addressFullName.resolveReference(), + network, + ), + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + style = TangemTheme.typography.h3, + modifier = Modifier.testTag(TokenReceiveQrCodeBottomSheetTestTags.TITLE), + ) + + SpacerH(20.dp) + + Box( + modifier = Modifier + .border( + width = 8.dp, + color = TangemTheme.colors.icon.constant, + shape = RoundedCornerShape(8.dp), + ) + .padding(8.dp) + .testTag(TokenReceiveQrCodeBottomSheetTestTags.QR_CODE), + + ) { + Image( + painter = qrCodePainter, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.sizeIn(minWidth = 148.dp), + ) + } + + SpacerH24() + } + Text( + text = stringResourceSafe(R.string.wc_common_address), + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + style = TangemTheme.typography.subtitle2, + ) + + SpacerH2() + + Text( + text = addressValue, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + style = TangemTheme.typography.subtitle1, + modifier = Modifier.testTag(TokenReceiveQrCodeBottomSheetTestTags.ADDRESS), + ) + } +} + +@Composable +private fun Buttons( + snackbarHostState: SnackbarHostState, + onShareClick: () -> Unit, + onCopyClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val hapticFeedback = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val resources = context.resources + + val isRedesignEnabled = LocalRedesignEnabled.current + val tangemTopSnackbarHostState = LocalTopSnackbarHostState.current + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + SecondaryButtonIconStart( + modifier = Modifier.weight(1f), + text = stringResourceSafe(id = R.string.common_copy), + iconResId = R.drawable.ic_copy_24, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onCopyClick() + coroutineScope.launch { + if (isRedesignEnabled) { + tangemTopSnackbarHostState.showSnackbar( + SnackbarMessage( + startIconId = R.drawable.ic_check_24, + message = resourceReference(R.string.wallet_notification_address_copied), + ), + ) + } else { + snackbarHostState.showSnackbar( + message = resources.getStringSafe(R.string.wallet_notification_address_copied), + ) + } + } + }, + ) + + SecondaryButtonIconStart( + modifier = Modifier.weight(1f), + text = stringResourceSafe(id = R.string.common_share), + iconResId = R.drawable.ic_share_24, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onShareClick() + }, + ) + } +} + +@Composable +private fun rememberQrPainter(content: String, size: Dp = 248.dp, padding: Dp = 0.dp): BitmapPainter { + val density = LocalDensity.current + return remember(content) { + BitmapPainter( + content.toQrCode( + sizePx = with(density) { size.roundToPx() }, + paddingPx = with(density) { padding.roundToPx() }, + ).asImageBitmap(), + ) + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_TokenReceiveQrCodeContent( + @PreviewParameter(TokenReceiveQrCodeContentPreviewProvider::class) qrCodeUM: QrCodeUM, +) { + TangemThemePreview { + TokenReceiveQrCodeContentLegacy(qrCodeUM = qrCodeUM) + } +} + +internal class TokenReceiveQrCodeContentPreviewProvider : PreviewParameterProvider { + private val config = QrCodeUM( + network = "Ethereum", + addressName = stringReference("Etherium"), + addressValue = "0xe5178c7d4d0e861ed2e9414e045b501226b0de8d", + onCopyClick = {}, + onShareClick = {}, + ) + + override val values: Sequence + get() = sequenceOf(config) +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt index 6f4c57d692..b9223214da 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt @@ -1,8 +1,10 @@ package com.tangem.features.tokenreceive.ui import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -13,19 +15,14 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.SpacerH12 -import com.tangem.core.ui.components.SpacerH24 -import com.tangem.core.ui.components.SpacerW6 import com.tangem.core.ui.components.currency.icon.CurrencyIcon -import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.test.TokenReceiveWarningBottomSheetTestTags import com.tangem.features.tokenreceive.impl.R import com.tangem.features.tokenreceive.ui.state.WarningUM @@ -37,51 +34,51 @@ internal fun TokenReceiveWarningContent(warningUM: WarningUM) { Column( modifier = Modifier .fillMaxWidth() - .background(color = TangemTheme.colors.background.tertiary) .padding( + top = 28.dp, start = 16.dp, end = 16.dp, bottom = 16.dp, - ).testTag(TokenReceiveWarningBottomSheetTestTags.BOTTOM_SHEET), + ) + .testTag(TokenReceiveWarningBottomSheetTestTags.BOTTOM_SHEET), horizontalAlignment = Alignment.CenterHorizontally, ) { CurrencyIcon( modifier = Modifier - .padding(8.dp) - .size(size = 64.dp), + .size(size = 76.dp), state = warningUM.iconState, shouldDisplayNetwork = true, - iconSize = 56.dp, + networkBadgeSize = 24.dp, + iconSize = 72.dp, ) - - SpacerH24() - + SpacerH(32.dp) WarningBlock(networkName = warningUM.network) - - SpacerH12() - + SpacerH(8.dp) Text( + modifier = Modifier.padding(horizontal = 16.dp), textAlign = TextAlign.Center, text = stringResourceSafe(R.string.domain_receive_assets_onboarding_description), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.secondary, ) - SpacerH(48.dp) - - SecondaryButton( - modifier = Modifier.fillMaxWidth(), - text = stringResourceSafe(R.string.common_got_it), + TangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + variant = TangemButton.Variant.Secondary, + text = resourceReference(R.string.common_got_it), onClick = { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) warningUM.onWarningAcknowledged() }, + size = TangemButton.Size.X12, ) } } @Composable -fun WarningBlock(networkName: String, modifier: Modifier = Modifier) { +private fun WarningBlock(networkName: String, modifier: Modifier = Modifier) { Column( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, @@ -89,17 +86,15 @@ fun WarningBlock(networkName: String, modifier: Modifier = Modifier) { Text( textAlign = TextAlign.Center, text = stringResourceSafe(R.string.domain_receive_assets_onboarding_title), - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, ) - SpacerW6() - Text( textAlign = TextAlign.Center, text = stringResourceSafe(R.string.domain_receive_assets_onboarding_network_name, networkName), - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, ) } } @@ -110,27 +105,7 @@ fun WarningBlock(networkName: String, modifier: Modifier = Modifier) { private fun Preview_TokenReceiveWarningContent( @PreviewParameter(TokenReceiveWarningContentProvider::class) warningUM: WarningUM, ) { - TangemThemePreview { + TangemThemePreviewRedesign { TokenReceiveWarningContent(warningUM = warningUM) } -} - -private class TokenReceiveWarningContentProvider : PreviewParameterProvider { - val iconState = CurrencyIconState.TokenIcon( - url = null, - topBadgeIconResId = null, - fallbackTint = TangemColorPalette.Black, - fallbackBackground = TangemColorPalette.Meadow, - isGrayscale = false, - shouldShowCustomBadge = false, - ) - - override val values: Sequence - get() = sequenceOf( - WarningUM( - iconState = iconState, - onWarningAcknowledged = {}, - network = "Etherium", - ), - ) } \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContentLegacy.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContentLegacy.kt new file mode 100644 index 0000000000..cbf960b3c5 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContentLegacy.kt @@ -0,0 +1,139 @@ +package com.tangem.features.tokenreceive.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TokenReceiveWarningBottomSheetTestTags +import com.tangem.features.tokenreceive.impl.R +import com.tangem.features.tokenreceive.ui.state.WarningUM + +@Composable +internal fun TokenReceiveWarningContentLegacy(warningUM: WarningUM) { + val hapticFeedback = LocalHapticFeedback.current + + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = TangemTheme.colors.background.tertiary) + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ) + .testTag(TokenReceiveWarningBottomSheetTestTags.BOTTOM_SHEET), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CurrencyIcon( + modifier = Modifier + .padding(8.dp) + .size(size = 64.dp), + state = warningUM.iconState, + shouldDisplayNetwork = true, + iconSize = 56.dp, + ) + + SpacerH24() + + WarningBlock(networkName = warningUM.network) + + SpacerH12() + + Text( + textAlign = TextAlign.Center, + text = stringResourceSafe(R.string.domain_receive_assets_onboarding_description), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + + SpacerH(48.dp) + + SecondaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_got_it), + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + warningUM.onWarningAcknowledged() + }, + ) + } +} + +@Composable +private fun WarningBlock(networkName: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + textAlign = TextAlign.Center, + text = stringResourceSafe(R.string.domain_receive_assets_onboarding_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + + SpacerH(6.dp) + + Text( + textAlign = TextAlign.Center, + text = stringResourceSafe(R.string.domain_receive_assets_onboarding_network_name, networkName), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_TokenReceiveWarningContent( + @PreviewParameter(TokenReceiveWarningContentProvider::class) warningUM: WarningUM, +) { + TangemThemePreview { + TokenReceiveWarningContentLegacy(warningUM = warningUM) + } +} + +internal class TokenReceiveWarningContentProvider : PreviewParameterProvider { + val iconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = null, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ) + + override val values: Sequence + get() = sequenceOf( + WarningUM( + iconState = iconState, + onWarningAcknowledged = {}, + network = "Etherium", + ), + ) +} \ No newline at end of file From 2e4a443fa09e09b45137223fd524c8b1fa3dd1f7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 17:26:01 +0000 Subject: [PATCH 23/43] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 0f32ca5313..7f9bf93423 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.39-1589" +tangemBlockchainSdk = "releases-6.0-1590" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.39-623" +tangemCardSdk = "releases-6.0-626" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 139b4ffdc6cc792d5968c8f8131efd98c7bfe02b Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 19:28:21 +0200 Subject: [PATCH 24/43] Updated on 2026-08-14 --- .../core/ui/ds/row/header/TangemHeaderRow.kt | 15 +- .../core/ui/ds/row/internal/TangemRowTail.kt | 20 +-- .../core/ui/ds/row/token/TangemTokenRow.kt | 30 ++-- .../features/feed/ui/search/SearchContent.kt | 2 + .../page/tokenrow/TangemTokenRowStory.kt | 1 - features/wallet/impl/build.gradle.kts | 1 + .../ui/OrganizeTokensContent.kt | 132 ++++++++++-------- .../child/tokenActions/TokenActionContent.kt | 1 - .../multicurrency/MultiCurrencyContent.kt | 3 - 9 files changed, 106 insertions(+), 99 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/header/TangemHeaderRow.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/header/TangemHeaderRow.kt index 2778e5efd8..b256537196 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/row/header/TangemHeaderRow.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/header/TangemHeaderRow.kt @@ -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 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, ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/internal/TangemRowTail.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/internal/TangemRowTail.kt index d5f60de779..55fba4a4e0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/row/internal/TangemRowTail.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/internal/TangemRowTail.kt @@ -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, ) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRow.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRow.kt index 591d39ad86..30d00c7e4a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRow.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRow.kt @@ -21,24 +21,24 @@ 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. * * [Token Row](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8207-17583&t=k8dyaykorsNocGVq-4) * - * @param tokenRowUM The user model containing the data for the token row. - * @param isBalanceHidden A boolean indicating whether the balance should be hidden. - * @param reorderableState The state of the reorderable lazy list, if applicable. - * @param modifier The modifier to be applied to the row. + * @param tokenRowUM The user model containing the data for the token row. + * @param isBalanceHidden A boolean indicating whether the balance should be hidden. + * @param 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), @@ -116,18 +116,19 @@ fun TangemTokenRow( * [Token Row](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8207-17583&t=k8dyaykorsNocGVq-4) * * @param tokenRowUM The user model containing the data for the token row. - * @param 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 modifier The modifier to be applied to the row. + * @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 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), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt index 2c6f115e59..5245ef407a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.tangem.common.ui.markets.MarketsListItem @@ -405,6 +406,7 @@ private fun ShowTokensUnder100kItem(onShowTokensClick: () -> Unit, modifier: Mod Text( text = stringResourceSafe(R.string.markets_search_see_tokens_under_100k), style = TangemTheme.typography2.subheadlineMedium14, + textAlign = TextAlign.Center, color = TangemTheme.colors2.text.neutral.secondary, ) TangemButton( diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tokenrow/TangemTokenRowStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tokenrow/TangemTokenRowStory.kt index 5fa78660ba..674dc5a2ba 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tokenrow/TangemTokenRowStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tokenrow/TangemTokenRowStory.kt @@ -61,7 +61,6 @@ internal fun TangemTokenRowStory(state: TangemTokenRowStory, modifier: Modifier TangemTokenRow( tokenRowUM = um, isBalanceHidden = state.isBalanceHidden, - reorderableState = null, modifier = Modifier.background(TangemTheme.colors2.surface.level1), ) HorizontalDivider( diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index 9e06c9a21b..ede24c9be3 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(deps.compose.material3) implementation(deps.compose.paging) implementation(deps.compose.reorderable) + implementation(deps.compose.reorderableV2) implementation(deps.compose.shimmer) implementation(deps.compose.ui) implementation(deps.compose.ui.tooling) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt index 0c08233a68..b64ccf1e30 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt @@ -28,7 +28,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent @@ -48,7 +48,6 @@ import com.tangem.core.ui.ds.topbar.TangemTopBarActionContent import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM import com.tangem.core.ui.ds.topbar.TangemTopBarType import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.reordarable.ReorderableItem import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.test.OrganizeTokensScreenTestTags @@ -63,9 +62,9 @@ import com.tangem.feature.wallet.child.organizetokens.ui.preview.OrganizeTokensP import com.tangem.feature.wallet.impl.R import dev.chrisbanes.haze.rememberHazeState import org.burnoutcrew.reorderable.ItemPosition -import org.burnoutcrew.reorderable.ReorderableLazyListState -import org.burnoutcrew.reorderable.rememberReorderableLazyListState -import org.burnoutcrew.reorderable.reorderable +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.ReorderableLazyListState +import sh.calvin.reorderable.rememberReorderableLazyListState @Composable internal fun OrganizeTokensContent( @@ -134,26 +133,27 @@ private fun TokenList( val hapticFeedback = LocalHapticFeedback.current val tokenList = organizeTokensUM.tokenList + + var draggingItem by remember { mutableStateOf(null) } + Box( modifier = modifier.background(TangemTheme.colors2.surface.level2), ) { - val onDragEnd: (Int, Int) -> Unit = remember { - { _, _ -> - dragAndDropIntents.onItemDraggingEnd() - } - } - val reorderableListState = rememberReorderableLazyListState( - onMove = dragAndDropIntents::onItemDragged, - listState = tokensListState, - canDragOver = dragAndDropIntents::canDragItemOver, - onDragEnd = onDragEnd, - ) + val footerInset = LocalTangemBottomSheetContentBottomInset.current - val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + val reorderableState = rememberReorderableLazyListState( + lazyListState = tokensListState, + scrollThresholdPadding = PaddingValues(bottom = footerInset), + ) { from, to -> + dragAndDropIntents.onItemDragged( + ItemPosition(index = from.index, key = from.key), + ItemPosition(index = to.index, key = to.key), + ) + } val listContentPadding = PaddingValues( top = TangemTheme.dimens2.x1, - bottom = TangemTheme.dimens2.x1 + bottomBarHeight, + bottom = TangemTheme.dimens2.x1 + footerInset, start = TangemTheme.dimens2.x3, end = TangemTheme.dimens2.x3, ) @@ -161,60 +161,82 @@ private fun TokenList( LazyColumn( modifier = Modifier .align(Alignment.TopCenter) - .reorderable(reorderableListState) .testTag(OrganizeTokensScreenTestTags.TOKENS_LAZY_LIST), - state = reorderableListState.listState, + state = tokensListState, contentPadding = listContentPadding, ) { itemsIndexed( items = tokenList, key = { _, item -> item.id }, ) { index, item -> - - val onDragStart = remember(item) { - { - dragAndDropIntents.onItemDraggingStart(item) - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - } - } - DraggableItem( index = index, item = item, - reorderableState = reorderableListState, - onDragStart = onDragStart, + reorderableState = reorderableState, + isValidDropTarget = isValidDropTarget( + item = item, + dragging = draggingItem, + isGrouped = organizeTokensUM.isGrouped, + isAccountsMode = organizeTokensUM.isAccountsMode, + ), isBalanceHidden = organizeTokensUM.isBalanceHidden, + onDragStart = { + draggingItem = item + dragAndDropIntents.onItemDraggingStart(item) + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDragStop = { + draggingItem = null + dragAndDropIntents.onItemDraggingEnd() + }, ) } - - item { - SpacerH(TangemTheme.dimens2.x20) - } } } } +private fun isValidDropTarget( + item: OrganizeRowItemUM, + dragging: OrganizeRowItemUM?, + isGrouped: Boolean, + isAccountsMode: Boolean, +): Boolean { + return when { + dragging == null -> true + item.id == dragging.id -> true + dragging is OrganizeRowItemUM.Network -> + item is OrganizeRowItemUM.Placeholder && item.accountId == dragging.accountId + dragging is OrganizeRowItemUM.Token -> when (item) { + is OrganizeRowItemUM.Token -> when { + isGrouped -> item.groupId == dragging.groupId + isAccountsMode -> item.accountId == dragging.accountId + else -> true + } + else -> false + } + else -> false + } +} + +@Suppress("LongParameterList") @Composable private fun LazyItemScope.DraggableItem( index: Int, item: OrganizeRowItemUM, reorderableState: ReorderableLazyListState, - onDragStart: () -> Unit, + isValidDropTarget: Boolean, isBalanceHidden: Boolean, + onDragStart: () -> Unit, + onDragStop: () -> Unit, ) { - var isDragging by remember { - mutableStateOf(value = false) - } - val itemModifier = Modifier.applyShapeAndShadow(item.roundingModeUM, item.isShowShadow) ReorderableItem( - reorderableState = reorderableState, - index = index, + state = reorderableState, key = item.id, - ) { isItemDragging -> - isDragging = isItemDragging - + // Only same-group/account items are eligible drop targets while dragging (see TokenList). + enabled = isValidDropTarget, + ) { _ -> val modifierWithBackground = itemModifier .background(color = TangemTheme.colors.background.primary) .semantics { lazyListItemPosition = index } @@ -222,7 +244,10 @@ private fun LazyItemScope.DraggableItem( when (item) { is OrganizeRowItemUM.Network -> TangemHeaderRow( modifier = modifierWithBackground.testTag(OrganizeTokensScreenTestTags.GROUP_TITLE_ITEM), - reorderableState = reorderableState, + dragHandleModifier = Modifier.draggableHandle( + onDragStarted = { onDragStart() }, + onDragStopped = { onDragStop() }, + ), headerRowUM = item.headerRowUM, ) is OrganizeRowItemUM.Portfolio -> TangemHeaderRow( @@ -233,21 +258,16 @@ private fun LazyItemScope.DraggableItem( is OrganizeRowItemUM.Token -> OrganizeTokenRow( modifier = modifierWithBackground.testTag(OrganizeTokensScreenTestTags.TOKEN_LIST_ITEM), tokenRowUM = item.tokenRowUM, - reorderableState = reorderableState, + dragHandleModifier = Modifier.draggableHandle( + onDragStarted = { onDragStart() }, + onDragStopped = { onDragStop() }, + ), isBalanceHidden = isBalanceHidden, ) // Should be presented in the list but remain invisible is OrganizeRowItemUM.Placeholder -> Box(modifier = Modifier.fillMaxWidth()) } } - - DisposableEffect(isDragging) { - onDispose { - if (isDragging) { - onDragStart() - } - } - } } @Composable @@ -310,7 +330,7 @@ private fun Modifier.applyShapeAndShadow(roundingMode: RoundingModeUM, showShado private fun OrganizeTokenRow( tokenRowUM: TangemTokenRowUM, isBalanceHidden: Boolean, - reorderableState: ReorderableLazyListState?, + dragHandleModifier: Modifier, modifier: Modifier = Modifier, ) { TangemRowContainer( @@ -345,7 +365,7 @@ private fun OrganizeTokenRow( TangemRowTail( tangemRowTailUM = tokenRowUM.tailUM, - reorderableState = reorderableState, + dragHandleModifier = dragHandleModifier, modifier = Modifier .layoutId(layoutId = TangemRowLayoutId.TAIL) .testTag(tag = TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/tokenActions/TokenActionContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/tokenActions/TokenActionContent.kt index 85efce67c6..f0cd95fb56 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/tokenActions/TokenActionContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/tokenActions/TokenActionContent.kt @@ -59,7 +59,6 @@ internal fun TokenActionContent( TangemTokenRow( tokenRowUM = tokenRowUM, isBalanceHidden = isBalanceHidden, - reorderableState = null, modifier = Modifier .padding(horizontal = TangemTheme.dimens2.x3) .clip(RoundedCornerShape(18.dp)) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt index f0fa031f17..c2a634a11c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt @@ -167,7 +167,6 @@ private fun LazyListScope.tokenItem( is TangemTokenRowUM -> TangemTokenRow( tokenRowUM = tokenRowUM, isBalanceHidden = isBalanceHidden, - reorderableState = null, modifier = itemModifier .onGloballyPositioned { position = it.positionOnScreen() } .combinedClickable( @@ -238,7 +237,6 @@ private fun LazyListScope.portfolioItem( is TangemTokenRowUM -> TangemTokenRow( tokenRowUM = tokenRowUM, isBalanceHidden = isBalanceHidden, - reorderableState = null, modifier = itemModifier .onGloballyPositioned { position = it.positionInWindow() @@ -450,7 +448,6 @@ internal fun PortfolioRowItem( headComponent = composables.icon, titleComponent = composables.title, isBalanceHidden = isBalanceHidden, - reorderableState = null, ) } } From 5013bb84ca5790dbefc1791b2522828c2e7bd913 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 22:30:13 +0500 Subject: [PATCH 25/43] Updated on 2026-08-14 --- .../tangem/domain/card/CardTypesResolver.kt | 2 + .../domain/card/TangemCardTypesResolver.kt | 2 + .../domain/GetSingleWalletWarningsFactory.kt | 2 +- .../GetWalletNotificationsCarouselFactory.kt | 2 +- ...tWalletNotificationsCarouselFactoryTest.kt | 77 +++++++++++++++++++ 5 files changed, 83 insertions(+), 2 deletions(-) diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/CardTypesResolver.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/CardTypesResolver.kt index ae9c6a0fbc..222017d966 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/CardTypesResolver.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/CardTypesResolver.kt @@ -31,6 +31,8 @@ interface CardTypesResolver { fun isSingleWalletWithToken(): Boolean + fun isSingleCurrency(): Boolean + fun isMultiwalletAllowed(): Boolean fun getBlockchain(): Blockchain diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/TangemCardTypesResolver.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/TangemCardTypesResolver.kt index 3e6943f724..bf3c6faab7 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/TangemCardTypesResolver.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/TangemCardTypesResolver.kt @@ -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 && diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetSingleWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetSingleWalletWarningsFactory.kt index 82596dbd03..19ad21c2e1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetSingleWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetSingleWalletWarningsFactory.kt @@ -128,7 +128,7 @@ internal class GetSingleWalletWarningsFactory @Inject constructor( element = WalletNotification.NoteMigration( onClick = { clickIntents.onNoteMigrationButtonClick(NOTE_MIGRATION_URL) }, ), - condition = cardTypesResolver.isTangemNote() && !hasWalletOrWallet2, + condition = cardTypesResolver.isSingleCurrency() && !hasWalletOrWallet2, ) addIf( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt index 82610ab8c7..e4f9a1402f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt @@ -122,7 +122,7 @@ internal class GetWalletNotificationsCarouselFactory @Inject constructor( typesResolver.isTangemWallet() || typesResolver.isWallet2() } - addIf(cardTypesResolver != null && cardTypesResolver.isTangemNote() && !isUserHasWalletOrWallet2) { + addIf(cardTypesResolver != null && cardTypesResolver.isSingleCurrency() && !isUserHasWalletOrWallet2) { WalletNotificationUM.NoteMigration( onClick = { clickIntents.onNoteMigrationButtonClick(TangemSiteUrlBuilder.NOTE_MIGRATION_URL) }, ) diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt index 5ce92507a5..6068ef6cf8 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt @@ -5,10 +5,13 @@ import com.google.common.truth.Truth.assertThat import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.card.CardTypesResolver +import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokensGroupType import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.notifications.repository.NotificationsRepository @@ -23,10 +26,13 @@ import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @@ -85,6 +91,11 @@ internal class GetWalletNotificationsCarouselFactoryTest { } returns flowOf(accountStatusList(TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL))) } + @AfterEach + fun tearDown() { + unmockkStatic(ScanResponse::cardTypesResolver) + } + @ParameterizedTest @MethodSource("provideTestModels") fun `GIVEN gating conditions WHEN create THEN yield boost banner visibility matches`(model: Model) = runTest { @@ -134,6 +145,56 @@ internal class GetWalletNotificationsCarouselFactoryTest { verify { clickIntents.onDismissYieldBoostBanner(WALLET_ID) } } + @ParameterizedTest + @MethodSource("provideNoteMigrationTestModels") + fun `GIVEN single-currency card WHEN create THEN discover wallet promo visibility matches`( + model: NoteMigrationModel, + ) = runTest { + // Arrange + mockkStatic(ScanResponse::cardTypesResolver) + val selectedResolver = mockk(relaxed = true) { + every { isSingleCurrency() } returns model.isSingleCurrency + } + val selectedWallet = mockColdWallet(selectedResolver, walletId = WALLET_ID) + val wallets = buildList { + add(selectedWallet) + if (model.userAlreadyHasWallet) { + val walletResolver = mockk(relaxed = true) { + every { isTangemWallet() } returns true + } + add(mockColdWallet(walletResolver, walletId = OTHER_WALLET_ID)) + } + } + every { getWalletsUseCase() } returns flowOf(wallets) + + // Act + val result = factory.create(selectedWallet, clickIntents).first() + + // Assert + assertThat(result.any { it is WalletNotificationUM.NoteMigration }).isEqualTo(model.expectedShown) + } + + @Test + fun `GIVEN hot wallet WHEN create THEN discover wallet promo is hidden`() = runTest { + // Arrange + every { getWalletsUseCase() } returns flowOf(listOf(userWallet)) + + // Act + val result = factory.create(userWallet, clickIntents).first() + + // Assert + assertThat(result.none { it is WalletNotificationUM.NoteMigration }).isTrue() + } + + private fun mockColdWallet(resolver: CardTypesResolver, walletId: UserWalletId): UserWallet.Cold { + val scanResponse = mockk() + every { scanResponse.cardTypesResolver } returns resolver + return mockk(relaxed = true) { + every { this@mockk.walletId } returns walletId + every { this@mockk.scanResponse } returns scanResponse + } + } + private fun accountStatusList(balance: TotalFiatBalance) = AccountStatusList( userWalletId = WALLET_ID, accountStatuses = emptyList(), @@ -189,7 +250,23 @@ internal class GetWalletNotificationsCarouselFactoryTest { ), ) + internal data class NoteMigrationModel( + val isSingleCurrency: Boolean, + val userAlreadyHasWallet: Boolean, + val expectedShown: Boolean, + ) + + private fun provideNoteMigrationTestModels() = listOf( + // Single-currency card (Note / S2C / Twins) and the user owns no multi-currency wallet — promo shown. + NoteMigrationModel(isSingleCurrency = true, userAlreadyHasWallet = false, expectedShown = true), + // Single-currency card, but the user already owns a Wallet / Wallet2 — promo hidden. + NoteMigrationModel(isSingleCurrency = true, userAlreadyHasWallet = true, expectedShown = false), + // Multi-currency card — promo hidden. + NoteMigrationModel(isSingleCurrency = false, userAlreadyHasWallet = false, expectedShown = false), + ) + private companion object { val WALLET_ID = UserWalletId("01") + val OTHER_WALLET_ID = UserWalletId("02") } } \ No newline at end of file From 6ccfffd3bdeeea511232b6c7a3002431b6b93cfc Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 20:30:25 +0300 Subject: [PATCH 26/43] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 7f9bf93423..395415d178 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1590" +tangemBlockchainSdk = "releases-6.0-1594" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-6.0-626" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 5402221995db1332b543e5bbd4afcb94c3ba4503 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 2 Jul 2026 11:53:44 +0200 Subject: [PATCH 27/43] Updated on 2026-08-14 --- .../tangem/core/ui/ds/message/TangemMessage.kt | 16 ++++++++++------ .../feed/components/DefaultFeedEntryComponent.kt | 6 +++++- .../list/DefaultMarketsTokenListComponent.kt | 8 ++++++-- .../promobanners/impl/ui/PromoBannersBlock.kt | 2 ++ 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt index 45e60128ee..5e5ae2eb8d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt @@ -83,12 +83,16 @@ fun TangemMessage( trailingContent = if (isIconLeading) null else icon, contentColor = contentColor, onCloseClick = messageUM.onCloseClick, - buttons = { - messageUM.buttonsUM.fastForEach { buttonUM -> - TangemButton( - buttonUM = buttonUM.tangemButtonUM, - modifier = Modifier.weight(1f), - ) + buttons = if (messageUM.buttonsUM.isEmpty()) { + null + } else { + { + messageUM.buttonsUM.fastForEach { buttonUM -> + TangemButton( + buttonUM = buttonUM.tangemButtonUM, + modifier = Modifier.weight(1f), + ) + } } }, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index 9df57ca897..d09ecd8cc7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -214,7 +214,11 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( private fun onChildBack() { if (stack.value.active.configuration !is FeedEntryChildFactory.Child.Feed) { - stackNavigation.pop() + if (stack.value.backStack.isEmpty()) { + router.pop() + } else { + stackNavigation.pop() + } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt index ceaa6edccd..ab0c54f7c1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt @@ -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), diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt index 1d13b403b0..2636ca256e 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt @@ -191,6 +191,7 @@ private fun SmoothHeightPager( BannerNotification( config = banners[lowerPage].config, containerColor = containerColor, + modifier = Modifier.padding(horizontal = horizontalPadding), ) }.first().measure(pageConstraints).height @@ -199,6 +200,7 @@ private fun SmoothHeightPager( BannerNotification( config = banners[upperPage].config, containerColor = containerColor, + modifier = Modifier.padding(horizontal = horizontalPadding), ) }.first().measure(pageConstraints).height } else { From 5da166073b8cef3fdece9b2b28a817cd5b80db2a Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 2 Jul 2026 17:39:15 +0500 Subject: [PATCH 28/43] Updated on 2026-08-14 --- .../tokens/actions/BaseActionsFactory.kt | 7 ++++++ .../transformer/UpdateTransferTransformer.kt | 25 +++++++++++-------- .../UpdateTransferTransformerTest.kt | 22 ++++++++++++++++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt index f023d7fea9..75b406ee1b 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt @@ -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?, ): 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, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformer.kt index 87a40e29dd..46f2b89ec5 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformer.kt @@ -43,16 +43,21 @@ internal class UpdateTransferTransformer( }, ) } - val swapAndSendRow = swapAction?.let { action -> - TransferUM.Row( - isLoading = action.unavailabilityReason.isLoading, - isEnabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None, - onClick = { - onActionDispatched() - clickIntents.onSwapAndSendClick(action.unavailabilityReason) - }, - ) - } + // Send&Swap is only meaningful when Swap itself is available, so it is shown only in that case + // (mirrors the main-screen Transfer quick actions, which synthesize Send&Swap only when swap is available). + // This prevents a disabled Send&Swap row for cards that cannot swap at all (e.g. S2C single-currency cards). + val swapAndSendRow = swapAction + ?.takeIf { it.unavailabilityReason == ScenarioUnavailabilityReason.None } + ?.let { + TransferUM.Row( + isLoading = false, + isEnabled = true, + onClick = { + onActionDispatched() + clickIntents.onSwapAndSendClick(it.unavailabilityReason) + }, + ) + } val sellRow = sellAction?.let { action -> TransferUM.Row( isLoading = action.unavailabilityReason.isOutdatedLoading(), diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformerTest.kt index c4354538ab..e50e0b4c1a 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTransferTransformerTest.kt @@ -352,6 +352,28 @@ class UpdateTransferTransformerTest { } } + @Test + fun `GIVEN Swap action present but unavailable WHEN transform THEN Swap shown disabled AND swapAndSend is null`() { + // Arrange — cards that cannot swap (e.g. S2C single-currency) still expose a Swap action, + // but with a non-None reason. Swap stays visible-but-disabled per design, while Send&Swap + // must not appear at all (it is meaningful only when swap is available). + val transformer = createTransformer( + actions = listOf( + TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.None), + TokenActionsState.ActionState.Swap(ScenarioUnavailabilityReason.Unreachable, false), + ), + ) + + // Act + val result = transformer.transform(initialState()) + + // Assert + val content = result.transferUM as TransferUM.Content + assertThat(content.swap).isNotNull() + assertThat(content.swap?.isEnabled).isFalse() + assertThat(content.swapAndSend).isNull() + } + @Test fun `GIVEN no Swap action WHEN transform THEN swapAndSend row is null`() { // Arrange From 4ed2144ca36503bd480bb56d3df3fb8e6da941d1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 2 Jul 2026 18:21:04 +0400 Subject: [PATCH 29/43] Updated on 2026-08-14 --- .../state/transformers/AddWalletTransformer.kt | 4 ++++ .../presentation/wallet/ui/WalletScreen2.kt | 17 +++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt index f33e2908eb..ae0310fa34 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt @@ -29,6 +29,10 @@ internal class AddWalletTransformer( override fun transform(prevState: WalletScreenState): WalletScreenState { return prevState.copy( + // Select the newly added wallet synchronously (it is appended to the end), mirroring + // Initialize/Delete transformers. Otherwise selectedWalletIndex stays stale until the + // deferred scroll settles, and getSelectedWalletId() returns the previously selected wallet. + selectedWalletIndex = prevState.wallets2.size, wallets = (prevState.wallets + walletLoadingStateFactory.create(userWallet)).toImmutableList(), wallets2 = (prevState.wallets2 + walletLoadingStateFactory.create2(userWallet)).toImmutableList(), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt index ebd2c17955..8c4b1cfede 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt @@ -80,6 +80,8 @@ import com.tangem.features.virtualaccount.main.component.VirtualAccountMainBlock import com.tangem.features.virtualaccount.main.entity.VirtualAccountMainUM import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeTint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlin.math.abs @@ -208,10 +210,17 @@ private fun WalletContent2( bottom = paddingValues.calculateBottomPadding() + marketHintApproxHeight, ) - LaunchedEffect(walletsPagerState.currentPage) { - if (walletsPagerState.currentPage != state.selectedWalletIndex) { - state.onWalletChange(walletsPagerState.currentPage, false) - } + val selectedWalletIndex by rememberUpdatedState(state.selectedWalletIndex) + LaunchedEffect(walletsPagerState) { + // Only react to genuine settles and skip the page the pager was (re)created with, so a + // programmatic scroll or pager recreation can't revert the selection to a stale page. + snapshotFlow { walletsPagerState.settledPage } + .drop(count = 1) + .collectLatest { settledPage -> + if (settledPage != selectedWalletIndex) { + state.onWalletChange(settledPage, false) + } + } } val canPagerScroll by remember { derivedStateOf { behavior.state.heightOffset == 0f } } From 575081de3e3b5698882780e6684a352471be50e9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 2 Jul 2026 16:25:55 +0200 Subject: [PATCH 30/43] Updated on 2026-08-14 --- .../features/tangempay/closure/TangemPayCloseCardContent.kt | 2 +- .../tangempay/ui/TangemPayChangePinCodeSuccessScreenV2.kt | 2 +- .../presentation/wallet/domain/GetMultiWalletWarningsFactory.kt | 2 +- .../presentation/wallet/domain/GetWalletNotificationsFactory.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardContent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardContent.kt index 19032854f9..c4f4ba10d0 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardContent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardContent.kt @@ -108,7 +108,7 @@ private fun Content(state: TangemPayCloseCardUM) { modifier = Modifier.fillMaxWidth(), variant = TangemButton.Variant.Secondary, size = TangemButton.Size.X12, - text = resourceReference(R.string.tangem_pay_close_card_popup_secondary_button_title), + text = resourceReference(R.string.common_cancel), isEnabled = !state.isClosingInProgress, onClick = state.onDismissRequest, ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreenV2.kt index 703379bc57..ebbc532419 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreenV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreenV2.kt @@ -15,7 +15,7 @@ internal fun TangemPayChangePinCodeSuccessScreenV2(onClose: () -> Unit, modifier modifier = modifier, title = resourceReference(R.string.tangempay_card_details_change_pin_success_title), subtitle = resourceReference(R.string.tangempay_card_details_change_pin_success_description), - buttonText = resourceReference(R.string.common_close), + buttonText = resourceReference(R.string.common_done), onButtonClick = onClose, titleTestTag = TangemPayTestTags.PIN_SUCCESS_TITLE, subtitleTestTag = TangemPayTestTags.PIN_SUCCESS_DESCRIPTION, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index b488d4e170..54aa726f71 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -334,7 +334,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( addIf( element = WalletNotification.Informational.MissingAddresses( tangemIcon = walletInterationIcon(userWallet), - missingAddressesCount = currencies.count(), + missingAddressesCount = currencies.distinctBy { it.network.id }.count(), onGenerateClick = { clickIntents.onGenerateMissedAddressesClick( userWalletId = userWallet.walletId, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt index a8b6e590d5..003cfb3498 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt @@ -213,7 +213,7 @@ internal class GetWalletNotificationsFactory @Inject constructor( addIf( element = WalletNotificationUM.MissingAddresses( tangemIcon = walletInterationIcon(userWallet), - missingAddressesCount = currencies.count(), + missingAddressesCount = currencies.distinctBy { it.network.id }.count(), onGenerateClick = { clickIntents.onGenerateMissedAddressesClick( userWalletId = userWallet.walletId, From 04fdc401d599de64cec3b322399c78be6d4f251a Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 2 Jul 2026 15:26:40 +0100 Subject: [PATCH 31/43] Updated on 2026-08-14 --- .../notifications/model/NotificationsModel.kt | 34 +++-- .../transfer/SwapTransferInteractorImpl.kt | 25 +++- .../SwapTransferInteractorImplTest.kt | 138 ++++++++++++++++++ .../SwapTransferNotificationsFactory.kt | 9 +- 4 files changed, 184 insertions(+), 22 deletions(-) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt index 6f1349f9c3..60ed22fd79 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt @@ -149,8 +149,21 @@ internal class NotificationsModel @Inject constructor( feeValue = feeValue, reduceAmountBy = reduceAmountBy, ) + val feePaymentBalance = getCurrencyStatusForFeePayment().value.amount.orZero() + val isFeeCoverageForRent = checkFeeCoverage( + isSubtractAvailable = isAmountSubtractAvailable, + balance = feePaymentBalance, + amountValue = amountValue, + feeValue = feeValue.orZero(), + reduceAmountBy = reduceAmountBy, + ) + val sendingAmountForRentCheck = if (isFeeCoverageForRent) { + (amountValue - feeValue.orZero()).coerceAtLeast(BigDecimal.ZERO) + } else { + amountValue + } val feeCurrencyBalanceAfterTransaction = getFeeCurrencyBalanceAfterTx( - sendingAmount = sendingAmount, + sendingAmount = sendingAmountForRentCheck, feeValue = feeValue, ) val currencyCheck = getCurrencyCheckUseCase( @@ -226,14 +239,17 @@ internal class NotificationsModel @Inject constructor( } private fun getFeeCurrencyBalanceAfterTx(sendingAmount: BigDecimal, feeValue: BigDecimal?): BigDecimal? { - val sendingCurrencyBalance = cryptoCurrencyStatus.value as? CryptoCurrencyStatus.Loaded - val feeCurrencyBalance = feeCryptoCurrencyStatus.value as? CryptoCurrencyStatus.Loaded - if (feeCryptoCurrencyStatus.value !is CryptoCurrencyStatus.Loaded || feeValue == null) return null - return when { - feeCryptoCurrencyStatus == cryptoCurrencyStatus -> sendingCurrencyBalance?.let { - it.amount - sendingAmount - feeValue - } - else -> feeCurrencyBalance?.let { it.amount - feeValue } + val feeCurrencyBalance = feeCryptoCurrencyStatus.value as? CryptoCurrencyStatus.Loaded ?: return null + if (feeValue == null) return null + // Compare by currency id, not by data-class equality: the sending status and the fee status come from + // two independently-populated flows, so a native-coin send (fee paid in the coin being sent, e.g. SOL) + // yields two non-equal snapshots. Falling into the else branch there would skip subtracting the sending + // amount and hide the rent-exemption warning. + val isFeeInSendingCurrency = feeCryptoCurrencyStatus.currency.id == cryptoCurrencyStatus.currency.id + return if (isFeeInSendingCurrency) { + feeCurrencyBalance.amount - sendingAmount - feeValue + } else { + feeCurrencyBalance.amount - feeValue } } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index 0c1371e0ad..b1124566eb 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -108,6 +108,24 @@ class SwapTransferInteractorImpl @Inject constructor( val feePaidCurrency = feePaidCurrencyStatus?.currency val isFeeInOtherToken = feePaidCurrency is CryptoCurrency.Token && feePaidCurrency.id != fromToken.id val warningsFee = if (isFeeInOtherToken) BigDecimal.ZERO else fee?.amount?.value.orZero() + val isAmountSubtractAvailable = isAmountSubtractAvailable( + userWalletId = userWallet.walletId, + currency = fromTokenInfo.swapCurrencyStatus.currency, + fee = fee, + ) + val fromBalance = fromSwapCurrencyStatus.status.value.amount.orZero() + val isFeeCoverageForRent = checkFeeCoverage( + isSubtractAvailable = isAmountSubtractAvailable, + balance = fromBalance, + amountValue = fromTokenAmountValue, + feeValue = fee?.amount?.value.orZero(), + reduceAmountBy = BigDecimal.ZERO, + ) + val sendingAmountForRentCheck = if (isFeeCoverageForRent) { + (fromTokenAmountValue - fee?.amount?.value.orZero()).coerceAtLeast(BigDecimal.ZERO) + } else { + fromTokenAmountValue + } val currencyCheck = getCurrencyCheckUseCase( userWalletId = fromSwapCurrencyStatus.userWalletId, currencyStatus = fromSwapCurrencyStatus.status, @@ -117,16 +135,11 @@ class SwapTransferInteractorImpl @Inject constructor( feeCurrencyBalanceAfterTransaction = getFeeCurrencyBalanceAfterTx( fromSwapCurrencyStatus = fromSwapCurrencyStatus, feePaidCurrencyStatus = feePaidCurrencyStatus, - sendingAmount = fromTokenAmountValue, + sendingAmount = sendingAmountForRentCheck, feeValue = fee?.amount?.value, ), recipientAddress = toSwapCurrencyStatus.destinationAddress(), ) - val isAmountSubtractAvailable = isAmountSubtractAvailable( - userWalletId = userWallet.walletId, - currency = fromTokenInfo.swapCurrencyStatus.currency, - fee = fee, - ) val coverageState = getCoverageState( fromTokenInfo = fromTokenInfo, isAmountSubtractAvailable = isAmountSubtractAvailable, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index d5c42f5ef9..a8c7256646 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -724,6 +724,144 @@ internal class SwapTransferInteractorImplTest { assertThat(feeBalances.single()).isNull() } + @Test + fun `GIVEN subtract available and sub-max amount in coverage zone WHEN updateTransfer THEN feeCurrencyBalanceAfterTx surfaces the dust remainder`() = + runTest { + // Arrange: reproduces the reported Solana dust bug. Entered amount is below the balance but within + // one fee of it → fee coverage applies and the sent amount tracks the entered amount (entered - fee), + // leaving (balance - entered) on the account. That dust remainder must be surfaced so the rent + // warning can fire — it must NOT be clamped to (balance - fee), which would report a zero remainder. + val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") + val userWallet: UserWallet = mockk(relaxed = true) + val balance = BigDecimal("0.0534546") + val enteredAmount = BigDecimal("0.05332441") + val feeValue = BigDecimal("0.000205") + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + fiatRate = BigDecimal.TEN, + amount = balance, + userWallet = userWallet, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + userWallet = userWallet, + ) + val feePaidCurrencyStatus = buildFeeCurrencyStatus( + currency = fromCurrencyStatus.currency, + amount = balance, + ) + val fee: Fee = mockk(relaxed = true) { every { amount.value } returns feeValue } + stubBaseFlows(appCurrency) + coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns true.right() + coEvery { getBalanceNotEnoughForFeeWarningUseCase(any(), any(), any(), any()) } returns null.right() + val feeBalances = mutableListOf() + stubGetCurrencyCheckCapturingFeeBalance(feeBalances) + + // Act + sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = enteredAmount.toPlainString(), + feePaidCurrencyStatus = feePaidCurrencyStatus, + fee = fee, + ) + + // Assert: remainder is balance - entered (the dust), not zero. + assertThat(feeBalances.single()!!.compareTo(balance - enteredAmount)).isEqualTo(0) + } + + @Test + fun `GIVEN subtract available and max amount WHEN updateTransfer THEN feeCurrencyBalanceAfterTx is fee-adjusted remainder`() = + runTest { + // Arrange: Max send with fee coverage. The actual sent amount is entered - fee, so the true + // remainder is 0 (allowed) — the raw entered amount must not be subtracted on top of the fee. + val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") + val userWallet: UserWallet = mockk(relaxed = true) + val balance = BigDecimal("1.5") + val feeValue = BigDecimal("0.2") + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + fiatRate = BigDecimal.TEN, + amount = balance, + userWallet = userWallet, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + userWallet = userWallet, + ) + val feePaidCurrencyStatus = buildFeeCurrencyStatus( + currency = fromCurrencyStatus.currency, + amount = balance, + ) + val fee: Fee = mockk(relaxed = true) { every { amount.value } returns feeValue } + stubBaseFlows(appCurrency) + coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns true.right() + coEvery { getBalanceNotEnoughForFeeWarningUseCase(any(), any(), any(), any()) } returns null.right() + val feeBalances = mutableListOf() + stubGetCurrencyCheckCapturingFeeBalance(feeBalances) + + // Act + sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = balance.toPlainString(), + feePaidCurrencyStatus = feePaidCurrencyStatus, + fee = fee, + ) + + // Assert: fee-adjusted remainder is exactly zero, not -fee. + assertThat(feeBalances.single()!!.compareTo(BigDecimal.ZERO)).isEqualTo(0) + } + + @Test + fun `GIVEN subtract available and amount below coverage zone WHEN updateTransfer THEN feeCurrencyBalanceAfterTx is balance minus amount minus fee`() = + runTest { + // Arrange: amount well below balance → no fee coverage → the entered amount is used as-is. + val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") + val userWallet: UserWallet = mockk(relaxed = true) + val balance = BigDecimal("2.0") + val enteredAmount = BigDecimal("0.5") + val feeValue = BigDecimal("0.1") + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + fiatRate = BigDecimal.TEN, + amount = balance, + userWallet = userWallet, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + userWallet = userWallet, + ) + val feePaidCurrencyStatus = buildFeeCurrencyStatus( + currency = fromCurrencyStatus.currency, + amount = balance, + ) + val fee: Fee = mockk(relaxed = true) { every { amount.value } returns feeValue } + stubBaseFlows(appCurrency) + coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns true.right() + coEvery { getBalanceNotEnoughForFeeWarningUseCase(any(), any(), any(), any()) } returns null.right() + val feeBalances = mutableListOf() + stubGetCurrencyCheckCapturingFeeBalance(feeBalances) + + // Act + sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = enteredAmount.toPlainString(), + feePaidCurrencyStatus = feePaidCurrencyStatus, + fee = fee, + ) + + // Assert + assertThat(feeBalances.single()!!.compareTo(balance - enteredAmount - feeValue)).isEqualTo(0) + } + // endregion // region loadFee diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt index 7a631e308c..7201b3d59e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt @@ -7,6 +7,7 @@ import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalance import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addRentExemptionNotification import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications @@ -44,7 +45,7 @@ internal class SwapTransferNotificationsFactory @Inject constructor() { val feeContent = feeSelectorUM val getFeeError = (feeSelectorUM as? FeeSelectorUM.Error)?.error return buildList { - maybeAddRentExemptionError(transferState) + addRentExemptionNotification(transferState.currencyCheck?.rentWarning) maybeAddDomainWarnings( state = transferState, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, @@ -72,12 +73,6 @@ internal class SwapTransferNotificationsFactory @Inject constructor() { }.toPersistentList() } - private fun MutableList.maybeAddRentExemptionError(state: SwapState.Transfer) { - state.currencyCheck?.rentWarning?.let { - add(NotificationUM.Solana.RentInfo(it)) - } - } - private fun MutableList.maybeAddDomainWarnings( state: SwapState.Transfer, feeCryptoCurrencyStatus: CryptoCurrencyStatus?, From 49aede77dc6c756847733e7a93d629004965179e Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 3 Jul 2026 09:17:57 +0200 Subject: [PATCH 32/43] Updated on 2026-08-14 --- .../ui/OnrampAddToPortfolioContent.kt | 6 +- .../TonInitializeAccountBottomSheetConfig.kt | 1 + ...ShowTonInitializeBottomSheetTransformer.kt | 1 + .../TonInitializeAccountBottomSheet.kt | 32 +++++++---- .../tokendetails/model/TokenDetailsModel.kt | 56 ++++++------------- .../UpdateNotificationsTransformer.kt | 12 ++-- .../UpdateNotificationsTransformerTest.kt | 8 +-- .../WcSendMultipleTransactionsComponent.kt | 2 + .../converter/WcSendTransactionUMConverter.kt | 8 +-- .../entity/addresses/WcGetAddressesUM.kt | 2 +- ...endMultipleTransactionsModalBottomSheet.kt | 7 ++- 11 files changed, 67 insertions(+), 68 deletions(-) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/ui/OnrampAddToPortfolioContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/ui/OnrampAddToPortfolioContent.kt index db961c1580..ee0274ed13 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/ui/OnrampAddToPortfolioContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/ui/OnrampAddToPortfolioContent.kt @@ -111,7 +111,11 @@ private fun CurrencyNetworkName(text: TextReference, modifier: Modifier = Modifi private fun AddToPortfolioButton(state: OnrampAddToPortfolioUM.AddButtonUM, modifier: Modifier = Modifier) { TangemButton( text = state.text.resolveReference(), - icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24), + icon = if (state.isTangemIconVisible) { + TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24) + } else { + TangemButtonIconPosition.None + }, onClick = state.onClick, colors = TangemButtonsDefaults.primaryButtonColors, showProgress = state.isProgress, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/bottomsheet/TonInitializeAccountBottomSheetConfig.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/bottomsheet/TonInitializeAccountBottomSheetConfig.kt index f941d083ec..67fd5d26a0 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/bottomsheet/TonInitializeAccountBottomSheetConfig.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/bottomsheet/TonInitializeAccountBottomSheetConfig.kt @@ -12,4 +12,5 @@ internal data class TonInitializeAccountBottomSheetConfig( val isButtonEnabled: Boolean, val isButtonLoading: Boolean, val feeState: FeeState, + val isColdWalletInteractionIconVisible: Boolean, ) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ton/ShowTonInitializeBottomSheetTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ton/ShowTonInitializeBottomSheetTransformer.kt index 27e17d0ca7..7db33d4392 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ton/ShowTonInitializeBottomSheetTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ton/ShowTonInitializeBottomSheetTransformer.kt @@ -25,6 +25,7 @@ internal class ShowTonInitializeBottomSheetTransformer( isButtonEnabled = false, feeState = FeeState.Loading, isButtonLoading = false, + isColdWalletInteractionIconVisible = prevState.isColdWalletInteractionIconVisible, ), ), ) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/TonInitializeAccountBottomSheet.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/TonInitializeAccountBottomSheet.kt index 21be5002e8..c4ef7779b9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/TonInitializeAccountBottomSheet.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/TonInitializeAccountBottomSheet.kt @@ -101,23 +101,32 @@ internal fun TonInitializeAccountBottomSheet(config: TangemBottomSheetConfig) { SpacerH(height = 20.dp) - TangemButton( - text = stringResourceSafe(R.string.common_activate), - icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24), - onClick = content.onButtonClick, - colors = TangemButtonsDefaults.primaryButtonColors, - showProgress = content.feeState is FeeState.Loading || content.isButtonLoading, - enabled = content.isButtonEnabled, - size = TangemButtonSize.WideAction, - textStyle = TangemTheme.typography.subtitle1, - modifier = Modifier.fillMaxWidth(), - ) + ActivateButton(content) SpacerH(height = 8.dp) } } } +@Composable +private fun ActivateButton(content: TonInitializeAccountBottomSheetConfig, modifier: Modifier = Modifier) { + TangemButton( + text = stringResourceSafe(R.string.common_activate), + icon = if (content.isColdWalletInteractionIconVisible) { + TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24) + } else { + TangemButtonIconPosition.None + }, + onClick = content.onButtonClick, + colors = TangemButtonsDefaults.primaryButtonColors, + showProgress = content.feeState is FeeState.Loading || content.isButtonLoading, + enabled = content.isButtonEnabled, + size = TangemButtonSize.WideAction, + textStyle = TangemTheme.typography.subtitle1, + modifier = modifier.fillMaxWidth(), + ) +} + // region Preview @Composable @Preview(showBackground = true, widthDp = 360) @@ -149,6 +158,7 @@ private fun Preview_TonInitializeAccountBottomSheetContent() { isFeeApproximate = false, isFeeConvertibleToFiat = true, ), + isColdWalletInteractionIconVisible = true, ), ), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index df605c6c7e..48d4cc262f 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -3,38 +3,29 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model import androidx.compose.runtime.Stable import arrow.core.getOrElse import arrow.core.right -import com.tangem.utils.logging.TangemLogger import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchain.common.address.AddressType -import com.tangem.domain.dynamicaddresses.IsDynamicAddressesAvailableUseCase -import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase import com.tangem.common.extensions.calculateSha256 import com.tangem.common.extensions.hexToBytes import com.tangem.common.extensions.toHexString -import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.common.ui.bottomsheet.receive.AddressModel import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels -import com.tangem.features.rating.RatingComponent -import com.tangem.feature.swap.domain.SwapFeedbackUseCase -import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams -import com.tangem.features.swap.SwapFeatureToggles +import com.tangem.common.ui.tokens.getUnavailabilityReasonText +import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter +import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent import com.tangem.core.decompose.di.GlobalUiMessageSender -import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter -import com.tangem.domain.account.supplier.SingleAccountListSupplier -import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener -import com.tangem.common.ui.tokens.getUnavailabilityReasonText import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.ds.image.DeviceIconUM @@ -50,13 +41,18 @@ import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.card.IsWalletBackupProblematicUseCase -import com.tangem.domain.feedback.SendBackupProblemEmailUseCase +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains +import com.tangem.domain.dynamicaddresses.IsDynamicAddressesAvailableUseCase +import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.feedback.SendBackupProblemEmailUseCase import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveNotification import com.tangem.domain.models.account.Account @@ -89,41 +85,23 @@ import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.usecase.* import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase -import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase -import com.tangem.domain.wallets.usecase.GetWalletIconUseCase -import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase -import com.tangem.domain.wallets.usecase.GetUserWalletUseCase -import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase +import com.tangem.domain.wallets.usecase.* import com.tangem.domain.yield.supply.models.YieldSupplyRewardBalance import com.tangem.domain.yield.supply.usecase.YieldSupplyGetRewardsBalanceUseCase +import com.tangem.feature.swap.domain.SwapFeedbackUseCase +import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams import com.tangem.feature.tokendetails.deeplink.TokenDetailsDeepLinkActionListener import com.tangem.feature.tokendetails.domain.GetCurrencyWarningsUseCase import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsCurrencyStatusAnalyticsSender import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsNotificationsAnalyticsSender import com.tangem.feature.tokendetails.presentation.tokendetails.route.TokenDetailsBottomSheetConfig -import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsStateController -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.* import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.QuickTopUpBlockFactory import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory -import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer -import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer -import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetYieldSupplyBalanceTransformer -import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateActionButtonsTransformer -import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateAddFundsTransformer -import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTransferTransformer -import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateZeroBalanceActionsTransformer -import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.BindAddFundsActionButtonTransformer -import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.BindTransferActionButtonTransformer -import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetTopBarTitleTransformer -import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.ToggleBalanceTypeTransformer -import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateStakingNotificationTransformer -import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateNotificationsTransformer -import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTopBarMenuTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.* +import com.tangem.features.rating.RatingComponent +import com.tangem.features.swap.SwapFeatureToggles import com.tangem.features.tokendetails.ExpressTransactionsEvent import com.tangem.features.tokendetails.ExpressTransactionsEventListener import com.tangem.features.tokendetails.TokenDetailsComponent @@ -134,6 +112,7 @@ import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.utils.Provider import com.tangem.utils.coroutines.* import com.tangem.utils.extensions.isZero +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -438,6 +417,7 @@ internal class TokenDetailsModel @Inject constructor( UpdateNotificationsTransformer( warnings = warnings, clickIntents = this@TokenDetailsModel, + walletInteractionIcon = walletInterationIcon(userWallet), ), ) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt index c292623e56..9fdd660903 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt @@ -1,5 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer +import androidx.annotation.DrawableRes import com.tangem.blockchain.common.Blockchain import com.tangem.core.ui.R import com.tangem.core.ui.ds.button.TangemButtonType @@ -31,6 +32,7 @@ import com.tangem.core.res.R as CoreResR internal class UpdateNotificationsTransformer( private val warnings: Set, private val clickIntents: TokenDetailsClickIntents, + @DrawableRes private val walletInteractionIcon: Int?, ) : Transformer { override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { @@ -170,10 +172,12 @@ internal class UpdateNotificationsTransformer( TangemMessageButtonUM( text = resourceReference(CoreResR.string.alert_button_try_again), type = TangemButtonType.Primary, - tangemIconUM = TangemIconUM.Icon( - R.drawable.ic_tangem_24, - tintReference = { TangemTheme.colors2.graphic.neutral.primaryInverted }, - ), + tangemIconUM = walletInteractionIcon?.let { iconRes -> + TangemIconUM.Icon( + iconRes = iconRes, + tintReference = { TangemTheme.colors2.graphic.neutral.primaryInverted }, + ) + }, onClick = clickIntents::onRetryIncompleteTransactionClick, ), ), diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt index b87755d855..2634218c36 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt @@ -11,13 +11,8 @@ import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings import com.tangem.domain.tokens.model.warnings.HederaWarnings import com.tangem.domain.tokens.model.warnings.KaspaWarnings import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents -import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.* import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM -import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM import io.mockk.mockk import io.mockk.verify import kotlinx.collections.immutable.persistentListOf @@ -684,6 +679,7 @@ class UpdateNotificationsTransformerTest { private fun createTransformer(warnings: Set) = UpdateNotificationsTransformer( warnings = warnings, clickIntents = clickIntents, + walletInteractionIcon = null, ) private fun initialState(): TokenDetailsUM = TokenDetailsUM( diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendMultipleTransactionsComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendMultipleTransactionsComponent.kt index 0146849910..9055828c14 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendMultipleTransactionsComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendMultipleTransactionsComponent.kt @@ -1,6 +1,7 @@ package com.tangem.features.walletconnect.transaction.components.send import androidx.compose.runtime.Composable +import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent @@ -26,6 +27,7 @@ internal class WcSendMultipleTransactionsComponent( onDismissRequest = { model.popBack() }, content = TangemBottomSheetConfigContent.Empty, ), + walletInteractionIcon = walletInterationIcon(model.userWallet), onConfirm = onConfirm, onBack = { model.popBack() }, ) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt index 6decf0a891..244639b337 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt @@ -1,13 +1,10 @@ package com.tangem.features.walletconnect.transaction.converter import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.isHotWallet -import com.tangem.domain.walletconnect.model.WcBitcoinMethod -import com.tangem.domain.walletconnect.model.WcEthMethod -import com.tangem.domain.walletconnect.model.WcMethod -import com.tangem.domain.walletconnect.model.WcPsbtOutput -import com.tangem.domain.walletconnect.model.WcSolanaMethod +import com.tangem.domain.walletconnect.model.* import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck import com.tangem.domain.walletconnect.usecase.method.WcMethodContext import com.tangem.domain.walletconnect.usecase.method.WcSignState @@ -74,6 +71,7 @@ internal class WcSendTransactionUMConverter @Inject constructor( } }, feeErrorNotification = feeErrorNotification, + walletInteractionIcon = walletInterationIcon(value.context.session.wallet), isHoldToConfirmEnabled = value.context.session.wallet.isHotWallet, ), feeSelectorUM = when (value.feeState) { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/addresses/WcGetAddressesUM.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/addresses/WcGetAddressesUM.kt index 05b5667a53..ca0721a511 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/addresses/WcGetAddressesUM.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/addresses/WcGetAddressesUM.kt @@ -12,7 +12,7 @@ internal data class WcGetAddressesUM( val networkInfo: WcNetworkInfoUM, val addresses: List, val isLoading: Boolean, - @DrawableRes val walletInteractionIcon: Int, + @DrawableRes val walletInteractionIcon: Int?, val onApprove: () -> Unit, val onReject: () -> Unit, ) { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendMultipleTransactionsModalBottomSheet.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendMultipleTransactionsModalBottomSheet.kt index 85a56ac9fb..ee70cb1a76 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendMultipleTransactionsModalBottomSheet.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendMultipleTransactionsModalBottomSheet.kt @@ -1,6 +1,7 @@ package com.tangem.features.walletconnect.transaction.ui.send import android.content.res.Configuration +import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -32,6 +33,7 @@ import com.tangem.core.ui.res.TangemThemePreview @Composable internal fun WcSendMultipleTransactionsModalBottomSheet( config: TangemBottomSheetConfig, + @DrawableRes walletInteractionIcon: Int?, onConfirm: () -> Unit, onBack: () -> Unit, ) { @@ -50,7 +52,7 @@ internal fun WcSendMultipleTransactionsModalBottomSheet( .background(TangemTheme.colors.icon.informative.copy(alpha = 0.1f)) .padding(12.dp), painter = rememberVectorPainter( - ImageVector.vectorResource(com.tangem.core.ui.R.drawable.ic_alert_24), + ImageVector.vectorResource(R.drawable.ic_alert_24), ), tint = TangemTheme.colors.icon.attention, contentDescription = null, @@ -78,7 +80,7 @@ internal fun WcSendMultipleTransactionsModalBottomSheet( SpacerH8() PrimaryButtonIconEnd( modifier = Modifier.fillMaxWidth(), - iconResId = R.drawable.ic_tangem_24, + iconResId = walletInteractionIcon, text = stringResourceSafe(R.string.common_send), onClick = onConfirm, ) @@ -98,6 +100,7 @@ private fun WcSendTransactionBottomSheetPreview() { onDismissRequest = {}, content = TangemBottomSheetConfigContent.Empty, ), + walletInteractionIcon = R.drawable.ic_tangem_24, onConfirm = {}, onBack = {}, ) From 238e955fdda8a96f8242c12afbf89d11326c89ca Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 3 Jul 2026 16:57:00 +0500 Subject: [PATCH 33/43] Updated on 2026-08-14 --- .../converter/WalletTokensListUMConverter.kt | 4 +- .../state/utils/UserWalletConverterExt.kt | 4 + .../WalletTokensListUMConverterTest.kt | 174 ++++++++++++++++++ 3 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverterTest.kt diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt index a446ce4b30..010526dbd1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt @@ -24,7 +24,7 @@ import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.wallet.state.model.TokensListItemUM2 import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListUM -import com.tangem.feature.wallet.presentation.wallet.state.utils.isSingleWalletWithToken +import com.tangem.feature.wallet.presentation.wallet.state.utils.isSingleCurrency import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toPersistentList import java.math.BigDecimal @@ -171,7 +171,7 @@ internal class WalletTokensListUMConverter( } else { R.drawable.ic_filter_24 } - return if (accountList.flattenCurrencies().isNotEmpty() && !selectedWallet.isSingleWalletWithToken()) { + return if (accountList.flattenCurrencies().isNotEmpty() && !selectedWallet.isSingleCurrency()) { TangemButtonUM( text = resourceReference(textRes), isEnabled = accountList.totalFiatBalance !is TotalFiatBalance.Loading, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/UserWalletConverterExt.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/UserWalletConverterExt.kt index 8e3ebc996c..e7ee711544 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/UserWalletConverterExt.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/UserWalletConverterExt.kt @@ -25,4 +25,8 @@ internal fun UserWallet.isSingleWallet(): Boolean { internal fun UserWallet.isSingleWalletWithToken(): Boolean { return this is UserWallet.Cold && scanResponse.cardTypesResolver.isSingleWalletWithToken() +} + +internal fun UserWallet.isSingleCurrency(): Boolean { + return this is UserWallet.Cold && scanResponse.cardTypesResolver.isSingleCurrency() } \ No newline at end of file diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverterTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverterTest.kt new file mode 100644 index 0000000000..172497b821 --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverterTest.kt @@ -0,0 +1,174 @@ +package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.card.CardTypesResolver +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.core.utils.lceError +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account.CryptoPortfolio.Companion.createMainAccount +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListUM +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class WalletTokensListUMConverterTest { + + private val userWalletId = UserWalletId("00") + + @AfterEach + fun tearDown() { + unmockkStatic(ScanResponse::cardTypesResolver) + } + + @ParameterizedTest + @MethodSource("provideOrganizeButtonModels") + fun `GIVEN wallet type WHEN convert THEN Add and Manage button visibility matches`(model: OrganizeButtonModel) { + // Arrange + val converter = createConverter( + selectedWallet = mockColdWallet( + isSingleCurrency = model.isSingleCurrency, + isSingleWalletWithToken = model.isSingleWalletWithToken, + ), + ) + + // Act + val result = converter.convert(value = nonEmptyAccountList()) as WalletTokensListUM.Content + + // Assert + assertThat(result.organizeButtonUM != null).isEqualTo(model.expectedButtonShown) + } + + private fun createConverter(selectedWallet: UserWallet): WalletTokensListUMConverter = WalletTokensListUMConverter( + appCurrency = AppCurrency.Default, + selectedWallet = selectedWallet, + clickIntents = mockk(relaxed = true), + yieldModuleApyMap = emptyMap(), + isAccountsModeEnabled = false, + expandedAccounts = emptySet(), + stakingAvailabilityMap = emptyMap(), + isAddAndManageTokensEnabled = true, + shouldShowMainPromo = false, + ) + + private fun mockColdWallet(isSingleCurrency: Boolean, isSingleWalletWithToken: Boolean): UserWallet.Cold { + mockkStatic(ScanResponse::cardTypesResolver) + val resolver = mockk(relaxed = true) { + every { isSingleCurrency() } returns isSingleCurrency + every { isSingleWalletWithToken() } returns isSingleWalletWithToken + } + val scanResponse = mockk() + every { scanResponse.cardTypesResolver } returns resolver + return mockk(relaxed = true) { + every { this@mockk.scanResponse } returns scanResponse + } + } + + private fun nonEmptyAccountList(): AccountStatusList { + val tokenList = TokenList.Ungrouped( + currencies = listOf(createLoadedStatus(createToken())), + totalFiatBalance = TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL), + sortedBy = TokensSortType.NONE, + ) + return AccountStatusList( + userWalletId = userWalletId, + accountStatuses = listOf( + AccountStatus.CryptoPortfolio( + account = createMainAccount(userWalletId), + tokenList = tokenList, + priceChangeLce = Unit.lceError(), + ), + ), + totalAccounts = 1, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL), + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + } + + private fun createToken(): CryptoCurrency.Token { + val network = Network( + id = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None), + name = "ethereum", + currencySymbol = "ETH", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.ERC20, + hasFiatFeeRate = false, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawId = "ethereum"), + suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = "0xABCDEF"), + ), + network = network, + name = "Token", + symbol = "TKN", + decimals = 18, + iconUrl = null, + isCustom = false, + contractAddress = "0xABCDEF", + ) + } + + private fun createLoadedStatus(token: CryptoCurrency.Token): CryptoCurrencyStatus { + val value = CryptoCurrencyStatus.Loaded( + amount = BigDecimal.ONE, + fiatAmount = BigDecimal.ZERO, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "addr", + type = NetworkAddress.Address.Type.Primary, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + ) + return CryptoCurrencyStatus(currency = token, value = value) + } + + internal data class OrganizeButtonModel( + val isSingleCurrency: Boolean, + val isSingleWalletWithToken: Boolean, + val expectedButtonShown: Boolean, + ) + + private fun provideOrganizeButtonModels() = listOf( + // Multi-currency wallet (Wallet / Wallet2) — Add & Manage is shown. + OrganizeButtonModel(isSingleCurrency = false, isSingleWalletWithToken = false, expectedButtonShown = true), + // Single-currency wallet without token (e.g. S2C) — Add & Manage must be hidden ([REDACTED_TASK_KEY]). + OrganizeButtonModel(isSingleCurrency = true, isSingleWalletWithToken = false, expectedButtonShown = false), + // Single-currency wallet with token (e.g. NODL) — Add & Manage stays hidden. + OrganizeButtonModel(isSingleCurrency = true, isSingleWalletWithToken = true, expectedButtonShown = false), + ) +} \ No newline at end of file From f33c30851f55ddd6a017ce86fe79530d77169045 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 3 Jul 2026 14:26:25 +0200 Subject: [PATCH 34/43] Updated on 2026-08-14 --- .../features/tangempay/ui/TangemPayAddToWalletScreenV2.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreenV2.kt index 0c3b6175ec..7a962e03e9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreenV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreenV2.kt @@ -95,7 +95,7 @@ private fun ColumnScope.DynamicSpacer(scrollState: ScrollState) { private fun AddToWalletTitle(modifier: Modifier = Modifier) { Text( modifier = modifier - .padding(vertical = 12.dp, horizontal = 16.dp) + .padding(vertical = 12.dp, horizontal = 24.dp) .fillMaxWidth(), text = stringResourceSafe(R.string.tangempay_card_details_open_wallet_title), style = TangemTheme.typography3.heading.medium, @@ -111,8 +111,8 @@ private fun AddToWalletSteps(steps: ImmutableList Date: Fri, 3 Jul 2026 18:03:15 +0500 Subject: [PATCH 35/43] Updated on 2026-08-14 --- app/build.gradle.kts | 1 + .../AppsFlyerReferralParamsHandler.kt | 37 ++++---- .../di/UserWalletsListRepositoryModule.kt | 6 +- .../DefaultUserWalletsListRepository.kt | 13 +-- .../component/impl/DefaultRoutingComponent.kt | 60 +++++++----- .../AppsFlyerReferralParamsHandlerTest.kt | 94 +++++++++++++++++-- .../DefaultUserWalletsListRepositoryTest.kt | 8 +- .../local/appsflyer/AppsFlyerStore.kt | 2 + .../appsflyer/DefaultAppsFlyerRepository.kt | 5 + .../data/appsflyer/di/AppsFlyerDataModule.kt | 6 ++ .../appsflyer/AppsFlyerDeeplinkSource.kt | 1 + .../repository/AppsFlyerRepository.kt | 2 + .../usecase/IsReferralInstallUseCase.kt | 15 +++ features/home/impl/build.gradle.kts | 4 +- .../features/home/impl/model/HomeModel.kt | 6 +- .../DefaultMobileWalletPromoRepository.kt | 26 ----- .../referral/di/ReferralRepositoryModule.kt | 10 -- .../domain/MobileWalletPromoRepository.kt | 8 -- .../SetShouldShowMobileWalletPromoUseCase.kt | 18 ---- .../ShouldShowMobileWalletPromoUseCase.kt | 12 --- 20 files changed, 189 insertions(+), 145 deletions(-) create mode 100644 domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/usecase/IsReferralInstallUseCase.kt delete mode 100644 features/referral/data/src/main/java/com/tangem/feature/referral/data/DefaultMobileWalletPromoRepository.kt delete mode 100644 features/referral/domain/src/main/java/com/tangem/feature/referral/domain/MobileWalletPromoRepository.kt delete mode 100644 features/referral/domain/src/main/java/com/tangem/feature/referral/domain/SetShouldShowMobileWalletPromoUseCase.kt delete mode 100644 features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ShouldShowMobileWalletPromoUseCase.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c307bb3f9b..530687a6d1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandler.kt index 70a24bee80..c7edd65901 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandler.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandler.kt @@ -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() fun handle(params: Map) { @@ -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) { - AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE - } - deepLinkDeferred.await().takeIf { it == value } - } else { - deeplinkFromCache + 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 } + 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,13 +99,9 @@ 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), - ) - } + appsFlyerStore.storeIfAbsent( + value = AppsFlyerConversionData(refcode = refcode, campaign = campaign), + ) } } diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListRepositoryModule.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListRepositoryModule.kt index f9323c265d..066cfc843e 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListRepositoryModule.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListRepositoryModule.kt @@ -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, ): UserWalletsListRepository { val moshi = buildMoshi() @@ -109,7 +109,7 @@ internal object UserWalletsListRepositoryModule { trackingContextProxy = trackingContextProxy, analyticsEventHandler = analyticsEventHandler, hotWalletRepository = hotWalletRepository, - mobileWalletPromoRepository = mobileWalletPromoRepository, + clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase, userWalletSelectedHandler = userWalletSelectedHandler, ) } diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index 5c6d10635a..e2a1e3fd27 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -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, ) : UserWalletsListRepository { @@ -620,12 +621,12 @@ 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) + // reset the referral attribution (set from AF deeplink) after removing the last wallet + clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.Referral) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt index 6e5488d67b..305d341eda 100644 --- a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt +++ b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt @@ -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 { @@ -210,23 +210,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 @@ -246,16 +231,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) } } diff --git a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt index 385abb101c..206f23f70a 100644 --- a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt @@ -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 { + 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 { + 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" diff --git a/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepositoryTest.kt b/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepositoryTest.kt index 08b434e498..6593737113 100644 --- a/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepositoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepositoryTest.kt @@ -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 }, ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerStore.kt index 06f57bada6..b4c8e2018d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerStore.kt @@ -23,9 +23,11 @@ interface AppsFlyerStore { enum class AppsFlyerDeeplinkSource { TangemPayHotWalletOnboarding, + Referral, ; fun toStoreKey() = when (this) { TangemPayHotWalletOnboarding -> "tangem_pay_hot_wallet_onboarding" + Referral -> "referral" } } \ No newline at end of file diff --git a/data/appsflyer/src/main/java/com/tangem/data/appsflyer/DefaultAppsFlyerRepository.kt b/data/appsflyer/src/main/java/com/tangem/data/appsflyer/DefaultAppsFlyerRepository.kt index 0e9c5adbcc..680731d723 100644 --- a/data/appsflyer/src/main/java/com/tangem/data/appsflyer/DefaultAppsFlyerRepository.kt +++ b/data/appsflyer/src/main/java/com/tangem/data/appsflyer/DefaultAppsFlyerRepository.kt @@ -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 } } \ No newline at end of file diff --git a/data/appsflyer/src/main/java/com/tangem/data/appsflyer/di/AppsFlyerDataModule.kt b/data/appsflyer/src/main/java/com/tangem/data/appsflyer/di/AppsFlyerDataModule.kt index 27bcc1077f..9e23a23f3e 100644 --- a/data/appsflyer/src/main/java/com/tangem/data/appsflyer/di/AppsFlyerDataModule.kt +++ b/data/appsflyer/src/main/java/com/tangem/data/appsflyer/di/AppsFlyerDataModule.kt @@ -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) + } } } \ No newline at end of file diff --git a/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/AppsFlyerDeeplinkSource.kt b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/AppsFlyerDeeplinkSource.kt index 07eca402ff..d516a1ef10 100644 --- a/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/AppsFlyerDeeplinkSource.kt +++ b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/AppsFlyerDeeplinkSource.kt @@ -2,4 +2,5 @@ package com.tangem.domain.appsflyer enum class AppsFlyerDeeplinkSource { TangemPayHotWalletOnboarding, + Referral, } \ No newline at end of file diff --git a/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/repository/AppsFlyerRepository.kt b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/repository/AppsFlyerRepository.kt index 911eba519c..3ed7bde4d3 100644 --- a/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/repository/AppsFlyerRepository.kt +++ b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/repository/AppsFlyerRepository.kt @@ -4,5 +4,7 @@ import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource interface AppsFlyerRepository { + suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String? + suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) } \ No newline at end of file diff --git a/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/usecase/IsReferralInstallUseCase.kt b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/usecase/IsReferralInstallUseCase.kt new file mode 100644 index 0000000000..398ada8802 --- /dev/null +++ b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/usecase/IsReferralInstallUseCase.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index afa7941725..2cd201d377 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation(projects.common.routing) /** Domain */ + implementation(projects.domain.appsflyer) implementation(projects.domain.common) implementation(projects.domain.models) implementation(projects.domain.core) @@ -41,9 +42,6 @@ dependencies { implementation(projects.domain.feedback.models) implementation(projects.domain.referral) - /** Referral */ - implementation(projects.features.referral.domain) - /** AndroidX libraries */ implementation(deps.androidx.activity.compose) implementation(deps.lifecycle.runtime.ktx) diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt index 147e146fa6..ff2f441713 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt @@ -18,6 +18,7 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.message.dialog.Dialogs +import com.tangem.domain.appsflyer.usecase.IsReferralInstallUseCase import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.card.analytics.IntroductionProcess import com.tangem.domain.card.analytics.Shop @@ -32,7 +33,6 @@ import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase -import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase import com.tangem.features.home.api.HomeComponent import com.tangem.features.home.impl.ui.state.HomeUM import com.tangem.features.home.impl.ui.state.Stories @@ -66,7 +66,7 @@ internal class HomeModel @Inject constructor( private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val urlOpener: UrlOpener, private val userWalletsListRepository: UserWalletsListRepository, - private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase, + private val isReferralInstallUseCase: IsReferralInstallUseCase, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { @@ -136,7 +136,7 @@ internal class HomeModel @Inject constructor( private fun onGetStartedClick() { debouncer.debounce(modelScope) { modelScope.launch { - val mode = if (shouldShowMobileWalletPromoUseCase()) { + val mode = if (isReferralInstallUseCase()) { AppRoute.CreateWalletStart.Mode.HotWallet } else { AppRoute.CreateWalletStart.Mode.ColdWallet diff --git a/features/referral/data/src/main/java/com/tangem/feature/referral/data/DefaultMobileWalletPromoRepository.kt b/features/referral/data/src/main/java/com/tangem/feature/referral/data/DefaultMobileWalletPromoRepository.kt deleted file mode 100644 index 1fd76e4838..0000000000 --- a/features/referral/data/src/main/java/com/tangem/feature/referral/data/DefaultMobileWalletPromoRepository.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.tangem.feature.referral.data - -import androidx.datastore.preferences.core.booleanPreferencesKey -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.utils.getSyncOrDefault -import com.tangem.feature.referral.domain.MobileWalletPromoRepository -import javax.inject.Inject - -internal class DefaultMobileWalletPromoRepository @Inject constructor( - private val appPreferencesStore: AppPreferencesStore, -) : MobileWalletPromoRepository { - - override suspend fun shouldShowMobileWalletPromo(): Boolean { - return appPreferencesStore.getSyncOrDefault(key = SHOULD_SHOW_MOBILE_WALLET_PROMO_KEY, default = false) - } - - override suspend fun setShouldShowMobileWalletPromo(shouldShowPromo: Boolean) { - appPreferencesStore.editData { preferences -> - preferences[SHOULD_SHOW_MOBILE_WALLET_PROMO_KEY] = shouldShowPromo - } - } - - private companion object { - val SHOULD_SHOW_MOBILE_WALLET_PROMO_KEY = booleanPreferencesKey("should_show_mobile_wallet_promo") - } -} \ No newline at end of file diff --git a/features/referral/data/src/main/java/com/tangem/feature/referral/di/ReferralRepositoryModule.kt b/features/referral/data/src/main/java/com/tangem/feature/referral/di/ReferralRepositoryModule.kt index af0876c251..25f02ed82b 100644 --- a/features/referral/data/src/main/java/com/tangem/feature/referral/di/ReferralRepositoryModule.kt +++ b/features/referral/data/src/main/java/com/tangem/feature/referral/di/ReferralRepositoryModule.kt @@ -2,13 +2,10 @@ package com.tangem.feature.referral.di import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.feature.referral.converters.ReferralConverter -import com.tangem.feature.referral.data.DefaultMobileWalletPromoRepository import com.tangem.feature.referral.data.ExternalReferralRepository import com.tangem.feature.referral.data.ReferralRepositoryImpl -import com.tangem.feature.referral.domain.MobileWalletPromoRepository import com.tangem.feature.referral.domain.ReferralRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -56,11 +53,4 @@ class ReferralRepositoryModule { excludedBlockchains = excludedBlockchains, ) } - - @Provides - @Singleton - fun provideMobileWalletPromoRepository(appPreferencesStore: AppPreferencesStore): MobileWalletPromoRepository = - DefaultMobileWalletPromoRepository( - appPreferencesStore = appPreferencesStore, - ) } \ No newline at end of file diff --git a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/MobileWalletPromoRepository.kt b/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/MobileWalletPromoRepository.kt deleted file mode 100644 index 95cba17c9d..0000000000 --- a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/MobileWalletPromoRepository.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.feature.referral.domain - -interface MobileWalletPromoRepository { - - suspend fun shouldShowMobileWalletPromo(): Boolean - - suspend fun setShouldShowMobileWalletPromo(shouldShowPromo: Boolean) -} \ No newline at end of file diff --git a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/SetShouldShowMobileWalletPromoUseCase.kt b/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/SetShouldShowMobileWalletPromoUseCase.kt deleted file mode 100644 index 335669eabf..0000000000 --- a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/SetShouldShowMobileWalletPromoUseCase.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.feature.referral.domain - -import arrow.core.Either -import com.tangem.domain.common.wallets.UserWalletsListRepository -import javax.inject.Inject - -class SetShouldShowMobileWalletPromoUseCase @Inject constructor( - private val mobileWalletPromoRepository: MobileWalletPromoRepository, - private val userWalletsListRepository: UserWalletsListRepository, -) { - - suspend operator fun invoke(shouldShowPromo: Boolean): Either = Either.catch { - val wallets = userWalletsListRepository.userWallets.value - if (wallets.isNullOrEmpty()) { - mobileWalletPromoRepository.setShouldShowMobileWalletPromo(shouldShowPromo) - } - } -} \ No newline at end of file diff --git a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ShouldShowMobileWalletPromoUseCase.kt b/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ShouldShowMobileWalletPromoUseCase.kt deleted file mode 100644 index 09a3b358cc..0000000000 --- a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ShouldShowMobileWalletPromoUseCase.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.feature.referral.domain - -import javax.inject.Inject - -class ShouldShowMobileWalletPromoUseCase @Inject constructor( - private val mobileWalletPromoRepository: MobileWalletPromoRepository, -) { - - suspend operator fun invoke(): Boolean { - return mobileWalletPromoRepository.shouldShowMobileWalletPromo() - } -} \ No newline at end of file From 69369cb0ae87abc627f8437c3382bdc7a2609e76 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 3 Jul 2026 18:18:55 +0400 Subject: [PATCH 36/43] Updated on 2026-08-14 --- .../main/java/com/tangem/tap/di/data/CardSdkModule.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/di/data/CardSdkModule.kt b/app/src/main/java/com/tangem/tap/di/data/CardSdkModule.kt index 496271ef06..e131f9e8c0 100644 --- a/app/src/main/java/com/tangem/tap/di/data/CardSdkModule.kt +++ b/app/src/main/java/com/tangem/tap/di/data/CardSdkModule.kt @@ -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, ) } } From 49cd9db8dad21dae59c58c2ef3ed77c31778babd Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 3 Jul 2026 23:05:18 +0500 Subject: [PATCH 37/43] Updated on 2026-08-14 --- .../impl/addtoportfolio/model/AddTokenUiBuilder.kt | 2 +- .../impl/managefunds/DefaultManageFundsComponent.kt | 4 +++- .../impl/managefunds/model/ManageFundsRouteUiSpec.kt | 5 +++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt index 3356e8a39c..fc6d0e2afd 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt @@ -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) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt index 355331a109..8354a085f3 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt @@ -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) { { diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsRouteUiSpec.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsRouteUiSpec.kt index 46258a31b1..7fd98aecd9 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsRouteUiSpec.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsRouteUiSpec.kt @@ -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, ) From 5ba7c63987707d1b259aa40e02f31bfa23d1e108 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 11:08:18 +0400 Subject: [PATCH 38/43] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- .../moonpay/MoonpayBlockchainMapping.kt | 1 + .../common/ui/extensions/BlockchainIcons.kt | 2 ++ .../ui/extensions/BlockchainIconsTest.kt | 2 ++ core/ui/src/main/res/drawable/ic_gonka_22.xml | 21 +++++++++++++++++++ .../ui/src/main/res/drawable/img_gonka_22.xml | 13 ++++++++++++ .../data/common/network/NetworkFactory.kt | 1 + .../legacy/MercuryoBlockchainMapping.kt | 1 + .../DefaultTransactionRepository.kt | 1 + .../domain/card/configs/Wallet2CardConfig.kt | 1 + .../card/configs/Wallet2CardConfigTest.kt | 1 + gradle/tangem_dependencies.toml | 2 +- .../tangem/blockchainsdk/utils/Blockchain.kt | 3 +++ .../derivation/AccountNodeRecognizer.kt | 1 + 14 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_gonka_22.xml create mode 100644 core/ui/src/main/res/drawable/img_gonka_22.xml diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index a7b32c7668..f983c6defd 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit a7b32c766817076c6346156390c135a3dae1b6ce +Subproject commit f983c6defd0b2240eb6f134aefe30f7e93696795 diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt index a220b53c66..4e3c6b8051 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt @@ -166,4 +166,5 @@ internal val Blockchain.moonPaySupportedCurrency: MoonPaySupportedCurrency? Adi, AdiTestnet -> null SeiEvm, SeiEvmTestnet -> null Monad, MonadTestnet -> null + Gonka -> null } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt b/common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt index 00016c2d27..97b5ab5ec5 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt @@ -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) diff --git a/common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt b/common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt index 2b3ffdcdb4..b2d6770626 100644 --- a/common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt +++ b/common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt @@ -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 diff --git a/core/ui/src/main/res/drawable/ic_gonka_22.xml b/core/ui/src/main/res/drawable/ic_gonka_22.xml new file mode 100644 index 0000000000..bc3abe4626 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_gonka_22.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_gonka_22.xml b/core/ui/src/main/res/drawable/img_gonka_22.xml new file mode 100644 index 0000000000..efcda8344c --- /dev/null +++ b/core/ui/src/main/res/drawable/img_gonka_22.xml @@ -0,0 +1,13 @@ + + + + diff --git a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt index 5adabfe981..0cb498bb14 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt @@ -224,6 +224,7 @@ class NetworkFactory @Inject constructor( Blockchain.Binance, Blockchain.TON, Blockchain.Cosmos, + Blockchain.Gonka, Blockchain.TerraV1, Blockchain.TerraV2, Blockchain.Stellar, diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt index 2325a048fd..b248f1701e 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt @@ -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 } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt index ae96776efa..e1ce1e8b24 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt @@ -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, diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt index a731904778..4c57b37752 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt @@ -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 diff --git a/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt b/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt index a0f2f57615..72e61ad2b3 100644 --- a/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt +++ b/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt @@ -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, diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 395415d178..bafb894a94 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1594" +tangemBlockchainSdk = "releases-6.0-1599" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-6.0-626" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt index 8f4e925169..145196cb5e 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt @@ -62,6 +62,7 @@ fun Blockchain.Companion.fromNetworkId(networkId: String): Blockchain? { "ravencoin" -> Blockchain.Ravencoin "ravencoin/test" -> Blockchain.RavencoinTestnet "cosmos" -> Blockchain.Cosmos + "gonka" -> Blockchain.Gonka "cosmos/test" -> Blockchain.CosmosTestnet "terra" -> Blockchain.TerraV1 "terra-2" -> Blockchain.TerraV2 @@ -242,6 +243,7 @@ fun Blockchain.toNetworkId(): String { Blockchain.Ravencoin -> "ravencoin" Blockchain.RavencoinTestnet -> "ravencoin/test" Blockchain.Cosmos -> "cosmos" + Blockchain.Gonka -> "gonka" Blockchain.CosmosTestnet -> "cosmos/test" Blockchain.TerraV1 -> "terra" Blockchain.TerraV2 -> "terra-2" @@ -398,6 +400,7 @@ fun Blockchain.toCoinId(): String { Blockchain.Kava, Blockchain.KavaTestnet -> "kava" Blockchain.Ravencoin, Blockchain.RavencoinTestnet -> "ravencoin" Blockchain.Cosmos, Blockchain.CosmosTestnet -> "cosmos" + Blockchain.Gonka -> "gonka" Blockchain.TerraV1 -> "terra-luna" Blockchain.TerraV2 -> "terra-luna-2" Blockchain.Cronos -> "crypto-com-chain" diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt index 6acca83f43..cc801b47f8 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt @@ -180,6 +180,7 @@ class AccountNodeRecognizer(private val blockchain: Blockchain) { Blockchain.Adi, Blockchain.SeiEvm, Blockchain.Monad, + Blockchain.Gonka, -> true Blockchain.Nexa, // unsupported network Blockchain.Chia, From d950f3ed0ceacc751a575f28292c00c034485ae8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 09:11:39 +0200 Subject: [PATCH 39/43] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 1 + .../DefaultMarketsTokenDetailsComponent.kt | 2 +- .../model/PortfolioBlockModel.kt | 6 +- .../portfolioblock/ui/PortfolioBlock.kt | 55 ++++++++++++++++--- .../ui/state/PortfolioBlockUM.kt | 13 +++-- 5 files changed, 59 insertions(+), 18 deletions(-) diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 0f66cd5146..b72b7efbc3 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -900,6 +900,7 @@ **Add to your portfolio** to start buying, exchanging or receiving this asset In your portfolio Your portfolio + **Token not supported**. This token is currently not supported in the wallet Market Pulse Quick actions Clear all diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt index efbc99093b..a185cc8ed4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt @@ -76,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), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt index 6b5bb8bdef..d7e0019cea 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt @@ -47,7 +47,7 @@ internal class PortfolioBlockModel @Inject constructor( ) : Model() { val state: StateFlow - field = MutableStateFlow(PortfolioBlockUM.Loading) + field = MutableStateFlow(PortfolioBlockUM.Hidden) val cryptoCurrencyIdState: StateFlow field = MutableStateFlow(null) @@ -86,7 +86,7 @@ internal class PortfolioBlockModel @Inject constructor( private fun combineData(): Flow { 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) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt index 14cded09d0..22a447786c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt @@ -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) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt index 136a952189..620fe2084f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt @@ -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 } \ No newline at end of file From cdf7d1b3710a5ea6ea838a8cf028ddc973382bb9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 10:19:41 +0300 Subject: [PATCH 40/43] Updated on 2026-08-14 --- .../core/ui/ds2/button/TangemButtonExt.kt | 25 +++++++++++-- .../tangem/core/ui/ds2/search/TangemSearch.kt | 6 +++- .../core/ui/ds2/surface/TangemSurface.kt | 29 +++++++++------ .../ds2/topnavigation/TangemNavigationText.kt | 4 +-- .../ds2/topnavigation/TangemTopNavigation.kt | 24 ++++++------- .../topnavigation/TangemTopNavigationStory.kt | 35 ++++++++----------- 6 files changed, 75 insertions(+), 48 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/button/TangemButtonExt.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/button/TangemButtonExt.kt index a023d2cf26..028b66b851 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/button/TangemButtonExt.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/button/TangemButtonExt.kt @@ -3,9 +3,10 @@ package com.tangem.core.ui.ds2.button import androidx.compose.runtime.Composable import androidx.compose.runtime.NonRestartableComposable import androidx.compose.ui.Modifier +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 @Composable @@ -14,7 +15,8 @@ fun TangemButton.Back(modifier: Modifier = Modifier, onClick: () -> Unit) { TangemButton( modifier = modifier, 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, ) } @@ -25,7 +27,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) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/search/TangemSearch.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/search/TangemSearch.kt index bd6ed68653..9009854d55 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/search/TangemSearch.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/search/TangemSearch.kt @@ -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) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt index 2b4f731dac..c60d3eb95b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt @@ -98,7 +98,7 @@ fun TangemSurface( } if (onClick != null) { - CompositionLocalProvider(LocalRippleConfiguration provides tangemSurfaceRipple()) { + CompositionLocalProvider(LocalRippleConfiguration provides tangemSurfaceRipple(color)) { surface() } } else { @@ -115,14 +115,17 @@ fun TangemSurface( * `isAlphaContentClip`) to avoid the dark blur bleeding through the surface. */ @Composable -private fun Modifier.materialShadow(shape: Shape): Modifier = softLayerShadow( - radius = 40.dp, - color = Color.Black.copy(alpha = 0.12f), - shape = shape, - spread = 0.dp, - offset = DpOffset(x = 0.dp, y = 8.dp), - isAlphaContentClip = true, -) +private fun Modifier.materialShadow(shape: Shape): Modifier { + val isBlurEnabled = LocalHazeState.current.blurEnabled + return softLayerShadow( + radius = 40.dp, + color = Color.Black.copy(alpha = 0.12f), + shape = shape, + spread = 0.dp, + offset = DpOffset(x = 0.dp, y = 8.dp), + isAlphaContentClip = isBlurEnabled, + ) +} /** Diagonal gradient stroke that wraps the material variant. */ @Composable @@ -191,8 +194,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, diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/topnavigation/TangemNavigationText.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/topnavigation/TangemNavigationText.kt index 2f9a0fc5d2..3c8c4394a2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/topnavigation/TangemNavigationText.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/topnavigation/TangemNavigationText.kt @@ -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, ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/topnavigation/TangemTopNavigation.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/topnavigation/TangemTopNavigation.kt index 97b85281f2..8be7187dcc 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/topnavigation/TangemTopNavigation.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/topnavigation/TangemTopNavigation.kt @@ -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)) } diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/topnavigation/TangemTopNavigationStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/topnavigation/TangemTopNavigationStory.kt index be6e3d55f2..97dc84c5d5 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/topnavigation/TangemTopNavigationStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/topnavigation/TangemTopNavigationStory.kt @@ -49,12 +49,13 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.tangem.core.ui.components.haze.hazeSourceTangem -import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds2.button.Back import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.GroupEntry import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.topnavigation.TangemNavigationText import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation @@ -79,7 +80,7 @@ private const val PREVIEW_HEIGHT_DP = 220 // Match the production TangemTopNavigation's spring stiffness so the story animates identically. private val SlotAlphaSpec = spring(stiffness = Spring.StiffnessMediumLow) -private val SlotSizeSpec = spring(stiffness = Spring.StiffnessMediumLow) +private val SlotSizeSpec = spring(stiffness = Spring.StiffnessMediumLow) private val TitleEnterTransition = fadeIn(animationSpec = SlotAlphaSpec) private val TitleExitTransition = fadeOut(animationSpec = SlotAlphaSpec) private val SubtitleEnterTransition = @@ -257,42 +258,36 @@ private fun endGroupContent(endGroup: EndGroup): (@Composable RowScope.() -> Uni EndGroup.None -> null EndGroup.One -> { { - TangemButton( - variant = TangemButton.Variant.Ghost, - iconStart = TangemIconUM.Icon(Icons.ic_arrow_swap_horizontal_20), + TangemButton.GroupEntry( + imageVector = Icons.ic_arrow_swap_horizontal_20, onClick = {}, ) } } EndGroup.Two -> { { - TangemButton( - variant = TangemButton.Variant.Ghost, - iconStart = TangemIconUM.Icon(Icons.ic_arrow_swap_horizontal_20), + TangemButton.GroupEntry( + imageVector = Icons.ic_arrow_swap_horizontal_20, onClick = {}, ) - TangemButton( - variant = TangemButton.Variant.Ghost, - iconStart = TangemIconUM.Icon(Icons.ic_scan_20), + TangemButton.GroupEntry( + imageVector = Icons.ic_scan_20, onClick = {}, ) } } EndGroup.Three -> { { - TangemButton( - variant = TangemButton.Variant.Ghost, - iconStart = TangemIconUM.Icon(Icons.ic_arrow_swap_horizontal_20), + TangemButton.GroupEntry( + imageVector = Icons.ic_arrow_swap_horizontal_20, onClick = {}, ) - TangemButton( - variant = TangemButton.Variant.Ghost, - iconStart = TangemIconUM.Icon(Icons.ic_sign_usd_20), + TangemButton.GroupEntry( + imageVector = Icons.ic_sign_usd_20, onClick = {}, ) - TangemButton( - variant = TangemButton.Variant.Ghost, - iconStart = TangemIconUM.Icon(Icons.ic_scan_20), + TangemButton.GroupEntry( + imageVector = Icons.ic_scan_20, onClick = {}, ) } From 0ad6ccea35a09672033411261c833d8e296eaf6f Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 3 Jul 2026 15:26:33 +0300 Subject: [PATCH 41/43] Updated on 2026-08-14 --- .../ui/components/TokenDetailsBalanceBlock.kt | 75 +++++++++++++++++-- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt index 56957faa3b..a74d169214 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -14,13 +15,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.currency.icon.CurrencyIcon @@ -46,7 +51,13 @@ import kotlinx.collections.immutable.persistentListOf private val CurrencyIconSize: Dp = 70.dp private val NetworkBadgeSize: Dp = 24.dp -/** OpenType "tabular figures" feature — makes every digit the same width to prevent horizontal jitter. */ +/** Lower bound for the fitted balance font size. */ +private val MinBalanceFontSize: TextUnit = 15.sp + +/** Step-down multiplier when fitting the balance font size. */ +private const val FONT_SIZE_FIT_STEP = 0.95f + +/** OpenType "tabular figures" — equal-width digits, no horizontal jitter while ticking. */ private const val TABULAR_FIGURES_FEATURE = "tnum" @Composable @@ -150,8 +161,8 @@ private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content, isBalanceHidd } /** - * Renders a balance that animates digit-by-digit ([TextAnimatedCounter]) while a ticking yield supply - * value is present, and falls back to a plain [Text] otherwise (or when the balance is hidden). + * Digit-by-digit animated balance ([TextAnimatedCounter]) while a ticking yield value is present, + * plain [Text] otherwise (or when the balance is hidden). */ @Composable private fun AnimatedBalance( @@ -162,7 +173,7 @@ private fun AnimatedBalance( isBalanceHidden: Boolean, modifier: Modifier = Modifier, ) { - Box( + BoxWithConstraints( modifier = modifier .fillMaxWidth() .padding(horizontal = TangemTheme.dimens2.x6), @@ -171,20 +182,70 @@ private fun AnimatedBalance( if (yieldBalance != null && !isBalanceHidden) { TextAnimatedCounter( text = yieldBalance, - // Tabular figures keep every digit the same width, so the centered balance doesn't - // jitter horizontally as digits roll during the increment animation. - style = style.copy(color = color, fontFeatureSettings = TABULAR_FIGURES_FEATURE), + style = rememberFittedBalanceStyle( + text = yieldBalance, + style = style.copy(color = color, fontFeatureSettings = TABULAR_FIGURES_FEATURE), + maxWidth = constraints.maxWidth, + ), ) } else { Text( text = fallbackBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), style = style, color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = MinBalanceFontSize, + maxFontSize = style.fontSize, + ), ) } } } +/** + * Returns [style] with the font size shrunk just enough for [text] to fit [maxWidth] px on one line + * (never below [MinBalanceFontSize]). + * + * [TextAutoSize] doesn't work here: [TextAnimatedCounter] renders each character as a separate + * [Text], so the size is fitted for the whole string upfront and shared by every character. + */ +@Composable +private fun rememberFittedBalanceStyle(text: String, style: TextStyle, maxWidth: Int): TextStyle { + val textMeasurer = rememberTextMeasurer() + // Tabular figures make every digit equally wide, so measuring a digit-normalized string gives + // the same result while keeping the remember key stable across ticks of the same shape. + val normalizedText = remember(text) { + buildString(text.length) { text.forEach { append(if (it.isDigit()) '0' else it) } } + } + return remember(textMeasurer, normalizedText, style, maxWidth) { + // TextAnimatedCounter renders each char as its own Text, so the row width is the sum of + // per-char widths (ceil-rounded, no kerning) — measure the same way or the row overflows. + fun widthAt(fontSize: TextUnit): Int { + val sizedStyle = style.copy(fontSize = fontSize) + val charWidths = HashMap() + return normalizedText.sumOf { char -> + charWidths.getOrPut(char) { + textMeasurer.measure(text = char.toString(), style = sizedStyle, softWrap = false).size.width + } + } + } + + val baseWidth = widthAt(style.fontSize) + if (baseWidth <= maxWidth) { + style + } else { + // Width grows ~linearly with font size: start from the proportional guess, step down until it fits. + var fontSize = style.fontSize * (maxWidth.toFloat() / baseWidth) + while (fontSize.value > MinBalanceFontSize.value && widthAt(fontSize) > maxWidth) { + fontSize *= FONT_SIZE_FIT_STEP + } + style.copy(fontSize = if (fontSize.value < MinBalanceFontSize.value) MinBalanceFontSize else fontSize) + } + } +} + @Composable private fun LoadingBody() { Text( From 882a605a7ce8e5c9f604af6ae5b319063a158386 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 09:59:38 +0200 Subject: [PATCH 42/43] Updated on 2026-08-14 --- .../converter/WalletTokenAccountItemConverter.kt | 14 ++------------ .../converter/WalletTokenCurrencyItemConverter.kt | 11 ++--------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenAccountItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenAccountItemConverter.kt index 75a56e8010..cb492ff00d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenAccountItemConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenAccountItemConverter.kt @@ -8,10 +8,7 @@ import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.row.token.TangemTokenRowUM -import com.tangem.core.ui.extensions.pluralReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.styledResourceReference -import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.formatStyled @@ -108,14 +105,7 @@ internal class WalletTokenAccountItemConverter( val priceChangeType = PriceChangeType.fromBigDecimal(priceChange.value) TangemTokenRowUM.EndContentUM.Content( - text = stringReference( - priceChange.value.format { - fiat( - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ) - }, - ), + text = TextReference.EMPTY, priceChangeUM = PriceChangeState.Content( type = priceChangeType, valueInPercent = priceChange.value.format { percent() }, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt index 7f37f1b1e8..2e70a6332c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt @@ -9,10 +9,7 @@ import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.ds.badge.* import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.row.token.TangemTokenRowUM -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.styledResourceReference -import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.* import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency @@ -136,11 +133,7 @@ internal class WalletTokenCurrencyItemConverter( is CryptoCurrencyStatus.NoQuote, is CryptoCurrencyStatus.NoAccount, -> TangemTokenRowUM.SubtitleUM.Content( - text = stringReference( - currencyStatus.value.fiatRate.format { - fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) - }, - ), + text = TextReference.EMPTY, priceChangeUM = PriceChangeState.Content( type = PriceChangeType.fromBigDecimal(currencyStatus.value.priceChange.orZero()), valueInPercent = currencyStatus.value.priceChange.format { percent() }, From 7965720fc8c17f90ed12cad2195b3fd12c2bc6fe Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 08:00:06 +0000 Subject: [PATCH 43/43] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index bafb894a94..6e0f27042e 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,16 +5,16 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1599" +tangemBlockchainSdk = "develop-1592" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-6.0-626" +tangemCardSdk = "develop-630" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ tangemHotSdk = "develop-550" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ - - +tangemUsedeskSdk = "main-10" +#tangemUsedeskSdk = "0.0.1" # Keep it! - used for local builds ^ [libraries] blockchain = { module = "com.tangem:blockchain", version.ref = "tangemBlockchainSdk" } @@ -23,6 +23,9 @@ card-core = { module = "com.tangem.tangem-sdk-kotlin:core", version.ref = "tange hot-core = { module = "com.tangem.tangem-hot-sdk-kotlin:core", version.ref = "tangemHotSdk" } hot-android = { module = "com.tangem.tangem-hot-sdk-kotlin:android", version.ref = "tangemHotSdk" } +usedesk-chat-sdk = { module = "com.tangem.usedesk:chat-sdk", version.ref = "tangemUsedeskSdk" } +usedesk-chat-gui = { module = "com.tangem.usedesk:chat-gui", version.ref = "tangemUsedeskSdk" } + vico-compose = { group = "com.tangem.vico", name = "compose", version.ref = "tangemVico" } vico-compose-m3 = { group = "com.tangem.vico", name = "compose-m3", version.ref = "tangemVico" } vico-core = { group = "com.tangem.vico", name = "core", version.ref = "tangemVico" }