diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7782a2cf4a..f4357f362b 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/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt new file mode 100644 index 0000000000..dc3ef704c7 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt @@ -0,0 +1,98 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.isDisplayedSafely +import com.tangem.screens.* +import com.tangem.tap.domain.sdk.mocks.MockContent +import com.tangem.tap.domain.sdk.mocks.MockProvider +import io.qameta.allure.kotlin.Allure.step + +/** 'Add Wallet' scans a card immediately (no type chooser), so [mockContent] must be set before the click. */ +fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { + step("Click 'More' button on TopBar") { + onMainScreenTopBar { moreButton.clickWithAssertion() } + } + MockProvider.setMocks(mockContent) + step("Click on 'Add Wallet' button (scans a new hardware wallet)") { + onDetailsScreen { addWalletButton.clickWithAssertion() } + } + // Gate on the top-bar More button, not the container — the bottom Markets sheet can leave the container un-"displayed". + step("Assert 'Main' screen is displayed with the new wallet") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) { + runCatching { onMainScreenTopBar { moreButton.assertIsDisplayed() } }.isSuccess + } + } + // The added card is the newest pager page; its "Synchronize addresses" prompt is off-screen until swiped to. + step("Synchronize the new card wallet's addresses") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + var shown = false + onMainScreen { shown = synchronizeAddressesButton.isDisplayedSafely() } + if (!shown) onMainScreen { swipeToAdjacentWallet(toPrevious = false) } + shown + } + // Let the pager fling settle — a click mid-animation is eaten by the button's clickableSingle debounce. + waitForIdle() + onMainScreen { synchronizeAddressesButton.clickWithAssertion() } + // The prompt clears once the card's addresses are derived (re-scan + reload over many, some failing, RPCs). + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) { + var generated = false + onMainScreen { generated = !synchronizeAddressesButton.isDisplayedSafely() } + generated + } + } +} + +fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) { + step("Click on token '$tokenName' on the visible wallet") { + onMainScreen { clickDisplayedToken(tokenName) } + } +} + +fun BaseTestCase.switchToPreviousWallet() { + step("Swipe wallet card to the previous wallet") { + onMainScreen { swipeToAdjacentWallet(toPrevious = true) } + } +} + +/** Picks a [token] the recipient [walletName] already holds, via the wallet tab. */ +fun BaseTestCase.selectReceiveTokenOnWallet(token: String, walletName: String) { + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + step("Select wallet tab '$walletName'") { + onBuyTokenScreen { walletTab(walletName).performClick() } + } + step("Click on token with name '$token'") { + onBuyTokenScreen { tokenWithTitle(token).performClick() } + } +} + +/** Adds [token] to [recipientWalletName] which lacks it, via market search. */ +fun BaseTestCase.addMissingReceiveTokenToWallet(token: String, recipientWalletName: String) { + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + step("Type '$token' in search field") { + onSwapSelectTokenScreen { + searchBarBlock.performClick() + searchBarBlock.performTextInput(token) + } + } + step("Click on market token '$token'") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapSelectTokenScreen { marketsTokenWithName(token).performClick() } }.isSuccess + } + } + // The 'Add token' sheet pre-selects the recipient (the only wallet missing the token, since the source already holds it). + step("Assert recipient wallet '$recipientWalletName' is selected") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onAddToPortfolioScreen { walletName(recipientWalletName).assertIsDisplayed() } }.isSuccess + } + } + step("Click on 'Add' button") { + onAddToPortfolioScreen { addButton.performClick() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index ad101a5545..4dc9ab8da6 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). */ +// 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() } 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/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 8e39f04196..ce8ca04066 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt @@ -32,6 +32,10 @@ class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : useUnmergedTree = true } + val addWalletButton: KNode = child { + hasTestTag(DetailsScreenTestTags.ADD_WALLET_BUTTON) + } + val buyTangemButton: KNode = child { hasTestTag(DetailsScreenTestTags.SCREEN_ITEM) hasText(getResourceString(R.string.details_buy_wallet)) 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 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..1904bd4c09 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,333 @@ class AppTransfersTest : BaseTestCase() { } } + @AllureId("10005") + @DisplayName("App transfers: Tron network fee") + @Test + fun tronNetworkFeeTest() { + val token = "Tron" + val amount = "0.001" + val userTokensState = "TwoAccountsSameTron" + val networksProvidersScenario = "networks_providers" + val appTransfersNetworksState = "AppTransfersNetworks" + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(storiesScenario, storiesErrorState) + // networks_providers configures SDK RPC hosts at launch — must be set before the activity starts. + setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState) + }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(networksProvidersScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + // Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + // blockchain SDK TonProvidersBuilder drops public providers, so TON has no provider in the mocked build. + @Ignore("[REDACTED_JIRA]") + @AllureId("10012") + @DisplayName("App transfers: TON network fee") + @Test + fun tonNetworkFeeTest() { + // The SDK names TON's coin "Gram" (Blockchain.TON.getCoinName), so the portfolio row shows "Gram", not "Toncoin". + val token = "Gram" + val amount = "0.001" + val userTokensState = "TwoAccountsSameTON" + val networksProvidersScenario = "networks_providers" + val appTransfersNetworksState = "AppTransfersNetworks" + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(storiesScenario, storiesErrorState) + // networks_providers configures SDK RPC hosts at launch — must be set before the activity starts. + setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState) + }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(networksProvidersScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + // Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("10013") + @DisplayName("App transfers: Cosmos network fee") + @Test + fun cosmosNetworkFeeTest() { + val token = "Cosmos" + val amount = "0.001" + val userTokensState = "TwoAccountsSameCosmos" + val networksProvidersScenario = "networks_providers" + val appTransfersNetworksState = "AppTransfersNetworks" + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(storiesScenario, storiesErrorState) + // networks_providers configures SDK RPC hosts at launch — must be set before the activity starts. + setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState) + }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(networksProvidersScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + // Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("10015") + @DisplayName("App transfers: Aptos network fee") + @Test + fun aptosNetworkFeeTest() { + val token = "Aptos" + val amount = "0.001" + val userTokensState = "TwoAccountsSameAptos" + val networksProvidersScenario = "networks_providers" + val appTransfersNetworksState = "AppTransfersNetworks" + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(storiesScenario, storiesErrorState) + // networks_providers configures SDK RPC hosts at launch — must be set before the activity starts. + setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState) + }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(networksProvidersScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + // Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("9856") + @DisplayName("App transfers: Transfer mode is available from a Tangem Pay account") + @Test + fun transferModeAvailableFromTangemPayAccountTest() { + val token = "USDC" + val receiveAccountName = "Main account" + val eligibilityState = "PaeraCustomer" + val balanceScenario = "tangem_pay_balance_update" + val balanceInitialState = "InitialBalance" + val historyScenario = "tangem_pay_transaction_history" + val historyInitialState = "InitialEmpty" + val userTokensState = "TangemPayTransferUsdc" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO) + resetWireMockScenarioState(balanceScenario) + resetWireMockScenarioState(historyScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$TANGEM_PAY_ELIGIBILITY_SCENARIO' to state: '$eligibilityState'") { + setWireMockScenarioState(scenarioName = TANGEM_PAY_ELIGIBILITY_SCENARIO, state = eligibilityState) + } + step("Set WireMock scenario: '$balanceScenario' to state: '$balanceInitialState'") { + setWireMockScenarioState(scenarioName = balanceScenario, state = balanceInitialState) + } + step("Set WireMock scenario: '$historyScenario' to state: '$historyInitialState'") { + setWireMockScenarioState(scenarioName = historyScenario, state = historyInitialState) + } + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + + step("Open Tangem Pay") { openTangemPay() } + step("Click on 'Withdraw' button") { + onTangemPayMainScreen { withdrawButton.clickWithAssertion() } + } + step("Acknowledge withdrawal note sheet") { + onTangemPayWithdrawNoteSheet { + title.assertIsDisplayed() + gotItButton.clickWithAssertion() + } + } + step("Choose identical receive token '$token' from '$receiveAccountName'") { + chooseIdenticalReceiveToken(tokenName = token, receiveAccountName = receiveAccountName) + } + // Withdraw-entry swap keeps recalculating — use flakySafely rather than assertTransferReady's waitUntil. + step("Assert Transfer mode is ready") { + flakySafely(WAIT_UNTIL_TIMEOUT_VERY_LONG) { + onSwapTokenScreen { transferTitle.assertIsDisplayed() } + } + onSwapTokenScreen { providersBlock.assertIsNotDisplayed() } + } + } + } + + @AllureId("9995") + @DisplayName("App transfers: transfer between different wallets reaches 'Transfer in progress' screen") + @Test + fun transferBetweenDifferentWalletsReachesFinishTest() { + val token = "Ethereum" + val amount = "0.001" + val secondWalletName = "Wallet 2" + val userTokensState = "EthereumWithSecondToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12) + } + step("Generate missing addresses") { generateMissingHotWalletAddresses() } + step("Wait for addresses to be generated") { waitForAddressesGenerated() } + step("Add a second card wallet '$secondWalletName'") { + addNewCardWallet(WalletMockContent) + } + step("Switch back to the hot wallet") { switchToPreviousWallet() } + step("Click on token with name: '$token'") { clickDisplayedTokenOnMain(token) } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) + } + step("Select identical receive token '$token' on '$secondWalletName'") { + selectReceiveTokenOnWallet(token = token, walletName = secondWalletName) + } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Hold to confirm the transfer") { holdToConfirmTransfer() } + step("Assert 'Transfer in progress' screen is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapSuccessScreen { transferInProgressTitle.assertIsDisplayed() } + } + } + } + } + + @AllureId("9996") + @DisplayName("App transfers: adding a missing token to the recipient wallet enables Transfer") + @Test + fun addMissingTokenToRecipientWalletEnablesTransferTest() { + val token = "Ethereum" + val bitcoinToken = "Bitcoin" + val recipientWalletName = "Wallet" + val recipientWithoutEthereumState = "RecipientWithoutEthereum" + val ethereumWithSecondTokenState = "EthereumWithSecondToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$recipientWithoutEthereumState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = recipientWithoutEthereumState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12) + } + step("Generate missing addresses") { generateMissingHotWalletAddresses() } + step("Wait for addresses to be generated") { waitForAddressesGenerated() } + step("Assert token '$bitcoinToken' is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onMainScreen { tokenWithTitleAndAddress(bitcoinToken).assertIsDisplayed() } + } + } + // Switch the user-tokens mock so the second wallet loads with Ethereum while the recipient stays Ethereum-less. + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$ethereumWithSecondTokenState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = ethereumWithSecondTokenState) + } + step("Add a second card wallet") { + addNewCardWallet(WalletMockContent) + } + step("Click on token with name: '$token'") { clickDisplayedTokenOnMain(token) } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) + } + step("Add missing token '$token' to recipient wallet '$recipientWalletName'") { + addMissingReceiveTokenToWallet(token = token, recipientWalletName = recipientWalletName) + } + step("Assert Transfer mode is ready") { assertTransferReady() } + } + } + // [REDACTED_TASK_KEY]: transfer mode never runs tx validation, so the destination rent-exemption notification never shows. @Ignore("[REDACTED_JIRA]") @AllureId("9852") 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/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/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, ) } } 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/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 4f02866bda..baf3cd3453 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,14 +621,13 @@ internal class DefaultUserWalletsListRepository( } private suspend fun onFirstWalletCreated() { - // reset flag (that is set from AF deeplink) after creating a new wallet - mobileWalletPromoRepository.setShouldShowMobileWalletPromo(false) + // reset the referral attribution (set from AF deeplink) after creating a new wallet + clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.Referral) } private suspend fun onAllWalletsDeleted() { - // reset flag (that is set from AF deeplink) after removing the last wallet - mobileWalletPromoRepository.setShouldShowMobileWalletPromo(false) - // wipe the Usedesk support-chat clientId so a fresh UUID is generated for the next wallet + // reset the referral attribution (set from AF deeplink) after removing the last wallet + clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.Referral) appPreferencesStore.editData { it.remove(PreferencesKeys.USEDESK_CLIENT_ID_KEY) } } } \ No newline at end of file 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/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 7fd765899e..637f3befc1 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 { @@ -206,23 +206,8 @@ internal class DefaultRoutingComponent @AssistedInject constructor( } private suspend fun navigateForEmptyWallets(): AppRoute { - val isHotWalletOnboardingEnabled = featureTogglesManager.isFeatureEnabled( - FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING, - ) - TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isHotWalletOnboardingEnabled") - val afterEmptyRoute: AppRoute = if (isHotWalletOnboardingEnabled) { - val tangemPayHotWalletOnboardingDeepLink = withTimeoutOrNull(2.seconds) { - appsFlyerReferralParamsHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) - } - TangemLogger.i("[TangemPay][HWO] Deep link present=${tangemPayHotWalletOnboardingDeepLink != null}") - if (tangemPayHotWalletOnboardingDeepLink != null) { - AppRoute.TangemPayHotWalletOnboarding - } else { - getDefaultRoute() - } - } else { - getDefaultRoute() - } + val afterEmptyRoute = resolveAppsFlyerOnboardingRoute() + ?: AppRoute.Home(launchMode = launchMode) val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull() ?: return afterEmptyRoute @@ -242,16 +227,41 @@ internal class DefaultRoutingComponent @AssistedInject constructor( } } - private suspend fun getDefaultRoute(): AppRoute { - val isHideStoriesForReferralEnabled = featureTogglesManager.isFeatureEnabled( + private suspend fun resolveAppsFlyerOnboardingRoute(): AppRoute? = coroutineScope { + val tangemPayRoute = async { resolveTangemPayHotWalletOnboardingRoute() } + val referralRoute = async { resolveReferralRoute() } + tangemPayRoute.await() ?: referralRoute.await() + } + + private suspend fun resolveTangemPayHotWalletOnboardingRoute(): AppRoute? { + val isEnabled = featureTogglesManager.isFeatureEnabled( + FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING, + ) + TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isEnabled") + if (!isEnabled) return null + + val deepLink = awaitAppsFlyerDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) + TangemLogger.i("[TangemPay][HWO] Deep link present=${deepLink != null}") + return if (deepLink != null) AppRoute.TangemPayHotWalletOnboarding else null + } + + private suspend fun resolveReferralRoute(): AppRoute? { + val isEnabled = featureTogglesManager.isFeatureEnabled( FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED, ) - // Referral users skip the Home stories screen and land directly on the - // mobile wallet creation flow. - return if (isHideStoriesForReferralEnabled && shouldShowMobileWalletPromoUseCase()) { + if (!isEnabled) return null + + val referralDeepLink = awaitAppsFlyerDeeplink(AppsFlyerDeeplinkSource.Referral) + return if (referralDeepLink != null) { AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet) } else { - AppRoute.Home(launchMode = launchMode) + null + } + } + + private suspend fun awaitAppsFlyerDeeplink(source: AppsFlyerDeeplinkSource): String? { + return withTimeoutOrNull(2.seconds) { + appsFlyerReferralParamsHandler.waitForDeeplink(source) } } 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/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/main/java/com/tangem/common/ui/notifications/NotificationUM.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt index 7272171df2..7c113343ad 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/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/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/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/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/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/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/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 { 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 640d24d748..f31188f802 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 @@ -4,9 +4,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.NonRestartableComposable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.graphics.vector.ImageVector import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.res.generated.icons.Icons -import com.tangem.core.ui.res.generated.icons.ic_arrow_left_20 +import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20 import com.tangem.core.ui.res.generated.icons.ic_cross_20 import com.tangem.core.ui.test.TopNavigationTestTags @@ -16,7 +17,8 @@ fun TangemButton.Back(modifier: Modifier = Modifier, onClick: () -> Unit) { TangemButton( modifier = modifier.testTag(TopNavigationTestTags.BACK_BUTTON), variant = TangemButton.Variant.Material, - iconStart = TangemIconUM.Icon(Icons.ic_arrow_left_20), + size = TangemButton.Size.X11, + iconStart = TangemIconUM.Icon(Icons.ic_chevron_left_20), onClick = onClick, ) } @@ -27,7 +29,26 @@ fun TangemButton.Close(modifier: Modifier = Modifier, onClick: () -> Unit) { TangemButton( modifier = modifier, variant = TangemButton.Variant.Material, + size = TangemButton.Size.X11, iconStart = TangemIconUM.Icon(Icons.ic_cross_20), onClick = onClick, ) +} + +@Composable +@NonRestartableComposable +fun TangemButton.GroupEntry(iconUM: TangemIconUM, modifier: Modifier = Modifier, onClick: () -> Unit) { + TangemButton( + modifier = modifier, + variant = TangemButton.Variant.Ghost, + size = TangemButton.Size.X9, + iconStart = iconUM, + onClick = onClick, + ) +} + +@Composable +@NonRestartableComposable +fun TangemButton.GroupEntry(imageVector: ImageVector, modifier: Modifier = Modifier, onClick: () -> Unit) { + GroupEntry(iconUM = TangemIconUM.Icon(imageVector), modifier = modifier, onClick = onClick) } \ No newline at end of file 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/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/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/ds2/surface/TangemSurface.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt index 87249b47d9..9cab348f29 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 @@ -100,7 +100,7 @@ fun TangemSurface( } if (onClick != null) { - CompositionLocalProvider(LocalRippleConfiguration provides tangemSurfaceRipple()) { + CompositionLocalProvider(LocalRippleConfiguration provides tangemSurfaceRipple(color)) { surface() } } else { @@ -117,14 +117,17 @@ fun TangemSurface( * `isAlphaContentClip`) to avoid the dark blur bleeding through the surface. */ @Composable -private fun Modifier.materialShadow(shape: Shape, radius: Dp): Modifier = softLayerShadow( - radius = radius, - 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, radius: Dp): Modifier { + val isBlurEnabled = LocalHazeState.current.blurEnabled + return softLayerShadow( + radius = radius, + color = Color.Black.copy(alpha = 0.12f), + shape = shape, + spread = 0.dp, + offset = DpOffset(x = 0.dp, y = 8.dp), + isAlphaContentClip = isBlurEnabled, + ) +} /** Diagonal gradient stroke that wraps the material variant. */ @Composable @@ -193,8 +196,12 @@ private fun materialBorderBrush(): Brush { @Composable @ReadOnlyComposable -private fun tangemSurfaceRipple(): RippleConfiguration = RippleConfiguration( - color = TangemTheme.colors3.interaction.press.default, +private fun tangemSurfaceRipple(backgroundColor: Color): RippleConfiguration = RippleConfiguration( + color = if (backgroundColor == TangemTheme.colors3.bg.inverse) { + TangemTheme.colors3.interaction.press.inverse + } else { + TangemTheme.colors3.interaction.press.default + }, rippleAlpha = RippleAlpha( draggedAlpha = 0f, focusedAlpha = 0f, 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/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/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/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_limit_new_20.xml b/core/ui/src/main/res/drawable/ic_limit_new_20.xml index 91a177c4d1..25e4cdb42c 100644 --- a/core/ui/src/main/res/drawable/ic_limit_new_20.xml +++ b/core/ui/src/main/res/drawable/ic_limit_new_20.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 @@ - + + + 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/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/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/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt index cfc2969751..b2a11483de 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt @@ -387,6 +387,7 @@ class NetworkFactoryTest { Blockchain.CosmosTestnet, Blockchain.Dogecoin, Blockchain.Ducatus, + Blockchain.Gonka, Blockchain.Ethereum, Blockchain.EthereumTestnet, Blockchain.EthereumClassic, Blockchain.EthereumClassicTestnet, Blockchain.Fantom, Blockchain.FantomTestnet, 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/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 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/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 -> 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/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/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/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( 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 { 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/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/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/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/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListShimmer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListShimmer.kt index 3b4ab5d208..bae537f7ba 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListShimmer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListShimmer.kt @@ -10,11 +10,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.R +import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.shimmers.ProvideTangemShimmer -import com.tangem.core.ui.ds2.shimmers.RectangleShimmer import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign 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/choosetoken/ui/ChooseTokenScreen.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt index e455ef9b71..a9af5504aa 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 @@ -255,6 +255,7 @@ private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) { .clip(RoundedCornerShape(percent = 50)) .background(backgroundColor) .clickable(onClick = state.onClick) + .testTag(BuyTokenScreenTestTags.WALLET_TAB) .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, 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, ) 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, ) { 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 bd3a3e0d26..a1866b4db4 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 @@ -218,7 +218,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/details/DefaultMarketsTokenDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt index 32e69dc413..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 @@ -1,19 +1,11 @@ package com.tangem.features.feed.components.market.details import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.ComponentContext @@ -27,32 +19,24 @@ import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.DesignFeatureToggles -import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState -import com.tangem.core.ui.components.haze.hazeEffectTangem import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent -import com.tangem.core.ui.ds.topbar.TangemTopBar -import com.tangem.core.ui.ds.topbar.TangemTopBarType -import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.res.LocalMainBottomSheetColor -import com.tangem.core.ui.res.LocalRedesignEnabled -import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockParentClickIntents import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.feed.model.market.details.state.TokenNetworksState -import com.tangem.features.feed.ui.LocalIsOpenedInBottomSheet import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent -import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar +import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTitle import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.serialization.Serializable @@ -92,7 +76,7 @@ internal class DefaultMarketsTokenDetailsComponent( } private val portfolioBlockComponent: PortfolioBlockComponent? = - if (updatedParams.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled) { + if (designFeatureToggles.isRedesignEnabled) { portfolioBlockComponentFactory.create( context = child("portfolio_block"), params = PortfolioBlockComponent.Params(token = updatedParams.token), @@ -187,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/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 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/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/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/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, 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) 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(), ) { 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/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouPortfolioTokenList.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouPortfolioTokenList.kt index 9678d40b4a..428abb6f90 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouPortfolioTokenList.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouPortfolioTokenList.kt @@ -92,7 +92,6 @@ private fun PortfolioTokenItem(listItem: ForYouTokenListItemUM, index: Int, oute TangemTokenRow( tokenRowUM = item, isBalanceHidden = false, // TODO For You - reorderableState = null, modifier = itemModifier .onGloballyPositioned { position = it.positionInWindow() @@ -171,7 +170,6 @@ private fun PortfolioAssetItem(listItem: ForYouTokenListItemUM, index: Int, oute headComponent = composables.icon, titleComponent = composables.title, isBalanceHidden = false, // todo For You - reorderableState = null, ) } } diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index 0197c7de7e..0ec611dc57 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(projects.common.routing) /** Domain */ + api(projects.domain.appsflyer) api(projects.domain.card) api(projects.domain.common) api(projects.domain.settings) 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 faf9bfc2be..5802a61fe3 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 @@ -15,6 +15,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender 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.repository.CardSdkConfigRepository @@ -27,7 +28,6 @@ import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.wallets.builder.ColdUserWalletBuilder 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.api.HomeFeatureToggles import com.tangem.features.home.impl.ui.state.HomeStoriesConfig @@ -60,7 +60,7 @@ internal class HomeModel @Inject constructor( private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, private val saveWalletUseCase: SaveWalletUseCase, private val userWalletsListRepository: UserWalletsListRepository, - private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase, + private val isReferralInstallUseCase: IsReferralInstallUseCase, private val homeFeatureToggles: HomeFeatureToggles, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { @@ -120,12 +120,14 @@ internal class HomeModel @Inject constructor( private fun onGetStartedClick() { debouncer.debounce(modelScope) { - val mode = if (shouldShowMobileWalletPromoUseCase()) { - AppRoute.CreateWalletStart.Mode.HotWallet - } else { - AppRoute.CreateWalletStart.Mode.ColdWallet + modelScope.launch { + val mode = if (isReferralInstallUseCase()) { + AppRoute.CreateWalletStart.Mode.HotWallet + } else { + AppRoute.CreateWalletStart.Mode.ColdWallet + } + router.push(AppRoute.CreateWalletStart(mode = mode)) } - router.push(AppRoute.CreateWalletStart(mode = mode)) } } diff --git a/features/home/impl/src/test/kotlin/com/tangem/features/home/impl/model/HomeModelTest.kt b/features/home/impl/src/test/kotlin/com/tangem/features/home/impl/model/HomeModelTest.kt index 4879b05357..e337357d28 100644 --- a/features/home/impl/src/test/kotlin/com/tangem/features/home/impl/model/HomeModelTest.kt +++ b/features/home/impl/src/test/kotlin/com/tangem/features/home/impl/model/HomeModelTest.kt @@ -8,6 +8,7 @@ import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.domain.appsflyer.usecase.IsReferralInstallUseCase import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -16,7 +17,6 @@ import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.wallets.builder.ColdUserWalletBuilder 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.api.HomeFeatureToggles import com.tangem.features.home.impl.ui.state.Stories @@ -45,7 +45,7 @@ internal class HomeModelTest { private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory = mockk(relaxed = true) private val saveWalletUseCase: SaveWalletUseCase = mockk(relaxed = true) private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true) - private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase = mockk(relaxed = true) + private val isReferralInstallUseCase: IsReferralInstallUseCase = mockk(relaxed = true) private val homeFeatureToggles: HomeFeatureToggles = mockk() private val uiMessageSender: UiMessageSender = mockk(relaxed = true) @@ -188,7 +188,7 @@ internal class HomeModelTest { coldUserWalletBuilderFactory = coldUserWalletBuilderFactory, saveWalletUseCase = saveWalletUseCase, userWalletsListRepository = userWalletsListRepository, - shouldShowMobileWalletPromoUseCase = shouldShowMobileWalletPromoUseCase, + isReferralInstallUseCase = isReferralInstallUseCase, homeFeatureToggles = homeFeatureToggles, uiMessageSender = uiMessageSender, ) diff --git a/features/nft/impl/build.gradle.kts b/features/nft/impl/build.gradle.kts index 03abf03910..873e739135 100644 --- a/features/nft/impl/build.gradle.kts +++ b/features/nft/impl/build.gradle.kts @@ -26,6 +26,8 @@ dependencies { implementation(projects.core.analytics.models) /** Domain modules */ + api(projects.domain.card) + api(projects.domain.feedback) api(projects.domain.account) api(projects.domain.account.status) api(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, 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/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 c495daf13c..04ba403a06 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 @@ -194,6 +194,7 @@ private fun SmoothHeightPager( BannerNotification( config = banners[lowerPage].config, containerColor = containerColor, + modifier = Modifier.padding(horizontal = horizontalPadding), ) }.first().measure(pageConstraints).height @@ -202,6 +203,7 @@ private fun SmoothHeightPager( BannerNotification( config = banners[upperPage].config, containerColor = containerColor, + modifier = Modifier.padding(horizontal = horizontalPadding), ) }.first().measure(pageConstraints).height } else { 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 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 d30b1aaa76..61f56380eb 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 @@ -530,6 +530,13 @@ internal class SendConfirmModel @Inject constructor( ), ) val isHighNetworkFee = isHighNetworkFee(feeCryptoCurrencyStatus.currency) + 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( @@ -540,6 +547,8 @@ internal class SendConfirmModel @Inject constructor( appCurrency = appCurrency, analyticsCategoryName = params.analyticsCategoryName, isHighNetworkFee = isHighNetworkFee, + 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 e240d132d3..f9987930e3 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, @@ -30,6 +32,8 @@ internal class SendConfirmationNotificationsTransformerV2( private val appCurrency: AppCurrency, private val analyticsCategoryName: String, private val isHighNetworkFee: Boolean = false, + private val isFeeSubtractedFromAmount: Boolean, + private val isFeeExceedingBalance: Boolean, ) : Transformer { override fun transform(prevState: ConfirmUM): ConfirmUM { val state = prevState as? ConfirmUM.Content ?: return prevState @@ -81,10 +85,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/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 b54ae939af..3a8ec887ad 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.subcomponents.notifications.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() @@ -144,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( @@ -221,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 } } @@ -295,6 +316,7 @@ internal class NotificationsModel @Inject constructor( cryptoCurrency = currency, feeCryptoCurrency = feeCryptoCurrencyStatus.currency, isAccountFunded = currencyCheck.isAccountFunded, + hasRequiredTrustline = recipientRequiresTrustline(notificationData.destinationAddress), ) addMinimumAmountErrorNotification( minimumSendAmount = currencyCheck.minimumSendAmount, @@ -303,6 +325,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/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt index 8c82cb540d..ff748a121e 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt @@ -1,23 +1,25 @@ package com.tangem.features.send.send.confirm.model.transformers -import com.google.common.truth.Truth.assertThat -import com.tangem.blockchain.common.Amount +import com.google.common.truth.Truth import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.R 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 import com.tangem.domain.models.network.Network -import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeFiatRateUM -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.domain.tokens.model.Amount +import com.tangem.features.send.api.subcomponents.feeSelector.entity.* +import com.tangem.features.send.api.utils.formatFooterFiatFee import com.tangem.features.send.common.ui.state.ConfirmUM import io.mockk.mockk import io.mockk.verify @@ -28,7 +30,6 @@ import org.junit.jupiter.api.Test import java.math.BigDecimal import java.math.BigInteger import java.util.Locale -import com.tangem.domain.tokens.model.Amount as DomainAmount class SendConfirmationNotificationsTransformerV2Test { @@ -77,6 +78,8 @@ class SendConfirmationNotificationsTransformerV2Test { appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, isHighNetworkFee = false, + isFeeSubtractedFromAmount = false, + isFeeExceedingBalance = false, ) val initialState: ConfirmUM = ConfirmUM.Empty @@ -84,7 +87,7 @@ class SendConfirmationNotificationsTransformerV2Test { val result = transformer.transform(initialState) // THEN - assertThat(result).isEqualTo(initialState) + Truth.assertThat(result).isEqualTo(initialState) } @Test @@ -100,6 +103,8 @@ class SendConfirmationNotificationsTransformerV2Test { appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, isHighNetworkFee = false, + isFeeSubtractedFromAmount = false, + isFeeExceedingBalance = false, ) val initialState = createTestConfirmUM() @@ -107,7 +112,7 @@ class SendConfirmationNotificationsTransformerV2Test { val result = transformer.transform(initialState) // THEN - assertThat(result).isEqualTo(initialState) + Truth.assertThat(result).isEqualTo(initialState) } @Test @@ -123,6 +128,8 @@ class SendConfirmationNotificationsTransformerV2Test { appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, isHighNetworkFee = false, + isFeeSubtractedFromAmount = false, + isFeeExceedingBalance = false, ) val initialState = createTestConfirmUM() @@ -130,10 +137,62 @@ class SendConfirmationNotificationsTransformerV2Test { val result = transformer.transform(initialState) // THEN - assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) + Truth.assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) val content = result as ConfirmUM.Content - assertThat(content.notifications).isEmpty() - assertThat(content.sendingFooter).isNotEqualTo(initialState.sendingFooter) + Truth.assertThat(content.notifications).isEmpty() + Truth.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 + Truth.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 + Truth.assertThat(content.sendingFooter).isEqualTo( + expectedFiatFooter(sendingValue = BigDecimal.ZERO, feeSelectorUM = feeSelectorUM), + ) } @Test @@ -149,6 +208,8 @@ class SendConfirmationNotificationsTransformerV2Test { appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, isHighNetworkFee = false, + isFeeSubtractedFromAmount = false, + isFeeExceedingBalance = false, ) val initialState = createTestConfirmUM() @@ -156,10 +217,10 @@ class SendConfirmationNotificationsTransformerV2Test { val result = transformer.transform(initialState) // THEN - assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) + Truth.assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) val content = result as ConfirmUM.Content - assertThat(content.notifications).hasSize(1) - assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.TooHigh::class.java) + Truth.assertThat(content.notifications).hasSize(1) + Truth.assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.TooHigh::class.java) } @Test @@ -175,6 +236,8 @@ class SendConfirmationNotificationsTransformerV2Test { appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, isHighNetworkFee = true, + isFeeSubtractedFromAmount = false, + isFeeExceedingBalance = false, ) val initialState = createTestConfirmUM() @@ -182,9 +245,9 @@ class SendConfirmationNotificationsTransformerV2Test { val result = transformer.transform(initialState) // THEN - assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) + Truth.assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) val content = result as ConfirmUM.Content - assertThat(content.notifications).containsExactly(NotificationUM.Warning.HighNetworkFee) + Truth.assertThat(content.notifications).containsExactly(NotificationUM.Warning.HighNetworkFee) } @Test @@ -200,6 +263,8 @@ class SendConfirmationNotificationsTransformerV2Test { appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, isHighNetworkFee = false, + isFeeSubtractedFromAmount = false, + isFeeExceedingBalance = false, ) val initialState = createTestConfirmUM() @@ -207,10 +272,10 @@ class SendConfirmationNotificationsTransformerV2Test { val result = transformer.transform(initialState) // THEN - assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) + Truth.assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) val content = result as ConfirmUM.Content - assertThat(content.notifications).hasSize(1) - assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.FeeTooLow::class.java) + Truth.assertThat(content.notifications).hasSize(1) + Truth.assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.FeeTooLow::class.java) verify { analyticsEventHandler.send(any()) } } @@ -227,6 +292,8 @@ class SendConfirmationNotificationsTransformerV2Test { appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, isHighNetworkFee = false, + isFeeSubtractedFromAmount = false, + isFeeExceedingBalance = false, ) val initialState = createTestConfirmUM() @@ -234,11 +301,11 @@ class SendConfirmationNotificationsTransformerV2Test { val result = transformer.transform(initialState) // THEN - assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) + Truth.assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) val content = result as ConfirmUM.Content - assertThat(content.notifications).hasSize(2) - assertThat(content.notifications.any { it is NotificationUM.Warning.TooHigh }).isTrue() - assertThat(content.notifications.any { it is NotificationUM.Warning.FeeTooLow }).isTrue() + Truth.assertThat(content.notifications).hasSize(2) + Truth.assertThat(content.notifications.any { it is NotificationUM.Warning.TooHigh }).isTrue() + Truth.assertThat(content.notifications.any { it is NotificationUM.Warning.FeeTooLow }).isTrue() } private fun createTestConfirmUM(): ConfirmUM.Content { @@ -253,12 +320,12 @@ class SendConfirmationNotificationsTransformerV2Test { } private fun createTestAmountUM(): AmountState.Data { - val cryptoAmount = DomainAmount( + val cryptoAmount = Amount( currencySymbol = "SOL", value = BigDecimal("1.5"), decimals = 8, ) - val fiatAmount = DomainAmount( + val fiatAmount = Amount( currencySymbol = "USD", value = BigDecimal("50.00"), decimals = 2, @@ -294,9 +361,52 @@ class SendConfirmationNotificationsTransformerV2Test { ) } + private fun createFiatConvertibleFeeSelectorUM(feeValue: BigDecimal): FeeSelectorUM.Content { + val fee = Fee.Common( + amount = com.tangem.blockchain.common.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( + amount = com.tangem.blockchain.common.Amount( currencySymbol = "SOL", value = BigDecimal("0.001"), decimals = 8, @@ -327,14 +437,14 @@ class SendConfirmationNotificationsTransformerV2Test { private fun createFeeTooHighUM(): FeeSelectorUM.Content { val priorityFee = Fee.Common( - amount = Amount( + amount = com.tangem.blockchain.common.Amount( currencySymbol = "SOL", value = BigDecimal("0.001"), decimals = 8, ), ) val minimumFee = Fee.Common( - amount = Amount( + amount = com.tangem.blockchain.common.Amount( currencySymbol = "SOL", value = BigDecimal("0.001"), decimals = 8, @@ -351,7 +461,7 @@ class SendConfirmationNotificationsTransformerV2Test { feeItems = persistentListOf( FeeItem.Custom( fee = Fee.Common( - amount = Amount( + amount = com.tangem.blockchain.common.Amount( currencySymbol = "SOL", value = BigDecimal("0.01"), decimals = 8, @@ -373,7 +483,7 @@ class SendConfirmationNotificationsTransformerV2Test { ), selectedFeeItem = FeeItem.Custom( fee = Fee.Common( - amount = Amount( + amount = com.tangem.blockchain.common.Amount( currencySymbol = "SOL", value = BigDecimal("0.01"), decimals = 8, @@ -411,14 +521,14 @@ class SendConfirmationNotificationsTransformerV2Test { private fun createFeeTooLowUM(): FeeSelectorUM.Content { val fee = Fee.Common( - amount = Amount( + amount = com.tangem.blockchain.common.Amount( currencySymbol = "SOL", value = BigDecimal("0.0001"), decimals = 8, ), ) val minimumFee = Fee.Common( - amount = Amount( + amount = com.tangem.blockchain.common.Amount( currencySymbol = "SOL", value = BigDecimal("0.001"), decimals = 8, @@ -483,14 +593,14 @@ class SendConfirmationNotificationsTransformerV2Test { private fun createFeeTooHighAndTooLowUM(): FeeSelectorUM.Content { val priorityFee = Fee.Common( - amount = Amount( + amount = com.tangem.blockchain.common.Amount( currencySymbol = "SOL", value = BigDecimal("0.001"), decimals = 8, ), ) val minimumFee = Fee.Common( - amount = Amount( + amount = com.tangem.blockchain.common.Amount( currencySymbol = "SOL", value = BigDecimal("0.01"), decimals = 8, @@ -507,7 +617,7 @@ class SendConfirmationNotificationsTransformerV2Test { feeItems = persistentListOf( FeeItem.Custom( fee = Fee.Common( - amount = Amount( + amount = com.tangem.blockchain.common.Amount( currencySymbol = "SOL", value = BigDecimal("0.008"), decimals = 8, @@ -529,7 +639,7 @@ class SendConfirmationNotificationsTransformerV2Test { ), selectedFeeItem = FeeItem.Custom( fee = Fee.Common( - amount = Amount( + amount = com.tangem.blockchain.common.Amount( currencySymbol = "SOL", value = BigDecimal("0.008"), decimals = 8, 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/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/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/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..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 @@ -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,10 +32,12 @@ 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 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 @@ -66,8 +69,11 @@ class SwapTransferInteractorImpl @Inject constructor( private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, private val getTronFeeNotificationShowCountUseCase: GetTronFeeNotificationShowCountUseCase, private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase, + private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase, + private val validateTransactionUseCase: ValidateTransactionUseCase, ) : SwapTransferInteractor { + @Suppress("LongMethod") override suspend fun updateTransfer( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, @@ -102,18 +108,37 @@ 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, feeCurrencyStatus = feePaidCurrencyStatus, amount = fromTokenAmountValue, fee = warningsFee, - feeCurrencyBalanceAfterTransaction = null, - ) - val isAmountSubtractAvailable = isAmountSubtractAvailable( - userWalletId = userWallet.walletId, - currency = fromTokenInfo.swapCurrencyStatus.currency, - fee = fee, + feeCurrencyBalanceAfterTransaction = getFeeCurrencyBalanceAfterTx( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + feePaidCurrencyStatus = feePaidCurrencyStatus, + sendingAmount = sendingAmountForRentCheck, + feeValue = fee?.amount?.value, + ), + recipientAddress = toSwapCurrencyStatus.destinationAddress(), ) val coverageState = getCoverageState( fromTokenInfo = fromTokenInfo, @@ -130,6 +155,17 @@ class SwapTransferInteractorImpl @Inject constructor( ) } val tronFeeNotificationShowCount = getTronFeeNotificationShowCountUseCase() + val hasRequiredTrustline = getAssetRequirementsUseCase( + 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, @@ -145,9 +181,45 @@ class SwapTransferInteractorImpl @Inject constructor( isAmountSubtractAvailable = isAmountSubtractAvailable, isSendingAmountLoading = coverageState.isSendingAmountLoading, currencyCheck = currencyCheck, + validationResult = validationResult, + minAdaValue = minAdaValue, + hasRequiredTrustline = hasRequiredTrustline, ) } + 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?, + 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 6ff5e5270d..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 @@ -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 @@ -30,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 @@ -42,6 +44,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 +68,8 @@ 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 validateTransactionUseCase: ValidateTransactionUseCase = mockk() private val sut = SwapTransferInteractorImpl( swapFeatureToggles = swapFeatureToggles, @@ -82,8 +87,16 @@ internal class SwapTransferInteractorImplTest { getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase, 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 fun tearDown() { clearAllMocks() @@ -429,6 +442,426 @@ 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, + ) + } + } + + @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) + } + + @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() + } + + @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 @@ -997,6 +1430,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, 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 1411b01dcf..58d13cc7f1 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 @@ -1501,7 +1501,7 @@ internal class SwapModel @Inject constructor( } private fun updateTransferModeTangemPayState() { - sendTransferInProgressEvent() + sendTransferInProgressEvent(isTangemPay = true) uiState = swapTransferStateBuilder.createTangemPayWithdrawalSuccessState( uiState = uiState, dataState = dataState, @@ -1551,7 +1551,7 @@ internal class SwapModel @Inject constructor( "" } updateWalletBalance() - sendTransferInProgressEvent() + sendTransferInProgressEvent(isTangemPay = false) uiState = swapTransferStateBuilder.createSuccessState( uiState = uiState, dataState = dataState, @@ -1574,7 +1574,7 @@ internal class SwapModel @Inject constructor( ) } - private fun sendTransferInProgressEvent() { + private fun sendTransferInProgressEvent(isTangemPay: Boolean) { val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus analyticsEventHandler.send( @@ -1582,6 +1582,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 99ecd52206..18803e9f8f 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 @@ -231,6 +231,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 778e8eff18..7166f53e31 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 @@ -45,7 +46,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, @@ -73,12 +74,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?, @@ -127,7 +122,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, 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 e593d981d1..720be1f88f 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, ) } 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/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/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 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, ) { 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/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 aab5f9b53a..ac0ea74c33 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 @@ -349,11 +348,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 -> { val balanceColor = if (animatedState.isMuted) { 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 b6a1bffc3f..b7f1861b7b 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 @@ -179,11 +178,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/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/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 e3bb4e61e4..4d2f33c8fc 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 @@ -14,7 +14,6 @@ import com.tangem.core.ui.ds2.loader.TangemLoaderSize import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner 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 @@ -115,16 +114,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 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 = {}, ) } 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/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/DefaultTokenReceiveComponent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/DefaultTokenReceiveComponent.kt index 70f0fe63d0..5679f51b7a 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/DefaultTokenReceiveComponent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/DefaultTokenReceiveComponent.kt @@ -11,10 +11,12 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.features.tokenreceive.TokenReceiveComponent import com.tangem.features.tokenreceive.model.TokenReceiveModel import com.tangem.features.tokenreceive.route.TokenReceiveRoutes import com.tangem.features.tokenreceive.ui.TokenReceiveContentSheet +import com.tangem.features.tokenreceive.ui.TokenReceiveContentSheetLegacy import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -43,13 +45,21 @@ internal class DefaultTokenReceiveComponent @AssistedInject constructor( override fun BottomSheet() { val content by contentStack.subscribeAsState() val currentRoute = content.active.configuration - - TokenReceiveContentSheet( - route = currentRoute, - onCloseClick = ::dismiss, - onBackClick = ::onChildBack, - contentStack = content, - ) + if (LocalRedesignEnabled.current) { + TokenReceiveContentSheet( + route = currentRoute, + onCloseClick = ::dismiss, + onBackClick = ::onChildBack, + contentStack = content, + ) + } else { + TokenReceiveContentSheetLegacy( + route = currentRoute, + onCloseClick = ::dismiss, + onBackClick = ::onChildBack, + contentStack = content, + ) + } } override fun dismiss() = model.params.onDismiss() diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveAssetsComponent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveAssetsComponent.kt index f435631cb2..16e4e8487f 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveAssetsComponent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveAssetsComponent.kt @@ -9,10 +9,12 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.domain.tokens.model.analytics.TokenReceiveCopyActionSource import com.tangem.features.tokenreceive.entity.ReceiveAddress import com.tangem.features.tokenreceive.model.TokenReceiveAssetsModel import com.tangem.features.tokenreceive.ui.TokenReceiveAssetsContent +import com.tangem.features.tokenreceive.ui.TokenReceiveAssetsContentLegacy import kotlinx.collections.immutable.ImmutableList internal class TokenReceiveAssetsComponent( @@ -25,7 +27,11 @@ internal class TokenReceiveAssetsComponent( @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() - TokenReceiveAssetsContent(assetsUM = state) + if (LocalRedesignEnabled.current) { + TokenReceiveAssetsContent(assetsUM = state) + } else { + TokenReceiveAssetsContentLegacy(assetsUM = state) + } } internal interface TokenReceiveAssetsModelCallback { diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveQrCodeComponent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveQrCodeComponent.kt index 6780fd2740..13f6d05b0c 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveQrCodeComponent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveQrCodeComponent.kt @@ -7,12 +7,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.domain.models.TokenReceiveType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.tokens.model.analytics.TokenReceiveCopyActionSource import com.tangem.features.tokenreceive.entity.ReceiveAddress import com.tangem.features.tokenreceive.model.TokenReceiveQrCodeModel import com.tangem.features.tokenreceive.ui.TokenReceiveQrCodeContent +import com.tangem.features.tokenreceive.ui.TokenReceiveQrCodeContentLegacy internal class TokenReceiveQrCodeComponent( appComponentContext: AppComponentContext, @@ -24,7 +26,11 @@ internal class TokenReceiveQrCodeComponent( @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() - TokenReceiveQrCodeContent(qrCodeUM = state) + if (LocalRedesignEnabled.current) { + TokenReceiveQrCodeContent(qrCodeUM = state) + } else { + TokenReceiveQrCodeContentLegacy(qrCodeUM = state) + } } internal interface TokenReceiveQrCodeModelCallback { diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveWarningComponent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveWarningComponent.kt index 46ce303258..3b95787584 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveWarningComponent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveWarningComponent.kt @@ -8,9 +8,11 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.domain.models.network.Network import com.tangem.features.tokenreceive.model.TokenReceiveWarningModel import com.tangem.features.tokenreceive.ui.TokenReceiveWarningContent +import com.tangem.features.tokenreceive.ui.TokenReceiveWarningContentLegacy internal class TokenReceiveWarningComponent( appComponentContext: AppComponentContext, @@ -22,7 +24,11 @@ internal class TokenReceiveWarningComponent( @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() - TokenReceiveWarningContent(warningUM = state) + if (LocalRedesignEnabled.current) { + TokenReceiveWarningContent(warningUM = state) + } else { + TokenReceiveWarningContentLegacy(warningUM = state) + } } internal interface TokenReceiveWarningModelCallback { diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt index 1a65fcfde1..699e500127 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt @@ -12,6 +12,7 @@ internal data class ReceiveAddress( sealed interface Type { data object Ens : Type + @Immutable sealed interface Primary : Type { val displayName: TextReference diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt index 61602b31b4..4577352882 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt @@ -1,97 +1,379 @@ 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.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.rememberCoroutineScope 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.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.SpacerH +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.SpacerW 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.ds.TangemPagerIndicator +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.message.TangemMessage +import com.tangem.core.ui.ds2.badge.TangemBadge +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.loader.TangemLoader +import com.tangem.core.ui.ds2.loader.TangemLoaderSize 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.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 TokenReceiveAssetsContent(assetsUM: ReceiveAssetsUM) { - val snackbarHostState = remember(::SnackbarHostState) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (assetsUM.isEnsResultLoading) { + LoadingBlock( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp), + ) + } + AddressBlock(assetsUM = assetsUM) + SpacerH(16.dp) + Info( + showMemoDisclaimer = assetsUM.showMemoDisclaimer, + notificationConfigs = assetsUM.notificationConfigs, + currencyIconState = assetsUM.currencyIconState, + ) + } +} - ContainerWithSnackbarHost(snackbarHostState = snackbarHostState) { - Column( +@Composable +private fun LoadingBlock(modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = TangemTheme.colors3.bg.opaque.primary), + ) { + Box( modifier = Modifier .fillMaxWidth() - .background(color = TangemTheme.colors.background.tertiary) - .padding(bottom = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .heightIn(min = 68.dp), + contentAlignment = Alignment.Center, ) { - 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, + TangemLoader( + color = TangemTheme.colors3.icon.secondary, + size = TangemLoaderSize.X28, ) } } } +@Composable +private fun AddressBlock(assetsUM: ReceiveAssetsUM) { + val hapticFeedback = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + 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 { + 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 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 a4d57d472b..60821d7231 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,17 +3,13 @@ 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 @@ -21,19 +17,18 @@ 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.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 @@ -49,13 +44,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 @@ -88,11 +88,7 @@ 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.tokendetails.deeplink.TokenDetailsDeepLinkActionListener @@ -101,28 +97,10 @@ import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRout 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.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/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/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( 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/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 diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt index 6ec531b3d2..85990403bd 100644 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt @@ -16,8 +16,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.text.applyBladeBrush -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.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign @@ -44,11 +43,9 @@ fun TangemBalanceHeader( }, ) { animatedState -> when (animatedState) { - is TangemBalanceHeaderState.Loading -> TextShimmer( + is TangemBalanceHeaderState.Loading -> TangemShimmer( modifier = Modifier.size(width = 160.dp, height = 56.dp), - text = "1234.00", - style = TextShimmerStyle.HEADING_MEDIUM, - radius = TangemTheme.dimens2.x25, + style = TangemTheme.typography3.heading.medium, ) is TangemBalanceHeaderState.Content -> Text( modifier = balanceModifier, diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index cc4d6ecf6b..5c20b43797 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/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 e628ea2581..192bb989bc 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 @@ -331,9 +331,12 @@ 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(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/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 5b88f3fc17..dfaf6d0930 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 @@ -119,7 +119,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/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..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,9 +213,12 @@ 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(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/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/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() }, 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 bbd03e0a0b..6727c0f20b 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 @@ -162,7 +162,7 @@ internal class WalletTokensListUMConverter( private fun getOrganizeButtonUM(accountList: AccountStatusList): TangemButtonUM? { val textRes = R.string.main_add_and_manage_tokens val iconRes = R.drawable.ic_filter_default_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/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 } } 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, ) } } 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 37e3bfdbb8..611150bf15 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 @@ -22,10 +25,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 @@ -80,6 +86,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 { @@ -128,6 +139,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(), @@ -180,7 +241,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 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..8ea472e436 --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverterTest.kt @@ -0,0 +1,173 @@ +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(), + 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 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 6bd7e45e31..315ec5920d 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 = {}, ) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 6e0f27042e..3ba4a94524 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 = "develop-1592" +tangemBlockchainSdk = "develop-1604" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-630" #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/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/TransactionExtras.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/TransactionExtras.kt index 13dd7d0e13..e6225281c1 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/TransactionExtras.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/TransactionExtras.kt @@ -49,6 +49,7 @@ fun Blockchain.getSupportedTransactionExtras(): Network.TransactionExtrasType { Blockchain.EthereumClassicTestnet, Blockchain.Fantom, Blockchain.FantomTestnet, + Blockchain.Gonka, Blockchain.Litecoin, Blockchain.Near, Blockchain.NearTestnet, 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,