From 752aaeecd7848cda2cd09d444281bf758d92ff6a Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 17 Dec 2025 14:21:08 +0000 Subject: [PATCH 001/172] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 462e99b59c..e51eb53245 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.32-1329" +tangemBlockchainSdk = "develop-1327" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.32-574" +tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 91cf397f4e13f97082c3093e1fa641f917e6ab67 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 17 Dec 2025 19:59:13 +0300 Subject: [PATCH 002/172] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 6 +- .../tangem/common/constants/TestConstants.kt | 2 + .../com/tangem/scenarios/SendScenarios.kt | 29 ++ ...ndSelectNetworkFeeBottomSheetPageObject.kt | 102 ++++ ...pSelectNetworkFeeBottomSheetPageObject.kt} | 14 +- .../kotlin/com/tangem/tests/SwapTokenTest.kt | 10 +- .../tests/send/feeScreen/SendFeeScreenTest.kt | 438 ++++++++++++++++++ .../sdk/mocks/content/WalletMockContent.kt | 43 ++ .../ui/components/inputrow/InputRowEnter.kt | 5 +- .../inputrow/InputRowEnterInfoAmount.kt | 16 +- .../ui/components/rows/SelectorRowItem.kt | 4 +- .../SelectNetworkFeeBottomSheetTestTags.kt | 6 - ...SendSelectNetworkFeeBottomSheetTestTags.kt | 22 + ...SwapSelectNetworkFeeBottomSheetTestTags.kt | 6 + .../ui/FeeSelectorModalBottomSheet.kt | 33 +- .../feature/swap/ui/ChooseFeeBottomSheet.kt | 4 +- gradle/tangem_dependencies.toml | 2 +- 17 files changed, 704 insertions(+), 38 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/SendSelectNetworkFeeBottomSheetPageObject.kt rename app/src/androidTest/kotlin/com/tangem/screens/{SelectNetworkFeePageObject.kt => SwapSelectNetworkFeeBottomSheetPageObject.kt} (65%) create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/SelectNetworkFeeBottomSheetTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/SendSelectNetworkFeeBottomSheetTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/SwapSelectNetworkFeeBottomSheetTestTags.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 8a40e56185..ea5a538da3 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -139,10 +139,10 @@ abstract class BaseTestCase : TestCase( return ApplicationInjectionExecutionRule( toggleStates = mapOf( "NEW_TOKEN_RECEIVE_ENABLED" to true, - "WALLET_BALANCE_FETCHER_ENABLED" to true, - "SWAP_REDESIGN_ENABLED" to true, + "SWAP_REDESIGN_ENABLED" to false, "NEW_ONRAMP_MAIN_ENABLED" to true, - "HOT_WALLET_ENABLED" to true + "HOT_WALLET_ENABLED" to true, + "YIELD_SUPPLY_FEATURE_ENABLED" to true ) ) } diff --git a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt index cb7ed9ff51..fd5d74d374 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -8,6 +8,7 @@ object TestConstants { const val ETHEREUM_RECIPIENT_ADDRESS = "0x5aa711F440Eb6d4361148bBD89d03464628ace84" const val ETHEREUM_RECIPIENT_SHORTENED_ADDRESS = "0x5aa711F440Eb6d43...89d03464628ace84" const val BITCOIN_ADDRESS = "bc1qtg9aa6jcpqtvun0pe0uct7sxm8nq2nsxfmfxm3" + const val BITCOIN_RECIPIENT_ADDRESS = "bc1qt90qc0na7z05nh63kyd78tujfc8vqv6sl7e4a9" const val CARDANO_ADDRESS = "addr1q8f9499e58k4hhfd9vhawprxt3xd94x7rmlyp33ee4xkatakcl2zgkrg0p6ceqkndtkw4cumfe9enhdph8yhuswn785srksm9p" const val SOLANA_RECIPIENT_ADDRESS = "5fcy9woa8Di1QHcce65CsV3XKrxdB2pD4HJx5xx82ipM" @@ -30,6 +31,7 @@ object TestConstants { const val XRP_ACTIVATED_RECIPIENT_ADDRESS = "rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH" const val DOGECOIN_RECIPIENT_ADDRESS = "DJQR3bdhBKcFGMHX2BkMCkrMFApNWNzr6V" const val DOGECOIN_ADDRESS = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaqz" + const val TERRA_RECIPIENT_ADDRESS = "terra148dmp5ccazcwdmrcpvqz5rprnn886kemqen3tj" const val WAIT_UNTIL_TIMEOUT = 20_000L const val WAIT_UNTIL_TIMEOUT_LONG = 30_000L diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt index 6bcaa775de..066600df5e 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt @@ -54,6 +54,10 @@ fun BaseTestCase.checkNetworkFeeBlock(currentFeeAmount: String, withFeeSelector: step("Assert select fee icon is displayed") { onSendConfirmScreen { selectFeeIcon.assertIsDisplayed() } } + } else { + step("Assert select fee icon is not displayed") { + onSendConfirmScreen { selectFeeIcon.assertIsNotDisplayed() } + } } } @@ -138,4 +142,29 @@ fun BaseTestCase.checkRecentAddressItem(address: String, description: String?) { recentAddressItem(recipientAddress = address, description = description).assertIsDisplayed() } } +} + +fun BaseTestCase.checkCustomFeeTooltip(title: String, tooltip: String) { + step("Click on tooltip icon for '$title'") { + waitForIdle() + onSendSelectNetworkFeeBottomSheet { tooltipIcon(title).performClick() } + } + step("Check '$title' tooltip text") { + onSendSelectNetworkFeeBottomSheet { tooltipText(tooltip).assertIsDisplayed() } + } + step("Click on tooltip icon again to close tooltip") { + onSendSelectNetworkFeeBottomSheet { tooltipIcon(title).performClick() } + } +} + +fun BaseTestCase.checkChangesInInputTextField(title: String, newValue: String, addition: String = "") { + step("Click on '$title' input text field") { + onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(title).performClick() } + } + step("Type '$newValue' in '$title' input text field") { + onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(title).performTextReplacement(newValue) } + } + step("Assert '$title' value: '$newValue + $addition'") { + onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(title).assertTextContains(newValue + addition) } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendSelectNetworkFeeBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendSelectNetworkFeeBottomSheetPageObject.kt new file mode 100644 index 0000000000..68d545c608 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendSelectNetworkFeeBottomSheetPageObject.kt @@ -0,0 +1,102 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.BaseBottomSheetTestTags +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.SendSelectNetworkFeeBottomSheetTestTags +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 +import androidx.compose.ui.test.hasTestTag as withTestTag +import androidx.compose.ui.test.hasText as withText + +class SendSelectNetworkFeeBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasTestTag(BaseBottomSheetTestTags.TITLE) + hasText(getResourceString(R.string.common_network_fee_title)) + useUnmergedTree = true + } + + fun regularFeeSelectorItem(title: String): KNode = child { + hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_FEE_ITEM) + hasAnyDescendant(withText(title)) + hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_ICON)) + hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE)) + hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.TOKEN_AMOUNT)) + hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.FIAT_AMOUNT)) + hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.DOT_SIGN)) + useUnmergedTree = true + } + + val customSelectorItem: KNode = child { + hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_FEE_ITEM) + hasAnyDescendant(withText(getResourceString(R.string.common_custom))) + hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_ICON)) + hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_TITLE)) + useUnmergedTree = true + } + + fun customInputItem(title: String, hasFiatAmount: Boolean = false): KNode = child { + hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM) + hasAnyDescendant(withText(title)) + hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_TITLE)) + hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_TOOLTIP_ICON)) + hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD)) + useUnmergedTree = true + if (hasFiatAmount) { + hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_FIAT_AMOUNT)) + } + } + + val nonceInputItem: KNode = child { + hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.NONCE_INPUT_ITEM) + hasAnyDescendant(withText(getResourceString(R.string.send_nonce))) + hasAnyDescendant(withText(getResourceString(R.string.send_nonce_hint))) + useUnmergedTree = true + } + + fun tooltipIcon(title: String): KNode = child { + hasAnySibling(withText(title)) + useUnmergedTree = true + } + + fun tooltipText(text: String): KNode = child { + hasText(text) + useUnmergedTree = true + } + + private fun inputTextField(title: String): KNode = child { + hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD) + hasAnySibling(withText(title)) + useUnmergedTree = true + } + + fun inputTextFieldValue(title: String): KNode = inputTextField(title).child { + hasParent(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD)) + useUnmergedTree = true + } + + val nonceInputTextField: KNode = child { + hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.NONCE_INPUT_TEXT_FIELD) + useUnmergedTree = true + } + + val customInputItemFiatAmount: KNode = child { + hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_FIAT_AMOUNT) + useUnmergedTree = true + } + + val doneButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_done)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onSendSelectNetworkFeeBottomSheet(function: SendSelectNetworkFeeBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SelectNetworkFeePageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapSelectNetworkFeeBottomSheetPageObject.kt similarity index 65% rename from app/src/androidTest/kotlin/com/tangem/screens/SelectNetworkFeePageObject.kt rename to app/src/androidTest/kotlin/com/tangem/screens/SwapSelectNetworkFeeBottomSheetPageObject.kt index 35a55722df..88520fec3a 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SelectNetworkFeePageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapSelectNetworkFeeBottomSheetPageObject.kt @@ -2,7 +2,7 @@ package com.tangem.screens import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase -import com.tangem.core.ui.test.SelectNetworkFeeBottomSheetTestTags +import com.tangem.core.ui.test.SwapSelectNetworkFeeBottomSheetTestTags import com.tangem.core.ui.test.TopAppBarTestTags import com.tangem.wallet.R import io.github.kakaocup.compose.node.element.ComposeScreen @@ -11,8 +11,8 @@ import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString import androidx.compose.ui.test.hasText as withText -class SelectNetworkFeePageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen(semanticsProvider = semanticsProvider) { +class SwapSelectNetworkFeeBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { val title: KNode = child { hasTestTag(TopAppBarTestTags.TITLE) @@ -21,22 +21,22 @@ class SelectNetworkFeePageObject(semanticsProvider: SemanticsNodeInteractionsPro } val marketSelectorItem: KNode = child { - hasTestTag(SelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM) + hasTestTag(SwapSelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM) hasAnyChild(withText(getResourceString(R.string.common_fee_selector_option_market))) useUnmergedTree = true } val fastSelectorItem: KNode = child { - hasTestTag(SelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM) + hasTestTag(SwapSelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM) hasAnyChild(withText(getResourceString(R.string.common_fee_selector_option_fast))) useUnmergedTree = true } val readMoreTextBlock: KNode = child { - hasTestTag(SelectNetworkFeeBottomSheetTestTags.READ_MORE_TEXT) + hasTestTag(SwapSelectNetworkFeeBottomSheetTestTags.READ_MORE_TEXT) useUnmergedTree = true } } -internal fun BaseTestCase.onSelectNetworkFeeBottomSheet(function: SelectNetworkFeePageObject.() -> Unit) = +internal fun BaseTestCase.onSwapSelectNetworkFeeBottomSheet(function: SwapSelectNetworkFeeBottomSheetPageObject.() -> Unit) = onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt index d237730598..060d6439e7 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/SwapTokenTest.kt @@ -215,19 +215,19 @@ class SwapTokenTest : BaseTestCase() { } } step("Assert 'Select fee' bottom sheet title is displayed") { - onSelectNetworkFeeBottomSheet { title.assertIsDisplayed() } + onSwapSelectNetworkFeeBottomSheet { title.assertIsDisplayed() } } step("Assert 'Market' item is displayed") { - onSelectNetworkFeeBottomSheet { marketSelectorItem.assertIsDisplayed() } + onSwapSelectNetworkFeeBottomSheet { marketSelectorItem.assertIsDisplayed() } } step("Assert 'Fast' item is displayed") { - onSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() } + onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() } } step("Assert 'Read more' text block is displayed") { - onSelectNetworkFeeBottomSheet { readMoreTextBlock.assertIsDisplayed() } + onSwapSelectNetworkFeeBottomSheet { readMoreTextBlock.assertIsDisplayed() } } step("Click on 'Fast' item") { - onSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() } + onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() } } step("Assert 'Network fee' block is displayed") { onSwapTokenScreen { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt new file mode 100644 index 0000000000..e3e66cb763 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt @@ -0,0 +1,438 @@ +package com.tangem.tests.send.feeScreen + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.BITCOIN_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.TERRA_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.SwipeDirection +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.swipeVertical +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R +import com.tangem.scenarios.* +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SendFeeScreenTest : BaseTestCase() { + + @AllureId("4906") + @DisplayName("Send (Fee screen): check fee block for fee in token") + @Test + fun checkFeeBlockForFeeInTokenTest() { + val tokenName = "TerraClassicUSD" + val scenarioName = "Terra" + val tokenAmount = "1" + val feeAmount = "<$0.01" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send' screen") { + openSendScreen(tokenName, scenarioName) + } + step("Type '$tokenAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(tokenAmount) + } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(TERRA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Assert fee block is displayed without fee selector") { + checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = false) + } + } + } + + @AllureId("4868") + @DisplayName("Send (Fee screen): check fee block for fixed fee") + @Test + fun checkFeeBlockForFixedFeeTest() { + val tokenName = "Polkadot" + val tokenAmount = "1" + val feeAmount = "$0.05" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send' screen") { + openSendScreen(tokenName) + } + step("Type '$tokenAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(tokenAmount) + } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(POLKADOT_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Assert fee block is displayed without fee selector") { + checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = false) + } + step("Click on 'Fee selector' block") { + onSendConfirmScreen { feeSelectorBlock.performClick() } + } + step("Assert 'Fee selector' bottom sheet is not displayed") { + onSwapSelectNetworkFeeBottomSheet { title.assertIsNotDisplayed() } + } + } + } + + @AllureId("4869") + @DisplayName("Send (Fee screen): check network fee bottom sheet for EVM networks") + @Test + fun checkNetworkFeeBottomSheetForEvmTest() { + val tokenName = "Ethereum" + val tokenAmount = "0.1" + val feeAmount = "~$1.06" + val fiatFeeAmount = "$1.09" + val newFeeAmount = "~$1.09" + val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market) + val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast) + val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow) + val feeUpTo = getResourceString(R.string.send_max_fee) + val feeUpToTooltip = getResourceString(R.string.send_custom_amount_fee_footer) + val feeUpToValue = "0.00042 ETH" + val newFeeUpToValue = "0.00043" + val maxFee = getResourceString(R.string.send_custom_evm_max_fee) + val maxFeeTooltip = getResourceString(R.string.send_custom_evm_max_fee_footer) + val maxFeeValue = "20 GWEI" + val newMaxFeeValue = "21" + val priorityFee = getResourceString(R.string.send_custom_evm_priority_fee) + val priorityFeeTooltip = getResourceString(R.string.send_custom_evm_priority_fee_footer) + val priorityFeeValue = "2 GWEI" + val newPriorityFeeValue = "3" + val gasLimit = getResourceString(R.string.send_gas_limit) + val gasLimitTooltip = getResourceString(R.string.send_gas_limit_footer) + val gasLimitValue = "21,000 " + val newGasLimitValue = "22" + val nonce = getResourceString(R.string.send_nonce) + val nonceTooltip = getResourceString(R.string.send_nonce_footer) + val nonceValue = "1" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send' screen") { + openSendScreen(tokenName) + } + step("Type '$tokenAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(tokenAmount) + } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(ETHEREUM_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Assert fee block is displayed with fee selector") { + checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = true) + } + step("Click on fee selector icon") { + onSendConfirmScreen { feeSelectorIcon.performClick() } + } + step("Assert 'Fee selector' bottom sheet title is displayed") { + onSendSelectNetworkFeeBottomSheet { title.assertIsDisplayed() } + } + step("Assert '$fastSelectorItem' selector item is displayed") { + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).assertIsDisplayed() } + } + step("Assert '$slowSelectorItem' selector item is displayed") { + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(slowSelectorItem).assertIsDisplayed() } + } + step("Assert '$marketSelectorItem' selector item is displayed") { + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSelectorItem).assertIsDisplayed() } + } + step("Assert 'Custom' selector item is displayed") { + onSendSelectNetworkFeeBottomSheet { customSelectorItem.assertIsDisplayed() } + } + step("Click on 'Custom' selector item") { + onSendSelectNetworkFeeBottomSheet { customSelectorItem.performClick() } + } + step("Assert '$feeUpTo' input item is displayed") { + onSendSelectNetworkFeeBottomSheet { + customInputItem(title = feeUpTo, hasFiatAmount = true).assertIsDisplayed() + } + } + step("Assert '$maxFee' input item is displayed") { + onSendSelectNetworkFeeBottomSheet { customInputItem(maxFee).assertIsDisplayed() } + } + step("Assert '$priorityFee' input item is displayed") { + onSendSelectNetworkFeeBottomSheet { customInputItem(priorityFee).assertIsDisplayed() } + } + step("Assert '$gasLimit' input item is displayed") { + onSendSelectNetworkFeeBottomSheet { customInputItem(gasLimit).assertIsDisplayed() } + } + step("Swipe up") { + swipeVertical(SwipeDirection.UP) + } + step("Assert '$nonce' input item is displayed") { + onSendSelectNetworkFeeBottomSheet { nonceInputItem.assertIsDisplayed() } + } + step("Check '$feeUpTo' tooltip") { + checkCustomFeeTooltip(title = feeUpTo, tooltip = feeUpToTooltip) + } + step("Check '$maxFee' tooltip") { + checkCustomFeeTooltip(title = maxFee, tooltip = maxFeeTooltip) + } + step("Check '$priorityFee' tooltip") { + checkCustomFeeTooltip(title = priorityFee, tooltip = priorityFeeTooltip) + } + step("Check '$gasLimit' tooltip") { + checkCustomFeeTooltip(title = gasLimit, tooltip = gasLimitTooltip) + } + step("Check '$nonce' tooltip") { + checkCustomFeeTooltip(title = nonce, tooltip = nonceTooltip) + } + step("Assert '$feeUpTo' value: '$feeUpToValue'") { + onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(feeUpTo).assertTextContains(feeUpToValue) } + } + step("Assert '$maxFee' value: '$maxFeeValue'") { + onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(maxFee).assertTextContains(maxFeeValue) } + } + step("Assert '$priorityFee' value: '$priorityFeeValue'") { + onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(priorityFee).assertTextContains(priorityFeeValue) } + } + step("Assert '$gasLimit' value: '$gasLimitValue'") { + onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(gasLimit).assertTextContains(gasLimitValue) } + } + step("Check changes in '$maxFee' input text field") { + checkChangesInInputTextField(title = maxFee, newValue = newMaxFeeValue, addition = " GWEI") + } + step("Check changes in '$priorityFee' input text field") { + checkChangesInInputTextField(title = priorityFee, newValue = newPriorityFeeValue, addition = " GWEI") + } + step("Check changes in '$gasLimit' input text field") { + checkChangesInInputTextField(title = gasLimit, newValue = newGasLimitValue, addition = " ") + } + step("Type '$nonceValue' in '$nonce' text field") { + onSendSelectNetworkFeeBottomSheet { + nonceInputTextField.performClick() + nonceInputTextField.performTextReplacement(nonceValue) + } + } + step("Assert '$nonce' value: '$nonceValue'") { + onSendSelectNetworkFeeBottomSheet { nonceInputTextField.assertTextContains(nonceValue) } + } + step("Check changes in '$feeUpTo' input text field") { + checkChangesInInputTextField(title = feeUpTo, newValue = newFeeUpToValue, addition = " ETH") + } + step("Assert new fiat fee amount: '$fiatFeeAmount'") { + onSendSelectNetworkFeeBottomSheet { customInputItemFiatAmount.assertTextContains(fiatFeeAmount) } + } + step("Click on 'Done' button") { + onSendSelectNetworkFeeBottomSheet { doneButton.performClick() } + } + step("Assert fee block is displayed with new fee amount: '$newFeeAmount'") { + checkNetworkFeeBlock(currentFeeAmount = newFeeAmount, withFeeSelector = true) + } + } + } + + @AllureId("4870") + @DisplayName("Send (Fee screen): check network fee bottom sheet for Bitcoin") + @Test + fun checkNetworkFeeBottomSheetForBitcoinTest() { + val tokenName = "Bitcoin" + val tokenAmount = "0.00000001" + val feeAmount = "$2.86" + val fiatFeeAmount = "$0.24" + val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market) + val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast) + val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow) + val feeUpTo = getResourceString(R.string.send_max_fee) + val feeUpToValue = "0.0000264 BTC" + val newFeeUpToValue = "0.0000022 BTC" + val satoshi = getResourceString(R.string.send_satoshi_per_byte_title) + val satoshiValue = "2" + val decimalNumber = "2.11" + val newSatoshiValue = "1" + val bitcoinUtxoScenarioName = "bitcoin_utxo" + val bitcoinUtxoScenarioState = "Balance" + val feeScenarioName = "bitcoin_estimate_smart_fee" + val feeScenarioState = "Started" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(bitcoinUtxoScenarioName) + } + ).run { + step("Set WireMock scenario: '$bitcoinUtxoScenarioName' to state: '$bitcoinUtxoScenarioState'") { + setWireMockScenarioState(bitcoinUtxoScenarioName, bitcoinUtxoScenarioState) + } + step("Set WireMock scenario: '$feeScenarioName' to state: '$feeScenarioState'") { + setWireMockScenarioState(feeScenarioName, feeScenarioState) + } + step("Open 'Main' screen") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send address' screen") { + openSendAddressScreen(tokenName, tokenAmount) + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(BITCOIN_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Assert fee block is displayed with fee selector") { + checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = true) + } + step("Click on fee selector icon") { + onSendConfirmScreen { feeSelectorIcon.performClick() } + } + step("Assert 'Fee selector' bottom sheet title is displayed") { + onSendSelectNetworkFeeBottomSheet { title.assertIsDisplayed() } + } + step("Assert '$fastSelectorItem' selector item is displayed") { + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).assertIsDisplayed() } + } + step("Assert '$slowSelectorItem' selector item is displayed") { + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(slowSelectorItem).assertIsDisplayed() } + } + step("Assert '$marketSelectorItem' selector item is displayed") { + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSelectorItem).assertIsDisplayed() } + } + step("Assert 'Custom' selector item is displayed") { + onSendSelectNetworkFeeBottomSheet { customSelectorItem.assertIsDisplayed() } + } + step("Click on 'Custom' selector item") { + onSendSelectNetworkFeeBottomSheet { customSelectorItem.performClick() } + } + step("Assert '$feeUpTo' input item is displayed") { + onSendSelectNetworkFeeBottomSheet { + customInputItem(title = feeUpTo, hasFiatAmount = true).assertIsDisplayed() + } + } + step("Assert '$feeUpTo' value: '$feeUpToValue'") { + onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(feeUpTo).assertTextContains(feeUpToValue) } + } + step("Assert '$satoshi' input item is displayed") { + onSendSelectNetworkFeeBottomSheet { customInputItem(satoshi).assertIsDisplayed() } + } + step("Click on '$satoshi' input text field") { + onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(satoshi).performClick() } + } + step("Type '$decimalNumber' in '$satoshi' input text field") { + onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(satoshi).performTextReplacement(decimalNumber) } + } + step("Assert '$satoshi' value: '$satoshiValue +  '") { + onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(satoshi).assertTextContains("$satoshiValue ") } + } + step("Check changes in '$satoshi' input text field") { + checkChangesInInputTextField(title = satoshi, newValue = newSatoshiValue, addition = " ") + } + step("Assert new fiat fee amount: '$fiatFeeAmount'") { + onSendSelectNetworkFeeBottomSheet { customInputItemFiatAmount.assertTextContains(fiatFeeAmount) } + } + step("Assert '$feeUpTo' value: '$newFeeUpToValue'") { + onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(feeUpTo).assertTextContains(newFeeUpToValue) } + } + step("Click on 'Done' button") { + onSendSelectNetworkFeeBottomSheet { doneButton.performClick() } + } + step("Assert fee block is displayed with new fee amount: '$fiatFeeAmount'") { + checkNetworkFeeBlock(currentFeeAmount = fiatFeeAmount, withFeeSelector = true) + } + } + } + + @AllureId("4871") + @DisplayName("Send (Fee screen): check network fee bottom sheet networks with fee in token") + @Test + fun checkNetworkFeeBottomSheetForVeThorTest() { + val tokenName = "VeThor" + val mockState = "Vechain" + val tokenAmount = "0.1" + val feeAmount = "<$0.01" + val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market) + val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast) + val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send' screen") { + openSendScreen(tokenName = tokenName, mockState = mockState) + } + step("Type '$tokenAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(tokenAmount) + } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(ETHEREUM_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Assert fee block is displayed with fee selector") { + checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = true) + } + step("Click on fee selector icon") { + onSendConfirmScreen { feeSelectorIcon.performClick() } + } + step("Assert 'Fee selector' bottom sheet title is displayed") { + onSendSelectNetworkFeeBottomSheet { title.assertIsDisplayed() } + } + step("Assert '$fastSelectorItem' selector item is displayed") { + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).assertIsDisplayed() } + } + step("Assert '$slowSelectorItem' selector item is displayed") { + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(slowSelectorItem).assertIsDisplayed() } + } + step("Assert '$marketSelectorItem' selector item is displayed") { + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSelectorItem).assertIsDisplayed() } + } + } + } +} \ No newline at end of file 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 3d66410898..382b50ad26 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 @@ -141,6 +141,14 @@ 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'/330'/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), + ), + DerivationPath("m/44'/818'/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), + ), ), extendedPublicKey = ExtendedPublicKey( publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), @@ -261,6 +269,20 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/330'/0'/0/0") to ExtendedPublicKey( // Terra + 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'/818'/0'/0/0") to ExtendedPublicKey( // Vechain + 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, + ), ), ), ByteArrayKey( @@ -318,6 +340,20 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/330'/0'/0/0") to ExtendedPublicKey( // Terra + 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'/818'/0'/0/0") to ExtendedPublicKey( // Vechain + 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, + ), ), ), @@ -371,6 +407,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/818'/0'/0/0") to ExtendedPublicKey( // Vechain + 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, + ), ), ), ByteArrayKey( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt index 96acb30975..194e55eb3e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt @@ -16,6 +16,7 @@ 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.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview @@ -31,6 +32,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.SendSelectNetworkFeeBottomSheetTestTags /** * [InputRowEnter](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-799&mode=design&t=IQ5lBJEkFGU4WSvi-4) @@ -113,7 +115,8 @@ fun InputRowEnter( visualTransformation = visualTransformation, keyboardOptions = keyboardOptions, modifier = Modifier - .padding(top = TangemTheme.dimens.spacing8), + .padding(top = TangemTheme.dimens.spacing8) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.NONCE_INPUT_TEXT_FIELD), ) } iconRes?.let { iconRes -> diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt index cbf84eee89..a805e7c638 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt @@ -11,6 +11,7 @@ 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.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.tangem.core.ui.R @@ -21,6 +22,7 @@ import com.tangem.core.ui.components.tooltip.TangemTooltip import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.SendSelectNetworkFeeBottomSheetTestTags import com.tangem.core.ui.utils.rememberDecimalFormat /** @@ -112,6 +114,7 @@ fun InputRowEnterInfoAmount( } } +@Suppress("LongMethod") @Composable fun InputRowEnterInfoAmountV2( title: TextReference, @@ -139,19 +142,22 @@ fun InputRowEnterInfoAmountV2( Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(16.dp) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM), ) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = title.resolveReference(), style = TangemTheme.typography.subtitle2, color = titleColor, + modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_TITLE), ) if (description != null) { TangemTooltip( modifier = Modifier .size(16.dp) - .clip(CircleShape), + .clip(CircleShape) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_TOOLTIP_ICON), text = description.resolveReference(), content = { contentModifier -> Icon( @@ -183,7 +189,8 @@ fun InputRowEnterInfoAmountV2( backgroundColor = Color.Transparent, modifier = Modifier .padding(top = TangemTheme.dimens.spacing8) - .weight(1f), + .weight(1f) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD), ) info?.let { info -> Text( @@ -192,7 +199,8 @@ fun InputRowEnterInfoAmountV2( color = infoColor, modifier = Modifier .padding(start = TangemTheme.dimens.spacing8) - .align(Alignment.Bottom), + .align(Alignment.Bottom) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_FIAT_AMOUNT), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/SelectorRowItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/SelectorRowItem.kt index a704b3fbbe..fbcb11f335 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/rows/SelectorRowItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/SelectorRowItem.kt @@ -25,7 +25,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.SelectNetworkFeeBottomSheetTestTags +import com.tangem.core.ui.test.SwapSelectNetworkFeeBottomSheetTestTags import com.tangem.utils.StringsSigns @Composable @@ -70,7 +70,7 @@ fun SelectorRowItem( modifier = Modifier .fillMaxWidth() .padding(paddingValues) - .testTag(SelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM), + .testTag(SwapSelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM), verticalAlignment = Alignment.CenterVertically, ) { Icon( diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SelectNetworkFeeBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SelectNetworkFeeBottomSheetTestTags.kt deleted file mode 100644 index 3d2af738bd..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/test/SelectNetworkFeeBottomSheetTestTags.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.core.ui.test - -object SelectNetworkFeeBottomSheetTestTags { - const val READ_MORE_TEXT = "SELECT_NETWORK_FEE_READ_MORE_TEXT" - const val SELECTOR_ITEM = "SELECT_NETWORK_FEE_SELECTOR_ITEM" -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SendSelectNetworkFeeBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SendSelectNetworkFeeBottomSheetTestTags.kt new file mode 100644 index 0000000000..d941ab4b10 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SendSelectNetworkFeeBottomSheetTestTags.kt @@ -0,0 +1,22 @@ +package com.tangem.core.ui.test + +object SendSelectNetworkFeeBottomSheetTestTags { + const val REGULAR_FEE_ITEM = "SEND_SELECT_NETWORK_FEE_REGULAR_FEE_ITEM" + const val REGULAR_ITEM_ICON = "SEND_SELECT_NETWORK_FEE_REGULAR_ITEM_ICON" + const val REGULAR_ITEM_TITLE = "SEND_SELECT_NETWORK_FEE_REGULAR_ITEM_TITLE" + const val DOT_SIGN = "SEND_SELECT_NETWORK_FEE_DOT_SIGN" + const val TOKEN_AMOUNT = "SEND_SELECT_NETWORK_FEE_TOKEN_AMOUNT" + const val FIAT_AMOUNT = "SEND_SELECT_NETWORK_FEE_FIAT_AMOUNT" + + const val CUSTOM_FEE_ITEM = "SEND_SELECT_NETWORK_FEE_CUSTOM_FEE_ITEM" + const val CUSTOM_ITEM_ICON = "SEND_SELECT_NETWORK_FEE_CUSTOM_ITEM_ICON" + const val CUSTOM_ITEM_TITLE = "SEND_SELECT_NETWORK_FEE_CUSTOM_ITEM_TITLE" + + const val CUSTOM_INPUT_ITEM = "SEND_SELECT_NETWORK_FEE_CUSTOM_INPUT_ITEM" + const val NONCE_INPUT_ITEM = "SEND_SELECT_NETWORK_FEE_NONCE_INPUT_ITEM" + const val NONCE_INPUT_TEXT_FIELD = "SEND_SELECT_NETWORK_FEE_NONCE_INPUT_TEXT_FIELD" + const val CUSTOM_INPUT_ITEM_TITLE = "SEND_SELECT_NETWORK_FEE_CUSTOM_INPUT_ITEM_TITLE" + const val CUSTOM_INPUT_ITEM_TOOLTIP_ICON = "SEND_SELECT_NETWORK_FEE_CUSTOM_INPUT_ITEM_TOOLTIP_ICON" + const val CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD = "SEND_SELECT_NETWORK_FEE_CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD" + const val CUSTOM_INPUT_ITEM_FIAT_AMOUNT = "SEND_SELECT_NETWORK_FEE_CUSTOM_INPUT_ITEM_FIAT_AMOUNT" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SwapSelectNetworkFeeBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SwapSelectNetworkFeeBottomSheetTestTags.kt new file mode 100644 index 0000000000..f279e2998f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SwapSelectNetworkFeeBottomSheetTestTags.kt @@ -0,0 +1,6 @@ +package com.tangem.core.ui.test + +object SwapSelectNetworkFeeBottomSheetTestTags { + const val READ_MORE_TEXT = "SWAP_SELECT_NETWORK_FEE_READ_MORE_TEXT" + const val SELECTOR_ITEM = "SWAP_SELECT_NETWORK_FEE_SELECTOR_ITEM" +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt index daa164aa95..b115e87f71 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign @@ -45,6 +46,7 @@ import com.tangem.core.ui.format.bigdecimal.fee import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.SendSelectNetworkFeeBottomSheetTestTags import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.features.send.v2.api.entity.* import com.tangem.features.send.v2.api.params.FeeSelectorParams @@ -200,7 +202,9 @@ private fun CustomFeeBlock( ) { Column(modifier = modifier) { Row( - modifier = Modifier.padding(all = 12.dp), + modifier = Modifier + .padding(all = 12.dp) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_FEE_ITEM), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -208,7 +212,8 @@ private fun CustomFeeBlock( modifier = Modifier .size(36.dp) .background(color = iconBackgroundColor, shape = CircleShape) - .padding(6.dp), + .padding(6.dp) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_ICON), painter = painterResource(R.drawable.ic_edit_v2_24), tint = iconTint, contentDescription = null, @@ -217,6 +222,7 @@ private fun CustomFeeBlock( text = stringResourceSafe(R.string.common_custom), color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.subtitle2, + modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_TITLE), ) } AnimatedVisibility( @@ -289,7 +295,8 @@ private fun ExpandedCustomFeeItems( .background( color = TangemTheme.colors.background.primary, shape = TangemTheme.shapes.roundedCornersXMedium, - ), + ) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.NONCE_INPUT_ITEM), ) } } @@ -309,7 +316,9 @@ private fun RegularFeeItemContent( ) { Column(modifier = modifier) { Row( - modifier = Modifier.padding(all = 12.dp), + modifier = Modifier + .padding(all = 12.dp) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_FEE_ITEM), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -317,7 +326,8 @@ private fun RegularFeeItemContent( modifier = Modifier .size(36.dp) .background(color = iconBackgroundColor, shape = CircleShape) - .padding(6.dp), + .padding(6.dp) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_ICON), painter = painterResource(iconRes), tint = iconTint, contentDescription = null, @@ -352,6 +362,7 @@ private fun FeeDescription( text = title.resolveReference(), color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.subtitle2, + modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE), ) if (preDot != null) { FeeValueContent(preDot = preDot, postDot = postDot, ellipsizeOffset = ellipsizeOffset) @@ -375,6 +386,7 @@ private fun FeeValueContent(preDot: TextReference, postDot: TextReference?, elli color = textColor, textAlign = TextAlign.End, ellipsis = ellipsis, + modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.TOKEN_AMOUNT), ) if (postDot != null) { Text( @@ -382,9 +394,16 @@ private fun FeeValueContent(preDot: TextReference, postDot: TextReference?, elli style = textStyle, color = textColor, textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing4), + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing4) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.DOT_SIGN), + ) + Text( + text = postDot.resolveReference(), + style = textStyle, + color = textColor, + modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.FIAT_AMOUNT), ) - Text(text = postDot.resolveReference(), style = textStyle, color = textColor) } } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt index 067df047a5..3f03952ad3 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt @@ -20,7 +20,7 @@ import com.tangem.core.ui.components.rows.SelectorRowItem import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.SelectNetworkFeeBottomSheetTestTags +import com.tangem.core.ui.test.SwapSelectNetworkFeeBottomSheetTestTags import com.tangem.feature.swap.domain.models.ui.FeeType import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig import com.tangem.feature.swap.models.states.FeeItemState @@ -91,7 +91,7 @@ private fun FooterBlock(readMore: TextReference, onReadMoreClick: () -> Unit) { vertical = TangemTheme.dimens.spacing8, horizontal = TangemTheme.dimens.spacing16, ) - .testTag(SelectNetworkFeeBottomSheetTestTags.READ_MORE_TEXT), + .testTag(SwapSelectNetworkFeeBottomSheetTestTags.READ_MORE_TEXT), style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Start), onClick = click, ) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index e51eb53245..36965eb1e1 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-1327" +tangemBlockchainSdk = "develop-1330" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 9e0b8b3234cc76bbe6f37112b3968397d407e222 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 18 Dec 2025 13:27:15 +0500 Subject: [PATCH 003/172] Updated on 2026-08-14 --- .../modal/TangemModalBottomSheet.kt | 4 +- .../modal/TangemModalBottomSheetWithFooter.kt | 4 +- .../bottomsheets/sheet/TangemBottomSheet.kt | 3 +- .../ui/components/inputrow/InputRowEnter.kt | 2 +- .../inputrow/InputRowEnterInfoAmount.kt | 2 +- .../TangemBottomSheetScaffold.kt | 14 +- .../sheetscaffold/TangemSheetState.kt | 8 +- .../ui/components/tooltip/TangemTooltip.kt | 83 ++++----- .../com/tangem/core/ui/res/TangemTheme.kt | 6 + .../feeselector/ui/FeeSelectorBlockContent.kt | 3 +- .../providers/ui/BlockchainProvidersScreen.kt | 15 +- .../TangemSnapLayoutInfoProvider.kt | 3 +- .../wallet/ui/components/common/WalletCard.kt | 173 +++++++++--------- .../transaction/ui/common/WcAddressItem.kt | 3 +- .../transaction/ui/common/WcNetworkItem.kt | 3 +- .../transaction/ui/common/WcWalletItem.kt | 2 +- gradle/dependencies.toml | 8 +- 17 files changed, 165 insertions(+), 171 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt index 2d29c50afc..a05b76f771 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt @@ -16,7 +16,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -146,7 +145,8 @@ inline fun PreviewModalBottomSheet( sheetState = SheetState( skipPartiallyExpanded = skipPartiallyExpanded, initialValue = Expanded, - density = LocalDensity.current, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ), onBack = null, bsContent = { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt index 00646f4f2e..103aff8752 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -141,7 +140,8 @@ inline fun PreviewModalBottomSheetW sheetState = SheetState( skipPartiallyExpanded = skipPartiallyExpanded, initialValue = Expanded, - density = LocalDensity.current, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ), onBack = null, containerColor = containerColor, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt index 0a9f35bb67..18bb77bdf7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt @@ -134,7 +134,8 @@ inline fun PreviewBottomSheet( sheetState = SheetState( skipPartiallyExpanded = skipPartiallyExpanded, initialValue = Expanded, - density = LocalDensity.current, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ), onBack = null, containerColor = containerColor, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt index 194e55eb3e..50454a4aca 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt @@ -94,7 +94,7 @@ fun InputRowEnter( modifier = Modifier .size(16.dp) .clip(CircleShape), - text = description.resolveReference(), + text = description, content = { contentModifier -> Icon( modifier = contentModifier.size(16.dp), diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt index a805e7c638..f69d323c1e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt @@ -158,7 +158,7 @@ fun InputRowEnterInfoAmountV2( .size(16.dp) .clip(CircleShape) .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_TOOLTIP_ICON), - text = description.resolveReference(), + text = description, content = { contentModifier -> Icon( modifier = contentModifier.size(16.dp), diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt index ccab322580..7c07f422bc 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt @@ -239,19 +239,19 @@ private fun StandardBottomSheet( } val newTarget = when (val oldTarget = state.anchoredDraggableState.targetValue) { - Hidden -> if (newAnchors.hasAnchorFor(Hidden)) Hidden else oldTarget + Hidden -> if (newAnchors.hasPositionFor(Hidden)) Hidden else oldTarget PartiallyExpanded -> when { - newAnchors.hasAnchorFor(PartiallyExpanded) -> PartiallyExpanded - newAnchors.hasAnchorFor(Expanded) -> Expanded - newAnchors.hasAnchorFor(Hidden) -> Hidden + newAnchors.hasPositionFor(PartiallyExpanded) -> PartiallyExpanded + newAnchors.hasPositionFor(Expanded) -> Expanded + newAnchors.hasPositionFor(Hidden) -> Hidden else -> oldTarget } Expanded -> when { - newAnchors.hasAnchorFor(Expanded) -> Expanded - newAnchors.hasAnchorFor(PartiallyExpanded) -> PartiallyExpanded - newAnchors.hasAnchorFor(Hidden) -> Hidden + newAnchors.hasPositionFor(Expanded) -> Expanded + newAnchors.hasPositionFor(PartiallyExpanded) -> PartiallyExpanded + newAnchors.hasPositionFor(Hidden) -> Hidden else -> oldTarget } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemSheetState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemSheetState.kt index 17988b6b6f..0e684d04bb 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemSheetState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemSheetState.kt @@ -102,11 +102,11 @@ class TangemSheetState( /** Whether the sheet has an expanded state defined. */ val hasExpandedState: Boolean - get() = anchoredDraggableState.anchors.hasAnchorFor(Expanded) + get() = anchoredDraggableState.anchors.hasPositionFor(Expanded) /** Whether the modal bottom sheet has a partially expanded state defined. */ val hasPartiallyExpandedState: Boolean - get() = anchoredDraggableState.anchors.hasAnchorFor(PartiallyExpanded) + get() = anchoredDraggableState.anchors.hasPositionFor(PartiallyExpanded) /** * Fully expand the bottom sheet with animation and suspend until it is fully expanded or @@ -274,8 +274,8 @@ internal fun consumeSwipeWithinBottomSheetBoundsNestedScrollConnection( override suspend fun onPreFling(available: Velocity): Velocity { val toFling = available.toFloat() val currentOffset = sheetState.requireOffset() - val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor() - return if (toFling < 0 && currentOffset > minAnchor) { + val minPosition = sheetState.anchoredDraggableState.anchors.minPosition() + return if (toFling < 0 && currentOffset > minPosition) { onFling(toFling) // since we go to the anchor with tween settling, consume all for the best UX available diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/tooltip/TangemTooltip.kt b/core/ui/src/main/java/com/tangem/core/ui/components/tooltip/TangemTooltip.kt index 7a54751d4b..02fa41588f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/tooltip/TangemTooltip.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/tooltip/TangemTooltip.kt @@ -1,29 +1,39 @@ package com.tangem.core.ui.components.tooltip +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import kotlinx.coroutines.launch +/** + * A Tangem-themed tooltip component that displays a tooltip with the provided text when the content is clicked. + * + * @param text The text to be displayed inside the tooltip. + * @param modifier The modifier to be applied to the tooltip component. + * @param enabled If false, the tooltip will not be shown when the content is clicked. + * @param content The content that triggers the tooltip when clicked. + */ @Composable fun TangemTooltip( - text: String, + text: TextReference, modifier: Modifier = Modifier, enabled: Boolean = true, content: @Composable (Modifier) -> Unit, @@ -33,30 +43,10 @@ fun TangemTooltip( enabled = enabled, tooltipContent = { Text( - modifier = Modifier.background(TangemTheme.colors.icon.secondary), - text = text, - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.primary2, - ) - }, - content = content, - ) -} - -@Composable -fun TangemTooltip( - text: AnnotatedString, - modifier: Modifier = Modifier, - enabled: Boolean = true, - content: @Composable (Modifier) -> Unit, -) { - InternalTangemTooltip( - modifier = modifier, - enabled = enabled, - tooltipContent = { - Text( - modifier = Modifier.background(TangemTheme.colors.icon.secondary), - text = text, + modifier = Modifier + .background(TangemTheme.colors.icon.secondary) + .padding(horizontal = 6.dp, vertical = 8.dp), + text = text.resolveAnnotatedReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.primary2, ) @@ -75,16 +65,22 @@ private fun InternalTangemTooltip( ) { val tooltipState = rememberTooltipState(isPersistent = true) val coroutineScope = rememberCoroutineScope() + + val windowSize = LocalWindowSize.current.width + TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(spacingBetweenTooltipAndAnchor = 8.dp), + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + positioning = TooltipAnchorPosition.Above, + spacingBetweenTooltipAndAnchor = 8.dp, + ), state = tooltipState, modifier = modifier, tooltip = { PlainTooltip( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), - caretSize = DpSize(width = 14.dp, height = 8.dp), + modifier = Modifier.padding(end = 12.dp), + shape = RoundedCornerShape(14.dp), + caretShape = TooltipDefaults.caretShape(), + maxWidth = windowSize - 24.dp, contentColor = TangemTheme.colors.text.primary2, containerColor = TangemTheme.colors.icon.secondary, content = { tooltipContent() }, @@ -103,24 +99,23 @@ private fun InternalTangemTooltip( ) } -@Preview +// region Preview @Composable +@Preview(showBackground = true, widthDp = 360, heightDp = 720) +@Preview(showBackground = true, widthDp = 360, heightDp = 720, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun TangemTooltip_Preview() { TangemThemePreview { Box( modifier = Modifier - .size(500.dp) - .background(TangemTheme.colors.background.secondary), - contentAlignment = Alignment.Center, + .fillMaxSize() + .background(TangemTheme.colors.background.tertiary), ) { TangemTooltip( - modifier = Modifier - .background(TangemTheme.colors.background.secondary) - .size(64.dp), - text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed venenatis.", + modifier = Modifier, + text = stringReference("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed venenatis."), content = { contentModifier -> Icon( - modifier = contentModifier.size(64.dp), + modifier = contentModifier, painter = painterResource(R.drawable.ic_token_info_24), tint = TangemTheme.colors.icon.informative, contentDescription = null, diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index 85535f739b..38b1726319 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -1,6 +1,8 @@ package com.tangem.core.ui.res import android.app.Activity +import androidx.compose.foundation.ComposeFoundationFlags +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.foundation.text.selection.TextSelectionColors @@ -27,6 +29,7 @@ import com.tangem.core.ui.windowsize.rememberWindowSize import com.tangem.domain.apptheme.model.AppThemeMode import com.valentinilk.shimmer.Shimmer +@OptIn(ExperimentalFoundationApi::class) @Composable fun TangemTheme( activity: Activity, @@ -36,6 +39,9 @@ fun TangemTheme( overrideSystemBarColors: Boolean = true, content: @Composable () -> Unit, ) { + // TODO Research and implement in redesign [REDACTED_TASK_KEY] + ComposeFoundationFlags.isPausableCompositionInPrefetchEnabled = false + val appThemeMode by uiDependencies.appThemeModeHolder.appThemeMode val windowSize = rememberWindowSize(activity = activity) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt index 5eb28eda97..1b2d00762c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt @@ -29,6 +29,7 @@ import com.tangem.common.ui.amountScreen.utils.getFiatString import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.tooltip.TangemTooltip +import com.tangem.core.ui.extensions.annotatedReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.EMPTY_BALANCE_SIGN import com.tangem.core.ui.format.bigdecimal.crypto @@ -122,7 +123,7 @@ private fun FeeSelectorStaticPart(onReadMoreClick: () -> Unit, modifier: Modifie } } TangemTooltip( - text = annotatedString, + text = annotatedReference(annotatedString), modifier = Modifier .padding(start = TangemTheme.dimens.spacing6) .size(TangemTheme.dimens.size16) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/providers/ui/BlockchainProvidersScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/providers/ui/BlockchainProvidersScreen.kt index dfe5e893f2..744c5f95b3 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/providers/ui/BlockchainProvidersScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/providers/ui/BlockchainProvidersScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.* import androidx.compose.foundation.draganddrop.dragAndDropSource import androidx.compose.foundation.draganddrop.dragAndDropTarget -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -241,16 +240,10 @@ private fun ProviderItem(index: Int, state: ProviderUM, onDrop: (Int, Int) -> Un modifier = Modifier .background(color = color, shape = RoundedCornerShape(16.dp)) .padding(vertical = 4.dp, horizontal = 8.dp) - .dragAndDropSource { - detectTapGestures( - onLongPress = { - startTransfer( - DragAndDropTransferData( - clipData = ClipData.newPlainText("provider index", index.toString()), - flags = View.DRAG_FLAG_GLOBAL, - ), - ) - }, + .dragAndDropSource { _ -> + DragAndDropTransferData( + clipData = ClipData.newPlainText("provider index", index.toString()), + flags = View.DRAG_FLAG_GLOBAL, ) } .dragAndDropTarget( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapLayoutInfoProvider.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapLayoutInfoProvider.kt index 1d68cf0349..f7264f8210 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapLayoutInfoProvider.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapLayoutInfoProvider.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.core.calculateTargetValue import androidx.compose.animation.splineBasedDecay import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.snapping.SnapFlingBehavior import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.ui.unit.Density @@ -23,7 +22,7 @@ import kotlin.math.sign * This position should be considered with regard to the start edge of the item and the placement * within the viewport. * - * @return A [SnapLayoutInfoProvider] that can be used with [SnapFlingBehavior] + * @return A [SnapLayoutInfoProvider] that can be used with snap fling behavior */ @Suppress("FunctionNaming") @ExperimentalFoundationApi diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt index 02aaf086d1..a3cfa9ee44 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayoutScope import androidx.constraintlayout.compose.Dimension +import androidx.constraintlayout.compose.Visibility import com.tangem.core.ui.components.FontSizeRange import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.ResizableText @@ -69,89 +70,6 @@ private const val HALF_OF_ITEM_WIDTH = 0.5 @Suppress("LongMethod") @Composable internal fun WalletCard(state: WalletCardState, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { - @Suppress("DestructuringDeclarationWithTooManyEntries") - CardContainer( - dropDownItems = state.dropDownItems, - isLockedState = state is WalletCardState.LockedContent, - modifier = modifier, - ) { itemSize -> - val (titleRef, balanceRef, additionalTextRef, imageRef) = createRefs() - - val contentVerticalMargin = TangemTheme.dimens.spacing12 - TitleText( - text = state.title, - modifier = Modifier.constrainAs(titleRef) { - start.linkTo(parent.start) - top.linkTo(anchor = parent.top, margin = contentVerticalMargin) - end.linkTo(imageRef.start) - width = Dimension.fillToConstraints - }, - ) - - var balanceWidth by remember { mutableIntStateOf(value = Int.MIN_VALUE) } - Balance( - state = state, - isBalanceHidden = isBalanceHidden, - modifier = Modifier - .onSizeChanged { balanceWidth = it.width } - .padding(vertical = TangemTheme.dimens.spacing8) - .constrainAs(balanceRef) { - start.linkTo(parent.start) - top.linkTo(anchor = titleRef.bottom) - bottom.linkTo(anchor = additionalTextRef.top) - }, - ) - - val additionalText by remember(state.additionalInfo, isBalanceHidden) { - mutableStateOf( - state.additionalInfo?.content?.orMaskWithStars( - maskWithStars = state.additionalInfo?.hideable == true && isBalanceHidden, - ), - ) - } - AdditionalInfo( - text = additionalText, - modifier = Modifier.constrainAs(additionalTextRef) { - start.linkTo(parent.start) - top.linkTo(balanceRef.bottom) - bottom.linkTo(anchor = parent.bottom, margin = contentVerticalMargin) - - if (additionalText != null) { - width = if (state.imageResId != null) { - end.linkTo(imageRef.start) - Dimension.fillToConstraints - } else { - Dimension.wrapContent - } - } - }, - ) - - // If balance has a large width then image must be hidden - val hasSpaceForImage by remember(key1 = balanceWidth, key2 = itemSize.width) { - mutableStateOf(value = balanceWidth < itemSize.width * HALF_OF_ITEM_WIDTH) - } - - if (hasSpaceForImage) { - Image( - id = state.imageResId, - modifier = Modifier.constrainAs(imageRef) { - end.linkTo(parent.end) - bottom.linkTo(parent.bottom) - height = Dimension.fillToConstraints - }, - ) - } - } -} - -@Composable -private fun CardContainer( - dropDownItems: ImmutableList, - isLockedState: Boolean, - modifier: Modifier = Modifier, - content: @Composable (ConstraintLayoutScope.(IntSize) -> Unit), -) { var isMenuVisible by rememberSaveable { mutableStateOf(value = false) } var pressOffset by remember { mutableStateOf(value = DpOffset.Zero) } var itemSize by remember { mutableStateOf(value = IntSize.Zero) } @@ -166,7 +84,7 @@ private fun CardContainer( .onSizeChanged { itemSize = it } .testTag(MainScreenTestTags.TOTAL_BALANCE_CONTAINER) .then( - if (isLockedState || dropDownItems.isEmpty()) { + if (state is WalletCardState.LockedContent || state.dropDownItems.isEmpty()) { Modifier } else { Modifier @@ -189,15 +107,19 @@ private fun CardContainer( } }, ), - shape = TangemTheme.shapes.roundedCornersXMedium, - color = TangemTheme.colors.background.primary, ) { ConstraintLayout( modifier = Modifier .fillMaxWidth() + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.primary) .padding(horizontal = TangemTheme.dimens.spacing12), ) { - content(itemSize) + CardContainer( + state = state, + isBalanceHidden = isBalanceHidden, + itemSize = itemSize, + ) } } @@ -209,7 +131,82 @@ private fun CardContainer( pressOffset = pressOffset, itemHeight = itemHeight, onDismissRequest = { isMenuVisible = false }, - dropDownItems = dropDownItems, + dropDownItems = state.dropDownItems, + ) +} + +@Suppress("DestructuringDeclarationWithTooManyEntries") +@Composable +private fun ConstraintLayoutScope.CardContainer(state: WalletCardState, isBalanceHidden: Boolean, itemSize: IntSize) { + val (titleRef, balanceRef, additionalTextRef, imageRef) = createRefs() + + val contentVerticalMargin = TangemTheme.dimens.spacing12 + TitleText( + text = state.title, + modifier = Modifier.constrainAs(titleRef) { + start.linkTo(anchor = parent.start) + top.linkTo(anchor = parent.top, margin = contentVerticalMargin) + end.linkTo(anchor = imageRef.start) + width = Dimension.fillToConstraints + }, + ) + + var balanceWidth by remember { mutableIntStateOf(value = Int.MIN_VALUE) } + Balance( + state = state, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .onSizeChanged { balanceWidth = it.width } + .padding(vertical = TangemTheme.dimens.spacing8) + .constrainAs(balanceRef) { + start.linkTo(anchor = parent.start) + top.linkTo(anchor = titleRef.bottom) + bottom.linkTo(anchor = additionalTextRef.top) + }, + ) + + val additionalText by remember(state.additionalInfo, isBalanceHidden) { + mutableStateOf( + state.additionalInfo?.content?.orMaskWithStars( + maskWithStars = state.additionalInfo?.hideable == true && isBalanceHidden, + ), + ) + } + AdditionalInfo( + text = additionalText, + modifier = Modifier.constrainAs(additionalTextRef) { + start.linkTo(parent.start) + top.linkTo(balanceRef.bottom) + bottom.linkTo(anchor = parent.bottom, margin = contentVerticalMargin) + + if (additionalText != null) { + width = if (state.imageResId != null) { + end.linkTo(imageRef.start) + Dimension.fillToConstraints + } else { + Dimension.wrapContent + } + } + }, + ) + + // If balance has a large width then image must be hidden + val hasSpaceForImage by remember(key1 = balanceWidth, key2 = itemSize.width) { + mutableStateOf(value = balanceWidth < itemSize.width * HALF_OF_ITEM_WIDTH) + } + + Image( + id = state.imageResId, + modifier = Modifier.constrainAs(imageRef) { + end.linkTo(parent.end) + bottom.linkTo(parent.bottom) + height = Dimension.fillToConstraints + visibility = if (hasSpaceForImage) { + Visibility.Visible + } else { + Visibility.Gone + } + }, ) } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcAddressItem.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcAddressItem.kt index b8c0eb3057..d30f9cf14c 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcAddressItem.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcAddressItem.kt @@ -14,6 +14,7 @@ import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.components.tooltip.TangemTooltip +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.features.walletconnect.impl.R @@ -38,7 +39,7 @@ internal fun WcAddressItem(address: String, modifier: Modifier = Modifier) { SpacerWMax() TangemTooltip( modifier = Modifier.padding(start = TangemTheme.dimens.spacing16), - text = address, + text = stringReference(address), enabled = isTooltipEnabled, content = { contentModifier -> EllipsisText( diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcNetworkItem.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcNetworkItem.kt index bc1a75f3fa..411f3620a6 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcNetworkItem.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcNetworkItem.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import com.tangem.core.ui.components.tooltip.TangemTooltip +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.features.walletconnect.impl.R @@ -44,7 +45,7 @@ internal fun WcNetworkItem(networkInfo: WcNetworkInfoUM, modifier: Modifier = Mo ) TangemTooltip( modifier = Modifier.padding(start = TangemTheme.dimens.spacing16), - text = networkInfo.name, + text = stringReference(networkInfo.name), enabled = isTooltipEnabled, content = { contentModifier -> Text( diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcWalletItem.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcWalletItem.kt index c8251d3242..9d17b822ba 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcWalletItem.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcWalletItem.kt @@ -52,7 +52,7 @@ internal fun WcPortfolioItem(portfolioName: AccountTitleUM, modifier: Modifier = ) is AccountTitleUM.Text -> TangemTooltip( modifier = Modifier.padding(start = TangemTheme.dimens.spacing16), - text = portfolioName.title.resolveReference(), + text = portfolioName.title, enabled = isTooltipEnabled, content = { contentModifier -> Text( diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 695f7f7021..6acac86f00 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -33,10 +33,10 @@ androidxWorkManager = "2.9.0" # endregion AndroidX # region Compose -compose-runtime = "1.7.8" -compose-foundation = "1.7.8" -compose-material3 = "1.3.1" -compose-constraint = "1.0.1" +compose-runtime = "1.10.0" +compose-foundation = "1.10.0" +compose-material3 = "1.4.0" +compose-constraint = "1.1.1" compose-navigation = "2.7.7" compose-accompanist = "0.30.1" compose-paging = "3.2.1" From b449775ad85d3b3f595e13cba462ddc3e4cb655f Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 18 Dec 2025 10:31:03 +0200 Subject: [PATCH 004/172] Updated on 2026-08-14 --- .../tap/di/domain/StakingDomainModule.kt | 7 +- .../auth/DefaultP2PEthPoolAuthProvider.kt | 4 +- .../tangem/tap/routing/utils/ChildFactory.kt | 2 +- common/routing/build.gradle.kts | 2 +- .../com/tangem/common/routing/AppRoute.kt | 5 +- .../MockP2PEthPoolAccountResponseFactory.kt | 2 +- .../ui/tokens/TokenItemStateConverter.kt | 60 +++---- .../api/common/config/P2PEthPool.kt | 4 +- .../response/P2PEthPoolBroadcastResponse.kt | 2 +- .../response/P2PEthPoolErrorResponse.kt | 4 +- .../models/response/P2PEthPoolResponse.kt | 2 +- .../response/P2PEthPoolVaultsResponse.kt | 2 +- .../datasource/di/StakingStoreModule.kt | 4 +- .../local/token/P2PEthPoolVaultsStore.kt | 4 +- .../managers/ProdApiConfigsManagerTest.kt | 4 +- .../staking/DefaultP2PEthPoolRepository.kt | 32 ++-- .../data/staking/DefaultStakeKitRepository.kt | 6 +- .../staking/DefaultStakingErrorResolver.kt | 4 +- .../data/staking/DefaultStakingRepository.kt | 6 +- .../data/staking/converters/YieldConverter.kt | 18 +- .../ethpool/P2PEthPoolAccountConverter.kt | 2 +- .../P2PEthPoolBroadcastResultConverter.kt | 2 +- .../ethpool/P2PEthPoolErrorConverter.kt | 2 +- .../ethpool/P2PEthPoolRewardConverter.kt | 2 +- ...t => P2PEthPoolStakingBalanceConverter.kt} | 24 +-- .../ethpool/P2PEthPoolUnsignedTxConverter.kt | 2 +- .../ethpool/P2PEthPoolVaultConverter.kt | 2 +- .../ethpool/P2PYieldBalanceConverter.kt | 92 ---------- .../di/StakingBalanceSupplierModule.kt | 10 +- .../data/staking/di/StakingDataModule.kt | 4 +- .../DefaultMultiStakingBalanceFetcher.kt | 165 ++++++++++-------- .../DefaultMultiStakingBalanceProducer.kt | 14 +- ...e.kt => DefaultP2PEthPoolBalancesStore.kt} | 20 +-- ...cesStore.kt => P2PEthPoolBalancesStore.kt} | 4 +- .../tangem/data/staking/StakingBalanceExt.kt | 8 +- .../DefaultMultiStakingBalanceFetcherTest.kt | 14 +- .../DefaultMultiStakingBalanceProducerTest.kt | 56 +++--- ...Account.kt => P2PEthPoolStakingAccount.kt} | 16 +- .../domain/models/staking/StakingBalance.kt | 8 +- .../domain/models/staking/YieldToken.kt | 15 +- domain/staking/build.gradle.kts | 2 - .../domain/staking/model/StakingTarget.kt | 66 +++++++ .../domain/staking/model/common/RewardInfo.kt | 10 ++ .../domain/staking/model/common/RewardType.kt | 10 ++ .../model/ethpool/P2PEthPoolAccount.kt | 2 +- .../staking/model/ethpool/P2PEthPoolAction.kt | 6 +- .../model/ethpool/P2PEthPoolBalance.kt | 6 +- .../model/ethpool/P2PEthPoolNetwork.kt | 2 +- .../staking/model/ethpool/P2PEthPoolReward.kt | 2 +- .../model/ethpool/P2PEthPoolStaking.kt | 2 +- ...ngConfig.kt => P2PEthPoolStakingConfig.kt} | 4 +- .../model/ethpool/P2PEthPoolUnsignedTx.kt | 2 +- .../domain/staking/model/stakekit/Yield.kt | 14 +- .../staking/FetchStakingOptionsUseCase.kt | 6 +- .../staking/GetStakingEntryInfoUseCase.kt | 2 +- .../staking/model/P2PEthPoolIntegration.kt | 62 +++++++ .../staking/model/StakeKitIntegration.kt | 60 +++++++ .../staking/model/StakingAvailability.kt | 0 .../staking/model/StakingIntegration.kt | 56 ++++++ .../staking/model/StakingIntegrationID.kt | 29 +-- .../domain/staking/model/StakingOption.kt | 34 ++-- .../repositories/P2PEthPoolRepository.kt | 40 +++-- .../repositories/StakeKitRepository.kt | 13 +- .../staking/usecase/StakingApyFlowUseCase.kt | 47 +++-- .../domain/staking/utils/StakingBalanceExt.kt | 8 +- .../domain/staking/StakingIdFactoryTest.kt | 4 +- .../staking/StakingIntegrationIDTest.kt | 15 +- .../operations/CryptoCurrencyStatusFactory.kt | 2 +- features/markets/impl/build.gradle.kts | 1 + .../impl/model/TokenActionsHandler.kt | 2 +- features/staking/api/build.gradle.kts | 2 +- .../features/staking/api/StakingComponent.kt | 3 +- .../analytics/utils/StakingAnalyticSender.kt | 14 +- .../deeplink/DefaultStakingDeepLinkHandler.kt | 17 +- .../presentation/model/StakingClickIntents.kt | 4 +- .../impl/presentation/model/StakingModel.kt | 86 +++++---- .../state/InnerYieldBalanceState.kt | 6 +- .../impl/presentation/state/StakingUiState.kt | 8 +- .../state/converters/BalanceItemConverter.kt | 16 +- .../RewardsValidatorStateConverter.kt | 15 +- .../converters/YieldBalancesConverter.kt | 6 +- .../state/helpers/StakingBalanceUpdater.kt | 8 +- .../helpers/StakingFeeTransactionLoader.kt | 17 +- .../state/helpers/StakingTransactionSender.kt | 15 +- .../previewdata/InitialStakingStatePreview.kt | 7 +- .../previewdata/ValidatorStatePreviewData.kt | 24 ++- .../state/stub/StakingClickIntentsStub.kt | 4 +- .../SetConfirmationStateInitTransformer.kt | 12 +- .../SetConfirmationStateLoadingTransformer.kt | 6 +- .../SetInitialDataStateTransformer.kt | 51 +++--- .../amount/AmountChangeStateTransformer.kt | 6 +- .../amount/AmountMaxValueStateTransformer.kt | 6 +- .../AmountRequirementStateTransformer.kt | 7 +- .../ShowApprovalBottomSheetTransformer.kt | 4 +- .../AddStakingNotificationsTransformer.kt | 8 +- .../StakingInfoNotificationsFactory.kt | 26 +-- .../ValidatorSelectChangeTransformer.kt | 29 +-- .../state/utils/StakingRewardsUtils.kt | 13 +- .../ui/StakingClaimRewardsValidatorContent.kt | 16 +- .../ui/StakingInitialInfoContent.kt | 12 +- .../ui/StakingValidatorListContent.kt | 31 ++-- .../presentation/ui/block/ValidatorBlock.kt | 17 +- .../router/DefaultTokenDetailsRouter.kt | 9 +- .../router/InnerTokenDetailsRouter.kt | 3 +- .../tokendetails/model/TokenDetailsModel.kt | 30 ++-- .../TokenDetailsStakingInfoConverter.kt | 6 +- .../WalletCurrencyActionsClickIntents.kt | 4 +- .../transformers/SetTokenListTransformer.kt | 4 +- .../converter/TokenListStateConverter.kt | 4 +- .../subscribers/AccountListSubscriber.kt | 4 +- .../subscribers/BasicAccountListSubscriber.kt | 8 +- .../subscribers/BasicTokenListSubscriber.kt | 6 +- 112 files changed, 979 insertions(+), 740 deletions(-) rename data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/{P2PStakingBalanceConverter.kt => P2PEthPoolStakingBalanceConverter.kt} (76%) delete mode 100644 data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PYieldBalanceConverter.kt rename data/staking/src/main/java/com/tangem/data/staking/store/{DefaultP2PBalancesStore.kt => DefaultP2PEthPoolBalancesStore.kt} (91%) rename data/staking/src/main/java/com/tangem/data/staking/store/{P2PBalancesStore.kt => P2PEthPoolBalancesStore.kt} (92%) rename domain/models/src/main/kotlin/com/tangem/domain/models/staking/{P2PStakingAccount.kt => P2PEthPoolStakingAccount.kt} (70%) create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingTarget.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardInfo.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardType.kt rename domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/{P2PStakingConfig.kt => P2PEthPoolStakingConfig.kt} (76%) create mode 100644 domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt create mode 100644 domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt rename domain/staking/{models/src/main/kotlin => src/main/java}/com/tangem/domain/staking/model/StakingAvailability.kt (100%) create mode 100644 domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt rename domain/staking/{models/src/main/kotlin => src/main/java}/com/tangem/domain/staking/model/StakingOption.kt (53%) diff --git a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt index 0280493a9a..fcf1233a1b 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt @@ -100,12 +100,12 @@ internal object StakingDomainModule { @Singleton fun provideFetchStakingOptionsUseCase( stakeKitRepository: StakeKitRepository, - p2pRepository: P2PEthPoolRepository, + p2pEthPoolRepository: P2PEthPoolRepository, stakingErrorResolver: StakingErrorResolver, ): FetchStakingOptionsUseCase { return FetchStakingOptionsUseCase( stakeKitRepository = stakeKitRepository, - p2pRepository = p2pRepository, + p2pEthPoolRepository = p2pEthPoolRepository, stakingErrorResolver = stakingErrorResolver, ) } @@ -240,8 +240,9 @@ internal object StakingDomainModule { @Singleton fun provideStakingApyFlowUseCase( stakeKitRepository: StakeKitRepository, + p2pEthPoolRepository: P2PEthPoolRepository, stakingFeatureToggles: StakingFeatureToggles, ): StakingApyFlowUseCase { - return StakingApyFlowUseCase(stakeKitRepository, stakingFeatureToggles) + return StakingApyFlowUseCase(stakeKitRepository, p2pEthPoolRepository, stakingFeatureToggles) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/auth/DefaultP2PEthPoolAuthProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultP2PEthPoolAuthProvider.kt index 5f54236082..93b0595647 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/DefaultP2PEthPoolAuthProvider.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/DefaultP2PEthPoolAuthProvider.kt @@ -1,7 +1,7 @@ package com.tangem.tap.network.auth import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage -import com.tangem.domain.staking.model.ethpool.P2PStakingConfig +import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig import com.tangem.lib.auth.P2PEthPoolAuthProvider internal class DefaultP2PEthPoolAuthProvider( @@ -12,6 +12,6 @@ internal class DefaultP2PEthPoolAuthProvider( val keys = environmentConfigStorage.getConfigSync().p2pApiKey ?: error("No P2P api keys provided") - return if (P2PStakingConfig.USE_TESTNET) keys.hoodi else keys.mainnet + return if (P2PEthPoolStakingConfig.USE_TESTNET) keys.hoodi else keys.mainnet } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 715bf51463..99c8e414b9 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -311,7 +311,7 @@ internal class ChildFactory @Inject constructor( params = StakingComponent.Params( userWalletId = route.userWalletId, cryptoCurrency = route.cryptoCurrency, - yieldId = route.yieldId, + integrationId = route.integrationId, ), componentFactory = stakingComponentFactory, ) diff --git a/common/routing/build.gradle.kts b/common/routing/build.gradle.kts index 9fe3f2a5e4..b34c827c74 100644 --- a/common/routing/build.gradle.kts +++ b/common/routing/build.gradle.kts @@ -19,7 +19,7 @@ dependencies { implementation(projects.domain.models) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) - implementation(projects.domain.staking.models) + implementation(projects.domain.staking) implementation(projects.domain.markets.models) implementation(projects.domain.onramp.models) implementation(projects.domain.appCurrency.models) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index c2d8b480d7..0c771f5609 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -7,6 +7,7 @@ import android.os.Bundle import com.tangem.common.routing.bundle.RouteBundleParams import com.tangem.common.routing.bundle.bundle import com.tangem.common.routing.entity.InitScreenLaunchMode +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.core.decompose.navigation.Route import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.feedback.models.WalletMetaInfo @@ -208,8 +209,8 @@ sealed class AppRoute(val path: String) : Route { data class Staking( val userWalletId: UserWalletId, val cryptoCurrency: CryptoCurrency, - val yieldId: String, - ) : AppRoute(path = "/staking/${userWalletId.stringValue}/${cryptoCurrency.id.value}/$yieldId") + val integrationId: StakingIntegrationID, + ) : AppRoute(path = "/staking/${userWalletId.stringValue}/${cryptoCurrency.id.value}/${integrationId.value}") @Serializable data class PushNotification( diff --git a/common/test/src/main/java/com/tangem/common/test/data/staking/MockP2PEthPoolAccountResponseFactory.kt b/common/test/src/main/java/com/tangem/common/test/data/staking/MockP2PEthPoolAccountResponseFactory.kt index 1e814c5d9f..2eafba7703 100644 --- a/common/test/src/main/java/com/tangem/common/test/data/staking/MockP2PEthPoolAccountResponseFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/data/staking/MockP2PEthPoolAccountResponseFactory.kt @@ -8,7 +8,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import java.math.BigDecimal /** - * Factory for creating mock P2P ETH Pool account responses for testing + * Factory for creating mock P2PEthPool account responses for testing */ object MockP2PEthPoolAccountResponseFactory { diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt index 8408463879..f572961610 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt @@ -22,8 +22,10 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.staking.model.StakingTarget import com.tangem.domain.staking.model.isStakingSupported -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.common.RewardInfo +import com.tangem.domain.staking.model.common.RewardType import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.StringsSigns.DASH_SIGN @@ -44,7 +46,7 @@ import java.math.BigDecimal class TokenItemStateConverter( private val appCurrency: AppCurrency, private val yieldModuleApyMap: Map = emptyMap(), - private val stakingApyMap: Map> = emptyMap(), + private val stakingApyMap: Map> = emptyMap(), private val yieldSupplyPromoBannerKey: String? = null, private val iconStateProvider: (CryptoCurrencyStatus) -> CurrencyIconState = { CryptoCurrencyToIconStateConverter().convert(it) @@ -177,7 +179,7 @@ class TokenItemStateConverter( private fun createTitleState( currencyStatus: CryptoCurrencyStatus, yieldModuleApyMap: Map, - stakingApyMap: Map>, + stakingApyMap: Map>, onApyLabelClick: ((CryptoCurrencyStatus, ApySource, String) -> Unit)?, ): TokenItemState.TitleState { return when (val value = currencyStatus.value) { @@ -217,7 +219,7 @@ class TokenItemStateConverter( private fun resolveEarnApy( cryptoCurrencyStatus: CryptoCurrencyStatus, yieldModuleApyMap: Map, - stakingApyMap: Map>, + stakingApyMap: Map>, ): EarnApyInfo? { val token = cryptoCurrencyStatus.currency as? CryptoCurrency.Token if (token != null && yieldModuleApyMap.isNotEmpty()) { @@ -247,9 +249,9 @@ class TokenItemStateConverter( stakingApyMap = stakingApyMap, ) val rewardTypeRes = when (stakingInfo.rewardType) { - Yield.RewardType.APR -> R.string.staking_apr_earn_badge - Yield.RewardType.UNKNOWN, - Yield.RewardType.APY, + RewardType.APR -> R.string.staking_apr_earn_badge + RewardType.UNKNOWN, + RewardType.APY, null, -> R.string.yield_module_earn_badge } @@ -272,49 +274,35 @@ class TokenItemStateConverter( private fun findStakingRate( currencyStatus: CryptoCurrencyStatus, - stakingApyMap: Map>, + stakingApyMap: Map>, ): StakingLocalInfo { val stakingKey = currencyStatus.currency.stakingKey() - val validators = stakingApyMap[stakingKey] + val targets = stakingApyMap[stakingKey] ?: return StakingLocalInfo(rate = null, isActive = false, rewardType = null) val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit - val rateInfo: Pair? = if (stakeKitBalance != null) { + val rewardInfo: RewardInfo? = if (stakeKitBalance != null) { // StakeKit-specific: try to find rate from validator address - val validatorsByAddress = validators.associateBy { it.address } + val targetsByAddress = targets.associateBy { it.address } stakeKitBalance.balance.items .mapNotNull { it.validatorAddress } - .firstNotNullOfOrNull { address -> - val validator = validatorsByAddress[address] - validator?.rewardInfo?.rate?.let { rate -> - rate to validator.rewardInfo?.type - } - } - ?: validators - .filter { it.preferred } - .mapNotNull { validator -> - validator.rewardInfo?.rate?.let { rate -> rate to validator.rewardInfo?.type } - } - .maxByOrNull { it.first } + .firstNotNullOfOrNull { address -> targetsByAddress[address]?.rewardInfo } + ?: targets + .filter { it.isPreferred } + .mapNotNull { it.rewardInfo } + .maxByOrNull { it.rate } } else { - // P2P or no balance: use preferred validators - // TODO p2p - validators - .filter { it.preferred } - .mapNotNull { validator -> - validator.rewardInfo?.rate?.let { rate -> - rate to validator.rewardInfo?.type - } - } - .maxByOrNull { it.first } + targets + .mapNotNull { it.rewardInfo } + .maxByOrNull { it.rate } } return StakingLocalInfo( - rate = rateInfo?.first, + rate = rewardInfo?.rate, isActive = stakingBalance != null, - rewardType = rateInfo?.second, + rewardType = rewardInfo?.type, ) } @@ -470,7 +458,7 @@ class TokenItemStateConverter( private data class StakingLocalInfo( val rate: BigDecimal?, val isActive: Boolean, - val rewardType: Yield.RewardType?, + val rewardType: RewardType?, ) private data class EarnApyInfo( diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/P2PEthPool.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/P2PEthPool.kt index aa1b28c629..0dbc3bb4bb 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/P2PEthPool.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/P2PEthPool.kt @@ -1,7 +1,7 @@ package com.tangem.datasource.api.common.config import com.tangem.datasource.BuildConfig -import com.tangem.domain.staking.model.ethpool.P2PStakingConfig +import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig import com.tangem.lib.auth.P2PEthPoolAuthProvider import com.tangem.utils.ProviderSuspend @@ -23,7 +23,7 @@ internal class P2PEthPool( private fun getInitialEnvironment(): ApiEnvironment { return when (BuildConfig.BUILD_TYPE) { MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK - else -> if (P2PStakingConfig.USE_TESTNET) ApiEnvironment.DEV else ApiEnvironment.PROD + else -> if (P2PEthPoolStakingConfig.USE_TESTNET) ApiEnvironment.DEV else ApiEnvironment.PROD } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolBroadcastResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolBroadcastResponse.kt index 20ec7b67f8..1970814c8c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolBroadcastResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolBroadcastResponse.kt @@ -29,7 +29,7 @@ data class P2PEthPoolBroadcastResponse( ) /** - * Transaction status from P2P API + * Transaction status from P2PEthPool API */ @JsonClass(generateAdapter = false) enum class P2PEthPoolTxStatusDTO { 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 88235092a1..c99f726d91 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 @@ -4,9 +4,9 @@ import com.squareup.moshi.Json import com.squareup.moshi.JsonClass /** - * Error response structure for P2P.org API + * Error response structure for P2P.org eth pooled API * - * All P2P API endpoints return errors in this format + * All P2PEthPool API endpoints return errors in this format */ @JsonClass(generateAdapter = true) data class P2PEthPoolErrorResponse( diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolResponse.kt index e9fe22fd51..d871ebbea8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolResponse.kt @@ -6,7 +6,7 @@ import com.squareup.moshi.JsonClass /** * Unified response wrapper for all P2P.org API responses * - * All P2P API endpoints return responses in this format: + * All P2PEthPool API endpoints return responses in this format: * ```json * { * "error": null | { code, message, name, errors }, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt index 5c825c6346..1e3bde7a5a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt @@ -15,7 +15,7 @@ data class P2PEthPoolVaultsResponse( ) /** - * Network identifier in P2P API + * Network identifier in P2PEthPool API */ @JsonClass(generateAdapter = false) enum class P2PEthPoolNetworkDTO { diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt index aeb4d1b8d9..d4bd6b3e65 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt @@ -80,7 +80,7 @@ internal object StakingStoreModule { @Provides @Singleton - fun provideP2PBalancesPersistenceStore( + fun provideP2PEthPoolBalancesPersistenceStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, @@ -91,7 +91,7 @@ internal object StakingStoreModule { types = mapWithStringKeyTypes(valueTypes = setTypes()), defaultValue = emptyMap(), ), - produceFile = { context.dataStoreFile(fileName = "p2p_balances") }, + produceFile = { context.dataStoreFile(fileName = "p2p_eth_pool_balances") }, scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/P2PEthPoolVaultsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/P2PEthPoolVaultsStore.kt index a99eba1730..e8e507ff08 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/P2PEthPoolVaultsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/P2PEthPoolVaultsStore.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.Flow * (similar to StakingYieldsStore for StakeKit yields) * * Vault is ETH-specific concept for pooled staking. - * For other blockchains, P2P may use different structures. + * For other blockchains, P2PEthPool may use different structures. */ interface P2PEthPoolVaultsStore { @@ -23,7 +23,7 @@ interface P2PEthPoolVaultsStore { suspend fun getSync(): List /** - * Store vaults from P2P API + * Store vaults from P2PEthPool API */ suspend fun store(vaults: List) } \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index e176d0b768..29e697fc8a 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -12,7 +12,7 @@ import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_ import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD_TYPE import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.BLOCK_AID_API_KEY import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_API_KEY -import com.tangem.domain.staking.model.ethpool.P2PStakingConfig +import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.lib.auth.P2PEthPoolAuthProvider import com.tangem.lib.auth.StakeKitAuthProvider @@ -300,7 +300,7 @@ internal class ProdApiConfigsManagerTest { } private fun createP2PModel(): TestModel { - val (environment, baseUrl) = if (P2PStakingConfig.USE_TESTNET) { + val (environment, baseUrl) = if (P2PEthPoolStakingConfig.USE_TESTNET) { ApiEnvironment.DEV to "https://api-test.p2p.org/" } else { ApiEnvironment.PROD to "https://api.p2p.org/" diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt index 85f3038652..33ea700fb0 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -25,10 +25,10 @@ import kotlinx.coroutines.withContext import timber.log.Timber /** - * P2P staking repository implementation + * P2PEthPool staking repository implementation */ internal class DefaultP2PEthPoolRepository( - private val p2pApi: P2PEthPoolApi, + private val p2pEthPoolApi: P2PEthPoolApi, private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore, private val dispatchers: CoroutineDispatcherProvider, ) : P2PEthPoolRepository { @@ -41,7 +41,7 @@ internal class DefaultP2PEthPoolRepository( override suspend fun fetchVaults(network: P2PEthPoolNetwork) { val vaults = getVaults(network).getOrElse { error -> - Timber.e("Error fetching P2P vaults: $error") + Timber.e("Error fetching P2PEthPool vaults: $error") emptyList() } p2pEthPoolVaultsStore.store(vaults) @@ -49,7 +49,7 @@ internal class DefaultP2PEthPoolRepository( override suspend fun getVaults(network: P2PEthPoolNetwork): Either> = either { withContext(dispatchers.io) { - val response = p2pApi.getVaults(network.value) + val response = p2pEthPoolApi.getVaults(network.value) when (response) { is ApiResponse.Success -> { val data = response.data @@ -76,7 +76,7 @@ internal class DefaultP2PEthPoolRepository( vaultAddress = vaultAddress, amount = amount.toDoubleOrNull() ?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")), ) - val response = p2pApi.createDepositTransaction(network.value, requestBody) + val response = p2pEthPoolApi.createDepositTransaction(network.value, requestBody) when (response) { is ApiResponse.Success -> { val data = response.data @@ -101,7 +101,7 @@ internal class DefaultP2PEthPoolRepository( stakerPublicKey = stakerPublicKey, stakeTransactionHash = stakeTransactionHash, ) - val response = p2pApi.createUnstakeTransaction(network.value, requestBody) + val response = p2pEthPoolApi.createUnstakeTransaction(network.value, requestBody) when (response) { is ApiResponse.Success -> { val data = response.data @@ -133,7 +133,7 @@ internal class DefaultP2PEthPoolRepository( ): Either = either { withContext(dispatchers.io) { val requestBody = P2PEthPoolWithdrawRequest(stakerAddress = stakerAddress) - val response = p2pApi.createWithdrawTransaction(network.value, requestBody) + val response = p2pEthPoolApi.createWithdrawTransaction(network.value, requestBody) when (response) { is ApiResponse.Success -> { val data = response.data @@ -154,7 +154,7 @@ internal class DefaultP2PEthPoolRepository( ): Either = either { withContext(dispatchers.io) { val requestBody = P2PEthPoolBroadcastRequest(signedTransaction = signedTransaction) - val response = p2pApi.broadcastTransaction(network.value, requestBody) + val response = p2pEthPoolApi.broadcastTransaction(network.value, requestBody) when (response) { is ApiResponse.Success -> { val data = response.data @@ -175,7 +175,7 @@ internal class DefaultP2PEthPoolRepository( vaultAddress: String, ): Either = either { withContext(dispatchers.io) { - val response = p2pApi.getAccountInfo(network.value, delegatorAddress, vaultAddress) + val response = p2pEthPoolApi.getAccountInfo(network.value, delegatorAddress, vaultAddress) when (response) { is ApiResponse.Success -> { val data = response.data @@ -197,7 +197,7 @@ internal class DefaultP2PEthPoolRepository( period: Int?, ): Either> = either { withContext(dispatchers.io) { - val response = p2pApi.getRewards( + val response = p2pEthPoolApi.getRewards( network = network.value, delegatorAddress = delegatorAddress, vaultAddress = vaultAddress, @@ -217,6 +217,10 @@ internal class DefaultP2PEthPoolRepository( } } + override fun getVaultsFlow(): Flow> { + return p2pEthPoolVaultsStore.get() + } + override fun getStakingAvailability(): Flow { return getVaultsFlow() .distinctUntilChanged() @@ -224,7 +228,7 @@ internal class DefaultP2PEthPoolRepository( if (vaults.isEmpty()) { return@map StakingAvailability.TemporaryUnavailable } else { - StakingAvailability.Available(StakingOption.P2P(vaults)) + StakingAvailability.Available(StakingOption.P2PEthPool(vaults)) } } } @@ -234,15 +238,11 @@ internal class DefaultP2PEthPoolRepository( return if (vaults.isEmpty()) { StakingAvailability.TemporaryUnavailable } else { - StakingAvailability.Available(StakingOption.P2P(vaults)) + StakingAvailability.Available(StakingOption.P2PEthPool(vaults)) } } private suspend fun getVaultsSync(): List { return p2pEthPoolVaultsStore.getSync() } - - private fun getVaultsFlow(): Flow> { - return p2pEthPoolVaultsStore.get() - } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt index 418992342d..5eedc18e16 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt @@ -364,6 +364,7 @@ internal class DefaultStakeKitRepository( } override fun getStakingAvailability( + integrationId: StakingIntegrationID.StakeKit, rawCurrencyId: CryptoCurrency.RawID, symbol: String, ): Flow { @@ -381,7 +382,7 @@ internal class DefaultStakeKitRepository( ) if (prefetchedYield != null) { - StakingAvailability.Available(StakingOption.StakeKit(prefetchedYield)) + StakingAvailability.Available(StakingOption.StakeKit(integrationId, prefetchedYield)) } else { StakingAvailability.TemporaryUnavailable } @@ -389,6 +390,7 @@ internal class DefaultStakeKitRepository( } override suspend fun getStakingAvailabilitySync( + integrationId: StakingIntegrationID.StakeKit, rawCurrencyId: CryptoCurrency.RawID, symbol: String, ): StakingAvailability { @@ -404,7 +406,7 @@ internal class DefaultStakeKitRepository( ) return if (prefetchedYield != null) { - StakingAvailability.Available(StakingOption.StakeKit(prefetchedYield)) + StakingAvailability.Available(StakingOption.StakeKit(integrationId, prefetchedYield)) } else { StakingAvailability.TemporaryUnavailable } diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingErrorResolver.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingErrorResolver.kt index 99dbdf2927..7cb5404966 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingErrorResolver.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingErrorResolver.kt @@ -29,12 +29,12 @@ internal class DefaultStakingErrorResolver( is StakingError.DomainError -> { analyticsEventHandler.send(StakingAnalyticsEvent.DomainError(error)) } - // P2P errors + // P2PEthPool errors is StakingError.InvalidAmount, is StakingError.DataError, is StakingError.UnknownError, -> { - // P2P errors - no specific analytics event yet + // P2PEthPool errors - no specific analytics event yet } } diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt index 2d73b39384..343fae5888 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt @@ -61,8 +61,9 @@ internal class DefaultStakingRepository( val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) val availabilityFlow = when (stakingIntegration) { - is StakingIntegrationID.P2P -> p2pEthPoolRepository.getStakingAvailability() + StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailability() is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailability( + stakingIntegration, rawCurrencyId, cryptoCurrency.symbol, ) @@ -94,8 +95,9 @@ internal class DefaultStakingRepository( ?: return StakingAvailability.Unavailable return when (stakingIntegration) { - is StakingIntegrationID.P2P -> p2pEthPoolRepository.getStakingAvailabilitySync() + StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailabilitySync() is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailabilitySync( + stakingIntegration, rawCurrencyId, cryptoCurrency.symbol, ) diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt index cc663e792b..9766779759 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt @@ -5,6 +5,8 @@ import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardScheduleDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.ValidatorDTO.ValidatorStatusDTO import com.tangem.datasource.local.token.converter.YieldTokenConverter +import com.tangem.domain.staking.model.common.RewardInfo +import com.tangem.domain.staking.model.common.RewardType import com.tangem.domain.staking.model.stakekit.AddressArgument import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.Yield.Metadata.RewardSchedule @@ -124,7 +126,7 @@ internal object YieldConverter : Converter { ) } - private fun convertValidator(validatorDTO: YieldDTO.ValidatorDTO, rewardType: Yield.RewardType): Yield.Validator { + private fun convertValidator(validatorDTO: YieldDTO.ValidatorDTO, rewardType: RewardType): Yield.Validator { val address = validatorDTO.address.asMandatory("address") return Yield.Validator( @@ -142,7 +144,7 @@ internal object YieldConverter : Converter { ) } - private fun createRewardInfo(validatorDTO: YieldDTO.ValidatorDTO, rewardType: Yield.RewardType): Yield.RewardInfo? { + private fun createRewardInfo(validatorDTO: YieldDTO.ValidatorDTO, rewardType: RewardType): RewardInfo? { val aprOrApy = validatorDTO.apr val commission = validatorDTO.commission // gross = net / (1 - commission) @@ -162,17 +164,17 @@ internal object YieldConverter : Converter { } else { netApy } - grossAprOrApy?.let { Yield.RewardInfo(rate = it, type = rewardType) } + grossAprOrApy?.let { RewardInfo(rate = it, type = rewardType) } } catch (_: Exception) { - aprOrApy?.let { Yield.RewardInfo(rate = it, type = rewardType) } + aprOrApy?.let { RewardInfo(rate = it, type = rewardType) } } } - private fun convertRewardType(rewardTypeDTO: YieldDTO.RewardTypeDTO): Yield.RewardType { + private fun convertRewardType(rewardTypeDTO: YieldDTO.RewardTypeDTO): RewardType { return when (rewardTypeDTO) { - YieldDTO.RewardTypeDTO.APY -> Yield.RewardType.APY - YieldDTO.RewardTypeDTO.APR -> Yield.RewardType.APR - else -> Yield.RewardType.UNKNOWN + YieldDTO.RewardTypeDTO.APY -> RewardType.APY + YieldDTO.RewardTypeDTO.APR -> RewardType.APR + else -> RewardType.UNKNOWN } } diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt index a19708587c..3fd0209523 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt @@ -9,7 +9,7 @@ import com.tangem.utils.converter.Converter import org.joda.time.Instant /** - * Converter from P2P Account Info Response to Domain model + * Converter from P2PEthPool Account Info Response to Domain model */ internal object P2PEthPoolAccountConverter : Converter { diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolBroadcastResultConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolBroadcastResultConverter.kt index 6f85db1475..13e58961c9 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolBroadcastResultConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolBroadcastResultConverter.kt @@ -8,7 +8,7 @@ import com.tangem.utils.converter.Converter import java.math.BigDecimal /** - * Converter from P2P Broadcast Transaction Response to Domain model + * Converter from P2PEthPool Broadcast Transaction Response to Domain model */ internal object P2PEthPoolBroadcastResultConverter : Converter { diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolErrorConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolErrorConverter.kt index baf0e6a718..97f3d1080a 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolErrorConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolErrorConverter.kt @@ -6,7 +6,7 @@ import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.utils.converter.Converter /** - * Converter from P2P Error Response to Domain StakingError + * Converter from P2PEthPool Error Response to Domain StakingError */ @Suppress("MagicNumber") internal object P2PEthPoolErrorConverter : Converter { diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt index da6432d026..0efd436ce7 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt @@ -5,7 +5,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward import com.tangem.utils.converter.Converter /** - * Converter from P2P Reward Entry DTO to Domain model + * Converter from P2PEthPool Reward Entry DTO to Domain model */ internal object P2PEthPoolRewardConverter : Converter { diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PStakingBalanceConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingBalanceConverter.kt similarity index 76% rename from data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PStakingBalanceConverter.kt rename to data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingBalanceConverter.kt index 83f9d0d2f7..61962eae94 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PStakingBalanceConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingBalanceConverter.kt @@ -9,16 +9,16 @@ import com.tangem.domain.models.staking.* import com.tangem.domain.staking.model.StakingIntegrationID import kotlinx.datetime.Instant -/** Converts P2P ETH Pool API response to [StakingBalance.Data.P2P] */ -internal object P2PStakingBalanceConverter { +/** Converts P2PEthPool API response to [StakingBalance.Data.P2PEthPool] */ +internal object P2PEthPoolStakingBalanceConverter { - fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance.Data.P2P { + fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance.Data.P2PEthPool { val stakingId = StakingID( - integrationId = StakingIntegrationID.P2P.EthereumPooled.value, + integrationId = StakingIntegrationID.P2PEthPool.value, address = response.delegatorAddress, ) - val account = P2PStakingAccount( + val account = P2PEthPoolStakingAccount( delegatorAddress = response.delegatorAddress, vaultAddress = response.vaultAddress, stake = convertStake(response.stake), @@ -27,29 +27,29 @@ internal object P2PStakingBalanceConverter { exitQueue = convertExitQueue(response.exitQueue), ) - return StakingBalance.Data.P2P( + return StakingBalance.Data.P2PEthPool( stakingId = stakingId, source = source, account = account, ) } - private fun convertStake(dto: P2PEthPoolStakeDTO): P2PStake { - return P2PStake( + private fun convertStake(dto: P2PEthPoolStakeDTO): P2PEthPoolStake { + return P2PEthPoolStake( assets = dto.assets, totalEarnedAssets = dto.totalEarnedAssets, ) } - private fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PExitQueue { - return P2PExitQueue( + private fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PEthPoolExitQueue { + return P2PEthPoolExitQueue( total = dto.total.toBigDecimal(), requests = dto.requests.map(::convertExitRequest), ) } - private fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PExitRequest { - return P2PExitRequest( + private fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PEthPoolExitRequest { + return P2PEthPoolExitRequest( ticket = dto.ticket, totalAssets = dto.totalAssets.toBigDecimal(), timestamp = Instant.fromEpochSeconds(dto.timestamp), diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolUnsignedTxConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolUnsignedTxConverter.kt index 98c7f7caf7..e82f2d7df5 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolUnsignedTxConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolUnsignedTxConverter.kt @@ -6,7 +6,7 @@ import com.tangem.utils.converter.Converter import java.math.BigDecimal /** - * Converter from P2P Unsigned Transaction DTO to Domain model + * Converter from P2PEthPool Unsigned Transaction DTO to Domain model */ internal object P2PEthPoolUnsignedTxConverter : Converter { diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt index cc010e8faa..75f9a6a470 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt @@ -5,7 +5,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import com.tangem.utils.converter.Converter /** - * Converter from P2P Vault DTO to Domain model + * Converter from P2PEthPool Vault DTO to Domain model */ internal object P2PEthPoolVaultConverter : Converter { diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PYieldBalanceConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PYieldBalanceConverter.kt deleted file mode 100644 index 373a5de22f..0000000000 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PYieldBalanceConverter.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.tangem.data.staking.converters.ethpool - -import com.tangem.domain.models.StatusSource -import com.tangem.domain.models.staking.* -import com.tangem.domain.staking.model.ethpool.P2PEthPoolAccount -import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault -import java.math.BigDecimal - -/** - * tmp solution before facade implementation - */ -internal object P2PYieldBalanceConverter { - - private const val ETH_DECIMALS = 18 - private const val ETH_SYMBOL = "ETH" - private const val ETH_NAME = "Ethereum" - private const val ETH_COINGECKO_ID = "ethereum" - - fun convert( - account: P2PEthPoolAccount, - vault: P2PEthPoolVault, - address: String, - source: StatusSource, - ): YieldBalance { - val integrationId = "p2p-ethereum-pooled" - val stakingId = StakingID( - integrationId = integrationId, - address = address, - ) - - val balanceItems = buildBalanceItems(account, vault) - - return if (balanceItems.isEmpty()) { - YieldBalance.Empty(stakingId = stakingId, source = source) - } else { - YieldBalance.Data( - stakingId = stakingId, - source = source, - balance = YieldBalanceItem( - items = balanceItems, - integrationId = integrationId, - ), - ) - } - } - - private fun buildBalanceItems(account: P2PEthPoolAccount, vault: P2PEthPoolVault): List = buildList { - if (account.stake.assets > BigDecimal.ZERO) { - add( - createBalanceItem( - groupId = "p2p-staked", - amount = account.stake.assets, - type = BalanceType.STAKED, - validatorAddress = vault.vaultAddress, - ), - ) - } - } - - private fun createBalanceItem( - groupId: String, - amount: BigDecimal, - type: BalanceType, - validatorAddress: String, - ): BalanceItem { - return BalanceItem( - groupId = groupId, - token = createEthToken(), - type = type, - amount = amount, - rawCurrencyId = ETH_COINGECKO_ID, - validatorAddress = validatorAddress, - date = null, - pendingActions = emptyList(), - pendingActionsConstraints = emptyList(), - isPending = false, - ) - } - - private fun createEthToken(): YieldToken { - return YieldToken( - name = ETH_NAME, - network = NetworkType.ETHEREUM, - symbol = ETH_SYMBOL, - decimals = ETH_DECIMALS, - address = null, - coinGeckoId = ETH_COINGECKO_ID, - logoURI = null, - isPoints = false, - ) - } -} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt index 26afc00827..0b50257936 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt @@ -1,9 +1,9 @@ package com.tangem.data.staking.di import androidx.datastore.core.DataStore -import com.tangem.data.staking.store.DefaultP2PBalancesStore +import com.tangem.data.staking.store.DefaultP2PEthPoolBalancesStore import com.tangem.data.staking.store.DefaultStakingBalancesStore -import com.tangem.data.staking.store.P2PBalancesStore +import com.tangem.data.staking.store.P2PEthPoolBalancesStore import com.tangem.data.staking.store.StakingBalancesStore import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO @@ -38,11 +38,11 @@ internal object StakingBalanceSupplierModule { @Provides @Singleton - fun provideP2PBalancesStore( + fun provideP2PEthPoolBalancesStore( persistenceStore: DataStore>>, dispatchers: CoroutineDispatcherProvider, - ): P2PBalancesStore { - return DefaultP2PBalancesStore( + ): P2PEthPoolBalancesStore { + return DefaultP2PEthPoolBalancesStore( runtimeStore = RuntimeSharedStore(), persistenceStore = persistenceStore, dispatchers = dispatchers, diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt index 22164269fd..fd25f57d71 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -75,12 +75,12 @@ internal object StakingDataModule { @Provides @Singleton fun provideP2PEthPoolRepository( - p2pApi: P2PEthPoolApi, + p2pEthPoolApi: P2PEthPoolApi, p2pEthPoolVaultsStore: P2PEthPoolVaultsStore, dispatchers: CoroutineDispatcherProvider, ): P2PEthPoolRepository { return DefaultP2PEthPoolRepository( - p2pApi = p2pApi, + p2pEthPoolApi = p2pEthPoolApi, p2pEthPoolVaultsStore = p2pEthPoolVaultsStore, dispatchers = dispatchers, ) 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 8a2411fc11..60c12b3b12 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 @@ -5,7 +5,7 @@ import arrow.core.left import arrow.core.right import arrow.core.toOption import com.tangem.data.common.api.safeApiCall -import com.tangem.data.staking.store.P2PBalancesStore +import com.tangem.data.staking.store.P2PEthPoolBalancesStore import com.tangem.data.staking.store.StakingBalancesStore import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory import com.tangem.datasource.api.common.response.ApiResponse @@ -24,7 +24,8 @@ 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.P2PStakingConfig +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 import com.tangem.utils.coroutines.runSuspendCatching @@ -38,16 +39,16 @@ import javax.inject.Inject /** * Default implementation of [MultiStakingBalanceFetcher] * - * Supports both StakeKit and P2P staking providers. + * Supports both StakeKit and P2PEthPool staking providers. * - * @property userWalletsStore user wallets store - * @property stakingYieldsStore staking yields store - * @property stakingBalancesStore staking balances store (StakeKit) - * @property p2pBalancesStore P2P balances store - * @property stakeKitApi stake kit API - * @property p2pApi P2P ETH Pool API - * @property p2pVaultsStore P2P vaults store - * @property dispatchers dispatchers + * @property userWalletsStore user wallets store + * @property stakingYieldsStore staking yields store + * @property stakingBalancesStore staking balances store (StakeKit) + * @property p2PEthPoolBalancesStore P2PEthPool balances store + * @property stakeKitApi stake kit API + * @property p2pEthPoolApi P2PEthPool API + * @property p2pEthPoolVaultsStore P2PEthPool vaults store + * @property dispatchers dispatchers * [REDACTED_AUTHOR] */ @@ -56,10 +57,10 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( private val userWalletsStore: UserWalletsStore, private val stakingYieldsStore: StakingYieldsStore, private val stakingBalancesStore: StakingBalancesStore, - private val p2pBalancesStore: P2PBalancesStore, + private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore, private val stakeKitApi: StakeKitApi, - private val p2pApi: P2PEthPoolApi, - private val p2pVaultsStore: P2PEthPoolVaultsStore, + private val p2pEthPoolApi: P2PEthPoolApi, + private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore, private val dispatchers: CoroutineDispatcherProvider, ) : MultiStakingBalanceFetcher { @@ -75,7 +76,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( return it.left() } - val (stakeKitIds, p2pIds) = stakingIds.partition { stakingId -> + val (stakeKitIds, p2pEthPoolIds) = stakingIds.partition { stakingId -> val stakingIntegrationID = StakingIntegrationID.entries.find { it.value == stakingId.integrationId } @@ -86,7 +87,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( """ Staking IDs to fetch: - StakeKit: ${stakeKitIds.joinToString()} - - P2P: ${p2pIds.joinToString()} + - P2PEthPool: ${p2pEthPoolIds.joinToString()} """.trimIndent(), ) @@ -96,8 +97,8 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( launch { fetchStakeKitBalances(params.userWalletId, stakeKitIds.toSet()) } } - if (p2pIds.isNotEmpty()) { - launch { fetchP2PBalances(params.userWalletId, p2pIds.toSet()) } + if (p2pEthPoolIds.isNotEmpty()) { + launch { fetchP2PBalances(params.userWalletId, p2pEthPoolIds.toSet()) } } } } @@ -110,8 +111,11 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( stakingIds = stakeKitIds.toSet(), ) } - if (p2pIds.isNotEmpty()) { - p2pBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = p2pIds.toSet()) + if (p2pEthPoolIds.isNotEmpty()) { + p2PEthPoolBalancesStore.storeError( + userWalletId = params.userWalletId, + stakingIds = p2pEthPoolIds.toSet(), + ) } } } @@ -128,12 +132,12 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( } private suspend fun fetchP2PBalances(userWalletId: UserWalletId, stakingIds: Set) { - p2pBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds) + p2PEthPoolBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds) - val vaults = runSuspendCatching { p2pVaultsStore.getSync() }.getOrNull().orEmpty() + val vaults = runSuspendCatching { p2pEthPoolVaultsStore.getSync() }.getOrNull().orEmpty() if (vaults.isEmpty()) { - Timber.w("No P2P vaults available for $userWalletId") - p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) + Timber.w("No P2PEthPool vaults available for $userWalletId") + p2PEthPoolBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) return } @@ -143,59 +147,17 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( private suspend fun fetchFromP2P( userWalletId: UserWalletId, stakingIds: Set, - vaults: List, + vaults: List, ) { safeApiCall( call = { val addresses = stakingIds.map { it.address }.toSet() + val responses = fetchP2PAccountResponses(vaults = vaults, addresses = addresses) - val responses = mutableSetOf() - - for (vault in vaults) { - for (address in addresses) { - runSuspendCatching { - val response = p2pApi.getAccountInfo( - network = P2PStakingConfig.activeNetwork.value, - delegatorAddress = address, - vaultAddress = vault.vaultAddress, - ) - - when (response) { - is ApiResponse.Success -> { - val data = response.data - if (data.error != null) { - Timber.w( - "P2P 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 -> { - Timber.w( - response.cause, - "Failed to fetch P2P balance for vault ${vault.vaultAddress}, " + - "address $address", - ) - } - } - }.onFailure { error -> - Timber.w( - error, - "Failed to fetch P2P balance for vault ${vault.vaultAddress}, address $address", - ) - } - } - } - - Timber.i("Successfully fetched ${responses.size} P2P balances for $userWalletId") + Timber.i("Successfully fetched ${responses.size} P2PEthPool balances for $userWalletId") if (responses.isNotEmpty()) { - p2pBalancesStore.storeActual(userWalletId = userWalletId, values = responses) + p2PEthPoolBalancesStore.storeActual(userWalletId = userWalletId, values = responses) val missingStakingIds = stakingIds.filter { stakingId -> responses.none { response -> @@ -205,23 +167,76 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( if (missingStakingIds.isNotEmpty()) { Timber.i("Missing responses for ${missingStakingIds.size} staking IDs: $missingStakingIds") - p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = missingStakingIds.toSet()) + p2PEthPoolBalancesStore.storeError( + userWalletId = userWalletId, + stakingIds = missingStakingIds.toSet(), + ) } } else { - Timber.i("No P2P responses received for $userWalletId") - p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) + Timber.i("No P2PEthPool responses received for $userWalletId") + p2PEthPoolBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) } }, onError = { throwable -> - Timber.e(throwable, "Unable to fetch P2P balances $userWalletId") + Timber.e(throwable, "Unable to fetch P2PEthPool balances $userWalletId") - p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) + p2PEthPoolBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) throw throwable }, ) } + private suspend fun fetchP2PAccountResponses( + 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) { + Timber.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 -> { + Timber.w( + response.cause, + "Failed to fetch P2PEthPool balance for vault ${vault.vaultAddress}, " + + "address $address", + ) + } + } + }.onFailure { error -> + Timber.w( + error, + "Failed to fetch P2PEthPool balance for vault ${vault.vaultAddress}, address $address", + ) + } + } + } + + return responses + } + private inline fun checkIsSupportedByWalletOrElse(userWalletId: UserWalletId, ifNotSupported: (Throwable) -> Unit) { val maybeUserWallet = userWalletsStore.getSyncOrNull(key = userWalletId).toOption() diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducer.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducer.kt index 2ab3c5ad8c..0ce6065dae 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducer.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducer.kt @@ -2,7 +2,7 @@ package com.tangem.data.staking.multi import arrow.core.Option import arrow.core.some -import com.tangem.data.staking.store.P2PBalancesStore +import com.tangem.data.staking.store.P2PEthPoolBalancesStore import com.tangem.data.staking.store.StakingBalancesStore import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.staking.multi.MultiStakingBalanceProducer @@ -19,11 +19,11 @@ import kotlinx.coroutines.flow.onEmpty /** * Default implementation of [MultiStakingBalanceProducer] * - * Combines staking balances from both StakeKit and P2P providers. + * Combines staking balances from both StakeKit and P2PEthPool providers. * * @property params params * @property stakingBalancesStore StakeKit staking balances store - * @property p2pBalancesStore P2P balances store + * @property p2PEthPoolBalancesStore P2PEthPool balances store * @property dispatchers dispatchers * [REDACTED_AUTHOR] @@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.onEmpty internal class DefaultMultiStakingBalanceProducer @AssistedInject constructor( @Assisted val params: MultiStakingBalanceProducer.Params, private val stakingBalancesStore: StakingBalancesStore, - private val p2pBalancesStore: P2PBalancesStore, + private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore, private val dispatchers: CoroutineDispatcherProvider, ) : MultiStakingBalanceProducer { @@ -39,10 +39,10 @@ internal class DefaultMultiStakingBalanceProducer @AssistedInject constructor( override fun produce(): Flow> { val stakeKitFlow = stakingBalancesStore.get(userWalletId = params.userWalletId) - val p2pFlow = p2pBalancesStore.get(userWalletId = params.userWalletId) + val p2pEthPoolFlow = p2PEthPoolBalancesStore.get(userWalletId = params.userWalletId) - return combine(stakeKitFlow, p2pFlow) { stakeKitBalances, p2pBalances -> - stakeKitBalances + p2pBalances + return combine(stakeKitFlow, p2pEthPoolFlow) { stakeKitBalances, p2pEthPoolBalances -> + stakeKitBalances + p2pEthPoolBalances } .distinctUntilChanged() .onEmpty { emit(value = hashSetOf()) } diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt similarity index 91% rename from data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PBalancesStore.kt rename to data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt index d95a730a9f..2be492ef3a 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt @@ -1,7 +1,7 @@ package com.tangem.data.staking.store import androidx.datastore.core.DataStore -import com.tangem.data.staking.converters.ethpool.P2PStakingBalanceConverter +import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingBalanceConverter import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.StatusSource @@ -20,22 +20,22 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch internal typealias WalletIdWithP2PStakingBalances = Map> -internal typealias WalletIdWithP2PResponses = Map> +internal typealias WalletIdWithP2PEthPoolResponses = Map> /** - * Default implementation of [P2PBalancesStore] + * Default implementation of [P2PEthPoolBalancesStore] * - * Stores P2P ETH Pool staking balances. + * Stores P2PEthPool staking balances. * * @property runtimeStore runtime store * @property persistenceStore persistence store * @param dispatchers coroutine dispatchers */ -internal class DefaultP2PBalancesStore( +internal class DefaultP2PEthPoolBalancesStore( private val runtimeStore: RuntimeSharedStore, - private val persistenceStore: DataStore, + private val persistenceStore: DataStore, dispatchers: CoroutineDispatcherProvider, -) : P2PBalancesStore { +) : P2PEthPoolBalancesStore { private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) @@ -47,7 +47,7 @@ internal class DefaultP2PBalancesStore( value = cachedData.map { (stringWalletId, responses) -> val key = UserWalletId(stringWalletId) val value = responses.map { response -> - P2PStakingBalanceConverter.convert( + P2PEthPoolStakingBalanceConverter.convert( response = response, source = StatusSource.CACHE, ) @@ -108,7 +108,7 @@ internal class DefaultP2PBalancesStore( private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set) { val newBalances = values.map { response -> - P2PStakingBalanceConverter.convert( + P2PEthPoolStakingBalanceConverter.convert( response = response, source = StatusSource.ACTUAL, ) @@ -152,7 +152,7 @@ internal class DefaultP2PBalancesStore( current.toMutableMap().apply { this[userWalletId.stringValue] = this[userWalletId.stringValue].orEmpty() .filterNot { response -> - StakingIntegrationID.P2P.EthereumPooled.value in integrationIds + StakingIntegrationID.P2PEthPool.value in integrationIds } .toSet() } diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/P2PBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt similarity index 92% rename from data/staking/src/main/java/com/tangem/data/staking/store/P2PBalancesStore.kt rename to data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt index 02479075c3..72891d6a1b 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/P2PBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt @@ -7,9 +7,9 @@ import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.Flow /** - * Store for P2P ETH Pool staking balances + * Store for P2PEthPool staking balances */ -interface P2PBalancesStore { +interface P2PEthPoolBalancesStore { fun get(userWalletId: UserWalletId): Flow> diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt index a0bacfe34f..ee45a232fe 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt @@ -1,6 +1,6 @@ package com.tangem.data.staking -import com.tangem.data.staking.converters.ethpool.P2PStakingBalanceConverter +import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingBalanceConverter import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO import com.tangem.datasource.local.token.converter.StakingBalanceConverter @@ -11,8 +11,10 @@ internal fun YieldBalanceWrapperDTO.toDomain(source: StatusSource = StatusSource return StakingBalanceConverter(isCached = source == StatusSource.CACHE).convert(this)!! } -internal fun P2PEthPoolAccountResponse.toDomain(source: StatusSource = StatusSource.CACHE): StakingBalance.Data.P2P { - return P2PStakingBalanceConverter.convert( +internal fun P2PEthPoolAccountResponse.toDomain( + source: StatusSource = StatusSource.CACHE, +): StakingBalance.Data.P2PEthPool { + return P2PEthPoolStakingBalanceConverter.convert( response = this, source = source, ) 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 e380157061..30f1ed325f 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 @@ -4,7 +4,7 @@ import arrow.core.toOption import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory import com.tangem.common.test.data.staking.MockYieldDTOFactory import com.tangem.common.test.domain.wallet.MockUserWalletFactory -import com.tangem.data.staking.store.P2PBalancesStore +import com.tangem.data.staking.store.P2PEthPoolBalancesStore import com.tangem.data.staking.store.StakingBalancesStore import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory import com.tangem.datasource.api.common.response.ApiResponse @@ -36,19 +36,19 @@ internal class DefaultMultiStakingBalanceFetcherTest { private val userWalletsStore: UserWalletsStore = mockk() private val stakingYieldsStore: StakingYieldsStore = mockk() private val stakingBalancesStore: StakingBalancesStore = mockk(relaxUnitFun = true) - private val p2pBalancesStore: P2PBalancesStore = mockk(relaxUnitFun = true) + private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore = mockk(relaxUnitFun = true) private val stakeKitApi: StakeKitApi = mockk() - private val p2pApi: P2PEthPoolApi = mockk() - private val p2pVaultsStore: P2PEthPoolVaultsStore = mockk() + private val p2pEthPoolApi: P2PEthPoolApi = mockk() + private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore = mockk() private val fetcher = DefaultMultiStakingBalanceFetcher( userWalletsStore = userWalletsStore, stakingYieldsStore = stakingYieldsStore, stakingBalancesStore = stakingBalancesStore, - p2pBalancesStore = p2pBalancesStore, + p2PEthPoolBalancesStore = p2PEthPoolBalancesStore, stakeKitApi = stakeKitApi, - p2pApi = p2pApi, - p2pVaultsStore = p2pVaultsStore, + p2pEthPoolApi = p2pEthPoolApi, + p2pEthPoolVaultsStore = p2pEthPoolVaultsStore, dispatchers = TestingCoroutineDispatcherProvider(), ) diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducerTest.kt index faf28b5731..e33aa4cd4f 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducerTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducerTest.kt @@ -3,7 +3,7 @@ package com.tangem.data.staking.multi import com.google.common.truth.Truth import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory import com.tangem.common.test.data.staking.MockP2PEthPoolAccountResponseFactory -import com.tangem.data.staking.store.P2PBalancesStore +import com.tangem.data.staking.store.P2PEthPoolBalancesStore import com.tangem.data.staking.store.StakingBalancesStore import com.tangem.data.staking.toDomain import com.tangem.domain.models.StatusSource @@ -28,13 +28,13 @@ internal class DefaultMultiStakingBalanceProducerTest { private val params = MultiStakingBalanceProducer.Params(userWalletId = UserWalletId("011")) private val stakingBalancesStore = mockk() - private val p2pBalancesStore = mockk() + private val p2PEthPoolBalancesStore = mockk() private val dispatchers = TestingCoroutineDispatcherProvider() private val producer = DefaultMultiStakingBalanceProducer( params = params, stakingBalancesStore = stakingBalancesStore, - p2pBalancesStore = p2pBalancesStore, + p2PEthPoolBalancesStore = p2PEthPoolBalancesStore, dispatchers = dispatchers, ) @@ -48,13 +48,13 @@ internal class DefaultMultiStakingBalanceProducerTest { val networksStatusesFlow = flowOf(balances) every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow - every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) + every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) val actual = producer.produce() // check after producer.produce() verify { stakingBalancesStore.get(params.userWalletId) } - verify { p2pBalancesStore.get(params.userWalletId) } + verify { p2PEthPoolBalancesStore.get(params.userWalletId) } val values = getEmittedValues(flow = actual) @@ -67,13 +67,13 @@ internal class DefaultMultiStakingBalanceProducerTest { val networksStatusesFlow = MutableSharedFlow>(replay = 2) every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow - every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) + every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) val actual = producer.produce() // check after producer.produce() verify { stakingBalancesStore.get(params.userWalletId) } - verify { p2pBalancesStore.get(params.userWalletId) } + verify { p2PEthPoolBalancesStore.get(params.userWalletId) } // first emit val balances = setOf( @@ -108,13 +108,13 @@ internal class DefaultMultiStakingBalanceProducerTest { val networksStatusesFlow = MutableSharedFlow>(replay = 2) every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow - every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) + every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) val actual = producer.produce() // check after producer.produce() verify { stakingBalancesStore.get(params.userWalletId) } - verify { p2pBalancesStore.get(params.userWalletId) } + verify { p2PEthPoolBalancesStore.get(params.userWalletId) } // first emit val wrappers = setOf( @@ -157,13 +157,13 @@ internal class DefaultMultiStakingBalanceProducerTest { .buffer(capacity = 5) every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow - every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) + every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) val actual = producer.produceWithFallback() // check after producer.produce() verify { stakingBalancesStore.get(params.userWalletId) } - verify { p2pBalancesStore.get(params.userWalletId) } + verify { p2PEthPoolBalancesStore.get(params.userWalletId) } val values1 = getEmittedValues(flow = actual) @@ -181,13 +181,13 @@ internal class DefaultMultiStakingBalanceProducerTest { @Test fun `test that flow is empty`() = runTest { every { stakingBalancesStore.get(params.userWalletId) } returns emptyFlow() - every { p2pBalancesStore.get(params.userWalletId) } returns emptyFlow() + every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns emptyFlow() val actual = producer.produce() // check after producer.produce() verify { stakingBalancesStore.get(params.userWalletId) } - verify { p2pBalancesStore.get(params.userWalletId) } + verify { p2PEthPoolBalancesStore.get(params.userWalletId) } val values = getEmittedValues(flow = actual) @@ -198,53 +198,53 @@ internal class DefaultMultiStakingBalanceProducerTest { @Test fun `test that StakeKit and P2P balances are combined`() = runTest { val stakeKitBalances = createStakeKitBalances() - val p2pBalances = createP2PBalances() + val p2pEthPoolBalances = createP2PEthPoolBalances() every { stakingBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances) - every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(p2pBalances) + every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(p2pEthPoolBalances) val actual = producer.produce() // check after producer.produce() verify { stakingBalancesStore.get(params.userWalletId) } - verify { p2pBalancesStore.get(params.userWalletId) } + verify { p2PEthPoolBalancesStore.get(params.userWalletId) } val values = getEmittedValues(flow = actual) Truth.assertThat(values.size).isEqualTo(1) - Truth.assertThat(values.first()).isEqualTo(stakeKitBalances + p2pBalances) + Truth.assertThat(values.first()).isEqualTo(stakeKitBalances + p2pEthPoolBalances) } @Test fun `test that P2P balances are updated independently from StakeKit`() = runTest { val stakeKitBalances = createStakeKitBalancesWithTonOnly() - val p2pFlow = MutableSharedFlow>(replay = 2) + val p2pEthPoolFlow = MutableSharedFlow>(replay = 2) every { stakingBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances) - every { p2pBalancesStore.get(params.userWalletId) } returns p2pFlow + every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns p2pEthPoolFlow val actual = producer.produce() // check after producer.produce() verify { stakingBalancesStore.get(params.userWalletId) } - verify { p2pBalancesStore.get(params.userWalletId) } + verify { p2PEthPoolBalancesStore.get(params.userWalletId) } - // first emit - empty P2P - p2pFlow.emit(emptySet()) + // first emit - empty P2PEthPool + p2pEthPoolFlow.emit(emptySet()) val values1 = getEmittedValues(flow = actual) Truth.assertThat(values1.size).isEqualTo(1) Truth.assertThat(values1.first()).isEqualTo(stakeKitBalances) - // second emit - with P2P balance - val p2pBalances = createP2PBalances() - p2pFlow.emit(p2pBalances) + // second emit - with P2PEthPool balance + val p2pEthPoolBalances = createP2PEthPoolBalances() + p2pEthPoolFlow.emit(p2pEthPoolBalances) val values2 = getEmittedValues(flow = actual) Truth.assertThat(values2.size).isEqualTo(2) - Truth.assertThat(values2.last()).isEqualTo(stakeKitBalances + p2pBalances) + Truth.assertThat(values2.last()).isEqualTo(stakeKitBalances + p2pEthPoolBalances) } private companion object { @@ -255,7 +255,7 @@ internal class DefaultMultiStakingBalanceProducerTest { address = "0x1", ) val p2pEthereumId = StakingID( - integrationId = StakingIntegrationID.P2P.EthereumPooled.value, + integrationId = StakingIntegrationID.P2PEthPool.value, address = "0x5aa711F440Eb6d4361148bBD89d03464628ace84", ) @@ -272,7 +272,7 @@ internal class DefaultMultiStakingBalanceProducerTest { ) } - fun createP2PBalances(): Set { + fun createP2PEthPoolBalances(): Set { return setOf( MockP2PEthPoolAccountResponseFactory.createWithBalance(stakingId = p2pEthereumId).toDomain( source = StatusSource.ACTUAL, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PStakingAccount.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccount.kt similarity index 70% rename from domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PStakingAccount.kt rename to domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccount.kt index edc489e496..d196a17cbc 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PStakingAccount.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccount.kt @@ -4,31 +4,31 @@ import com.tangem.domain.models.serialization.SerializedBigDecimal import kotlinx.datetime.Instant import kotlinx.serialization.Serializable -/** P2P.org staking account */ +/** P2P.org eth pooled staking account */ @Serializable -data class P2PStakingAccount( +data class P2PEthPoolStakingAccount( val delegatorAddress: String, val vaultAddress: String, - val stake: P2PStake, + val stake: P2PEthPoolStake, val availableToUnstake: SerializedBigDecimal, val availableToWithdraw: SerializedBigDecimal, - val exitQueue: P2PExitQueue, + val exitQueue: P2PEthPoolExitQueue, ) @Serializable -data class P2PStake( +data class P2PEthPoolStake( val assets: SerializedBigDecimal, val totalEarnedAssets: SerializedBigDecimal, ) @Serializable -data class P2PExitQueue( +data class P2PEthPoolExitQueue( val total: SerializedBigDecimal, - val requests: List, + val requests: List, ) @Serializable -data class P2PExitRequest( +data class P2PEthPoolExitRequest( val ticket: String, val totalAssets: SerializedBigDecimal, val timestamp: Instant, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt index a8493d22f4..a83dd1b1e2 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable import java.math.BigDecimal /** - * Staking balance facade covering StakeKit and P2P balances + * Staking balance facade covering StakeKit and P2PEthPool balances */ @Serializable sealed interface StakingBalance { @@ -50,10 +50,10 @@ sealed interface StakingBalance { } @Serializable - data class P2P( + data class P2PEthPool( override val stakingId: StakingID, override val source: StatusSource, - val account: P2PStakingAccount, + val account: P2PEthPoolStakingAccount, ) : Data { override val totalStaked: BigDecimal @@ -93,7 +93,7 @@ sealed interface StakingBalance { fun copySealed(source: StatusSource): StakingBalance { return when (this) { is Data.StakeKit -> copy(source = source) - is Data.P2P -> copy(source = source) + is Data.P2PEthPool -> copy(source = source) is Empty -> copy(source = source) is Error -> this } diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/YieldToken.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/YieldToken.kt index 18954a5c1f..92248c9b61 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/YieldToken.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/YieldToken.kt @@ -12,4 +12,17 @@ data class YieldToken( val coinGeckoId: String?, val logoURI: String?, val isPoints: Boolean?, -) \ No newline at end of file +) { + companion object { + val ETH = YieldToken( // TODO p2p + name = "Ethereum", + network = NetworkType.ETHEREUM, + symbol = "ETH", + decimals = 18, + address = null, + coinGeckoId = "ethereum", + logoURI = null, + isPoints = false, + ) + } +} \ No newline at end of file diff --git a/domain/staking/build.gradle.kts b/domain/staking/build.gradle.kts index 24a4cc4ae2..587e19d32d 100644 --- a/domain/staking/build.gradle.kts +++ b/domain/staking/build.gradle.kts @@ -29,8 +29,6 @@ dependencies { implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) - implementation(projects.features.staking.api) - implementation(tangemDeps.blockchain) { exclude(module = "joda-time") } diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingTarget.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingTarget.kt new file mode 100644 index 0000000000..c6c99e7fab --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingTarget.kt @@ -0,0 +1,66 @@ +package com.tangem.domain.staking.model + +import com.tangem.domain.staking.model.common.RewardInfo +import com.tangem.domain.staking.model.common.RewardType +import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault +import com.tangem.domain.staking.model.stakekit.Yield + +/** + * Represents either a StakeKit Validator or a P2P ETH Pool Vault. + */ +sealed interface StakingTarget { + + /** Unique identifier (validator address or vault address) */ + val address: String + + /** Display name */ + val name: String + + /** Reward info (rate and type) */ + val rewardInfo: RewardInfo? + + /** Whether this target is preferred/recommended */ + val isPreferred: Boolean + + /** Whether this target is active and available for staking */ + val isActive: Boolean + + /** Image URL for display (validator logo or vault icon) */ + val image: String? + + /** Whether this is a strategic partner (shows special badge in UI) */ + val isStrategicPartner: Boolean + + /** + * StakeKit Validator wrapper + */ + data class Validator(val delegate: Yield.Validator) : StakingTarget { + override val address: String = delegate.address + override val name: String = delegate.name + override val rewardInfo: RewardInfo? = delegate.rewardInfo + override val isPreferred: Boolean = delegate.preferred + override val isActive: Boolean = delegate.status == Yield.Validator.ValidatorStatus.ACTIVE + override val image: String? = delegate.image + override val isStrategicPartner: Boolean = delegate.isStrategicPartner + } + + /** + * P2P ETH Pool Vault wrapper + */ + data class Vault(val vault: P2PEthPoolVault) : StakingTarget { + override val address: String = vault.vaultAddress + override val name: String = vault.displayName + override val rewardInfo = RewardInfo( + rate = vault.apy, + type = RewardType.APY, + ) + override val isPreferred: Boolean = true + override val isActive: Boolean = true + override val image: String? = null + override val isStrategicPartner: Boolean = true + } +} + +fun Yield.Validator.toStakingTarget(): StakingTarget = StakingTarget.Validator(this) + +fun P2PEthPoolVault.toStakingTarget(): StakingTarget = StakingTarget.Vault(this) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardInfo.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardInfo.kt new file mode 100644 index 0000000000..b1534a9473 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardInfo.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.staking.model.common + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import kotlinx.serialization.Serializable + +@Serializable +data class RewardInfo( + val rate: SerializedBigDecimal, + val type: RewardType, +) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardType.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardType.kt new file mode 100644 index 0000000000..d444e512ea --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardType.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.staking.model.common + +import kotlinx.serialization.Serializable + +@Serializable +enum class RewardType { + APY, // compound rate + APR, // simple rate + UNKNOWN, +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt index 491c28a775..56476007cb 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt @@ -4,7 +4,7 @@ import com.tangem.domain.models.serialization.SerializedBigDecimal import org.joda.time.Instant /** - * P2P.org account staking information + * Account staking information * Contains detailed balance and exit queue information */ data class P2PEthPoolAccount( diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAction.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAction.kt index 5e5f10593f..00bfdcec8b 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAction.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAction.kt @@ -22,7 +22,7 @@ data class P2PEthPoolAction( ) /** - * Types of P2P staking actions + * Types of P2PEthPool staking actions */ @Serializable enum class P2PEthPoolActionType { @@ -33,7 +33,7 @@ enum class P2PEthPoolActionType { } /** - * Status of P2P staking action + * Status of P2PEthPool staking action */ @Serializable enum class P2PEthPoolActionStatus { @@ -46,7 +46,7 @@ enum class P2PEthPoolActionStatus { } /** - * Transaction details for P2P staking action + * Transaction details for P2PEthPool staking action */ data class P2PEthPoolStakingTransaction( val id: String?, diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBalance.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBalance.kt index c6ea6b9477..9f5b90ee4a 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBalance.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBalance.kt @@ -4,7 +4,7 @@ import com.tangem.domain.models.serialization.SerializedBigDecimal import org.joda.time.Instant /** - * P2P.org staking balance information (similar to StakeKit's YieldBalanceItem) + * Staking balance information (similar to StakeKit's YieldBalanceItem) * Contains staked amounts, rewards, and pending actions */ data class P2PEthPoolStakingBalance( @@ -15,7 +15,7 @@ data class P2PEthPoolStakingBalance( ) /** - * Individual balance item for P2P staking + * Individual balance item for P2PEthPool staking */ data class P2PEthPoolBalanceItem( val type: P2PEthPoolBalanceType, @@ -26,7 +26,7 @@ data class P2PEthPoolBalanceItem( ) /** - * Types of balances in P2P staking + * Types of balances in P2PEthPool staking */ enum class P2PEthPoolBalanceType { STAKED, diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolNetwork.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolNetwork.kt index dfbed790f9..db60654ee8 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolNetwork.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolNetwork.kt @@ -61,7 +61,7 @@ enum class P2PEthPoolNetwork( } /** - * Check if chain ID is supported for P2P staking + * Check if chain ID is supported for P2PEthPool staking * * @param chainId Ethereum chain ID * @return true if supported, false otherwise diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt index 70af95ac9a..b4672d3f90 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt @@ -4,7 +4,7 @@ import com.tangem.domain.models.serialization.SerializedBigDecimal import org.joda.time.DateTime /** - * P2P.org rewards history entry + * Rewards history entry * Historical reward information for account */ data class P2PEthPoolReward( diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStaking.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStaking.kt index b3da05663c..92dc1222cc 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStaking.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStaking.kt @@ -61,7 +61,7 @@ data class P2PEthPoolStaking( } /** - * Detailed vault information for P2P staking + * Detailed vault information for P2PEthPool staking */ data class P2PEthPoolVaultDetails( val vaultAddress: String, diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PStakingConfig.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStakingConfig.kt similarity index 76% rename from domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PStakingConfig.kt rename to domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStakingConfig.kt index 52c0f311e8..1e2949326b 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PStakingConfig.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStakingConfig.kt @@ -1,11 +1,11 @@ package com.tangem.domain.staking.model.ethpool /** - * Configuration for P2P Ethereum staking network. + * Configuration for P2PEthPool Ethereum staking network. * * Change [USE_TESTNET] to switch between testnet and mainnet. */ -object P2PStakingConfig { +object P2PEthPoolStakingConfig { const val USE_TESTNET: Boolean = true diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolUnsignedTx.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolUnsignedTx.kt index 5a801b205a..c454b15ac4 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolUnsignedTx.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolUnsignedTx.kt @@ -3,7 +3,7 @@ package com.tangem.domain.staking.model.ethpool import com.tangem.domain.models.serialization.SerializedBigDecimal /** - * P2P.org unsigned transaction ready for signing + * Unsigned transaction ready for signing * Contains all necessary data for transaction signing */ data class P2PEthPoolUnsignedTx( diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt index fc79ef7a9d..e1cf32955b 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt @@ -2,6 +2,8 @@ package com.tangem.domain.staking.model.stakekit import com.tangem.domain.models.serialization.SerializedBigDecimal import com.tangem.domain.models.staking.YieldToken +import com.tangem.domain.staking.model.common.RewardInfo +import com.tangem.domain.staking.model.common.RewardType import kotlinx.serialization.Serializable @Serializable @@ -138,18 +140,6 @@ data class Yield( UNKNOWN, } } - - enum class RewardType { - APY, // compound rate - APR, // simple rate - UNKNOWN, - } - - @Serializable - data class RewardInfo( - val rate: SerializedBigDecimal, - val type: RewardType, - ) } @Serializable diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingOptionsUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingOptionsUseCase.kt index 3c5a7b4fe1..2573289397 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingOptionsUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingOptionsUseCase.kt @@ -12,11 +12,11 @@ import kotlinx.coroutines.launch /** * Use case for fetching all staking options from all providers - * Fetches both StakeKit yields and P2P vaults + * Fetches both StakeKit yields and P2PEthPool vaults */ class FetchStakingOptionsUseCase( private val stakeKitRepository: StakeKitRepository, - private val p2pRepository: P2PEthPoolRepository, + private val p2pEthPoolRepository: P2PEthPoolRepository, private val stakingErrorResolver: StakingErrorResolver, ) { suspend operator fun invoke(): Either { @@ -25,7 +25,7 @@ class FetchStakingOptionsUseCase( block = { coroutineScope { launch { stakeKitRepository.fetchYields() } - launch { p2pRepository.fetchVaults() } + launch { p2pEthPoolRepository.fetchVaults() } } }, catch = { stakingErrorResolver.resolve(it) }, diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingEntryInfoUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingEntryInfoUseCase.kt index ef77b03abb..fd42a7e74f 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingEntryInfoUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingEntryInfoUseCase.kt @@ -30,7 +30,7 @@ class GetStakingEntryInfoUseCase( symbol = symbol, ) } - is StakingOption.P2P -> { + is StakingOption.P2PEthPool -> { StakingEntryInfo( tokenSymbol = "ETH", ) diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt new file mode 100644 index 0000000000..97fc1c11cf --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt @@ -0,0 +1,62 @@ +package com.tangem.domain.staking.model + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.staking.YieldToken +import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault +import com.tangem.domain.staking.model.stakekit.Yield +import java.math.BigDecimal + +/** + * StakingIntegration implementation for P2PEthPool pooled staking. + * Converts P2PEthPoolVault data to the common StakingIntegration interface. + */ +class P2PEthPoolIntegration( + override val integrationId: StakingIntegrationID, + vaults: List, +) : StakingIntegration { + + // Basic + + override val token: YieldToken = YieldToken.ETH + + override val tokens: List = listOf(token) + + // Targets (vaults) + + override val targets: List = vaults.map { vault -> + vault.toStakingTarget() + } + + override val preferredTargets: List = targets + + override val areAllTargetsFull: Boolean = false + + // Enter/Exit Args + + override val isPartialAmountDisabled: Boolean = false + + override val enterMinimumAmount: BigDecimal = DEFAULT_MINIMUM_STAKE + + override val exitMinimumAmount: BigDecimal? = null + + override val enterArgs: Yield.Args.Enter? = null + + override val exitArgs: Yield.Args.Enter? = null + + // Metadata + + override val warmupPeriodDays: Int = 0 + + override val cooldownPeriodDays: Int = DEFAULT_COOLDOWN_DAYS + + override val rewardSchedule: Yield.Metadata.RewardSchedule = Yield.Metadata.RewardSchedule.DAY + + override val rewardClaiming: Yield.Metadata.RewardClaiming = Yield.Metadata.RewardClaiming.AUTO + + override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = token + + companion object { + private const val DEFAULT_COOLDOWN_DAYS = 7 + private val DEFAULT_MINIMUM_STAKE = BigDecimal("0.01") + } +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt new file mode 100644 index 0000000000..d6b3ad49e5 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt @@ -0,0 +1,60 @@ +package com.tangem.domain.staking.model + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.staking.YieldToken +import com.tangem.domain.staking.model.stakekit.Yield +import java.math.BigDecimal + +/** + * StakingIntegration implementation for StakeKit. + * Delegates all calls to the underlying Yield object. + */ +class StakeKitIntegration( + override val integrationId: StakingIntegrationID, + private val yield: Yield, +) : StakingIntegration { + + // Basic + + override val token: YieldToken = yield.token + + override val tokens: List = yield.tokens + + // Targets (validators) + + override val targets: List = yield.validators.map { it.toStakingTarget() } + + override val preferredTargets: List = yield.preferredValidators.map { it.toStakingTarget() } + + override val areAllTargetsFull: Boolean = yield.allValidatorsFull + + // Enter/Exit Args + + override val isPartialAmountDisabled: Boolean = yield.args.enter.isPartialAmountDisabled + + override val enterMinimumAmount: BigDecimal? = + yield.args.enter.args[Yield.Args.ArgType.AMOUNT]?.minimum + + override val exitMinimumAmount: BigDecimal? = + yield.args.exit?.args + ?.get(Yield.Args.ArgType.AMOUNT)?.minimum + + override val enterArgs: Yield.Args.Enter = yield.args.enter + + override val exitArgs: Yield.Args.Enter? = yield.args.exit + + // Metadata + + override val warmupPeriodDays: Int = yield.metadata.warmupPeriod.days + + override val cooldownPeriodDays: Int? = yield.metadata.cooldownPeriod?.days + + override val rewardSchedule: Yield.Metadata.RewardSchedule = yield.metadata.rewardSchedule + + override val rewardClaiming: Yield.Metadata.RewardClaiming = yield.metadata.rewardClaiming + + // Basic + + override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = + tokens.firstOrNull { rawCurrencyId?.value == it.coinGeckoId } ?: token +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingAvailability.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingAvailability.kt similarity index 100% rename from domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingAvailability.kt rename to domain/staking/src/main/java/com/tangem/domain/staking/model/StakingAvailability.kt diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt new file mode 100644 index 0000000000..a20e1d6ebb --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt @@ -0,0 +1,56 @@ +package com.tangem.domain.staking.model + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.staking.YieldToken +import com.tangem.domain.staking.model.stakekit.Yield +import java.math.BigDecimal + +/** + * Strategy interface for staking integrations. + * Abstracts over StakeKit and P2PEthPool staking providers. + */ +// TODO p2p get rid of stakekit-specific models in StakingIntegration and implementors +interface StakingIntegration { + + // Basic + + val integrationId: StakingIntegrationID + + val token: YieldToken + + val tokens: List + + // Targets (validators or vaults) + + val targets: List + + val preferredTargets: List + + val areAllTargetsFull: Boolean + + // Enter/Exit Args + + val isPartialAmountDisabled: Boolean + + val enterMinimumAmount: BigDecimal? + + val exitMinimumAmount: BigDecimal? + + val enterArgs: Yield.Args.Enter? + + val exitArgs: Yield.Args.Enter? + + // Metadata + + val warmupPeriodDays: Int + + val cooldownPeriodDays: Int? + + val rewardSchedule: Yield.Metadata.RewardSchedule + + val rewardClaiming: Yield.Metadata.RewardClaiming + + // Basic + + fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt index 1bf895cbcc..db33de7982 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt @@ -5,7 +5,7 @@ import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toMigratedCoinId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network -import com.tangem.domain.staking.model.ethpool.P2PStakingConfig +import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig /** * Represents a staking integration identifier. @@ -97,16 +97,14 @@ sealed interface StakingIntegrationID { } /** - * Represents P2P staking integrations + * Represents P2PEthPool staking integration */ - enum class P2P : StakingIntegrationID { - EthereumPooled { - override val value: String = "p2p-ethereum-pooled" - override val blockchain: Blockchain - get() = if (P2PStakingConfig.USE_TESTNET) Blockchain.EthereumTestnet else Blockchain.Ethereum - override val networkId: String - get() = P2PStakingConfig.activeNetwork.stakingNetworkId - }, + object P2PEthPool : StakingIntegrationID { + override val value: String = "p2p-ethereum-pooled" + override val blockchain: Blockchain + get() = if (P2PEthPoolStakingConfig.USE_TESTNET) Blockchain.EthereumTestnet else Blockchain.Ethereum + override val networkId: String + get() = P2PEthPoolStakingConfig.activeNetwork.stakingNetworkId } // Polkadot { @@ -138,7 +136,7 @@ sealed interface StakingIntegrationID { /** List of all native staking integration IDs */ val entries: List by lazy { - StakeKit.Coin.entries + StakeKit.EthereumToken.entries + P2P.entries + StakeKit.Coin.entries + StakeKit.EthereumToken.entries + listOf(P2PEthPool) } /** @@ -152,9 +150,12 @@ sealed interface StakingIntegrationID { val blockchain = Blockchain.fromId(id = currencyId.rawNetworkId) return if (currencyId.contractAddress.isNullOrBlank()) { - // Order is not important — either P2P or Stakekit.Coin can be in any order - P2P.entries.firstOrNull { it.blockchain == blockchain } - ?: StakeKit.Coin.entries.firstOrNull { it.blockchain == blockchain } + // Order is not important — either P2PEthPool or Stakekit.Coin can be in any order + if (P2PEthPool.blockchain == blockchain) { + P2PEthPool + } else { + StakeKit.Coin.entries.firstOrNull { it.blockchain == blockchain } + } } else { StakeKit.EthereumToken.entries.firstOrNull { token -> token.blockchain == blockchain && diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingOption.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingOption.kt similarity index 53% rename from domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingOption.kt rename to domain/staking/src/main/java/com/tangem/domain/staking/model/StakingOption.kt index 60f9674e3f..80702d4a7c 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingOption.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingOption.kt @@ -1,19 +1,18 @@ package com.tangem.domain.staking.model import com.tangem.domain.models.serialization.SerializedBigDecimal -import com.tangem.domain.models.staking.NetworkType import com.tangem.domain.models.staking.YieldToken import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import com.tangem.domain.staking.model.stakekit.Yield /** * Represents a staking option from any provider - * Unified abstraction over StakeKit and P2P staking integrations + * Unified abstraction over StakeKit and P2PEthPool staking integrations */ sealed interface StakingOption { /** Unique identifier for the staking option */ - val integrationId: String + val integrationId: StakingIntegrationID /** Annual Percentage Yield */ val apy: SerializedBigDecimal @@ -28,34 +27,23 @@ sealed interface StakingOption { * StakeKit staking option * Wraps StakeKit Yield with all validator and metadata information */ - data class StakeKit(val yield: Yield) : StakingOption { - override val integrationId: String = yield.id + data class StakeKit( + override val integrationId: StakingIntegrationID.StakeKit, + val yield: Yield, + ) : StakingOption { override val apy: SerializedBigDecimal = yield.apy override val token: YieldToken = yield.token override val isAvailable: Boolean = yield.isAvailable } /** - * P2P pooled staking option - * Wraps P2P ETH Pool vault information + * P2PEthPool staking option + * Wraps P2PEthPool vault information */ - data class P2P(val vaults: List) : StakingOption { - override val integrationId: String = "p2p-ethereum-pooled" + data class P2PEthPool(val vaults: List) : StakingOption { + override val integrationId: StakingIntegrationID = StakingIntegrationID.P2PEthPool override val apy: SerializedBigDecimal = vaults.maxOf { it.apy } - override val token: YieldToken = createEthToken() + override val token: YieldToken = YieldToken.ETH override val isAvailable: Boolean = vaults.isNotEmpty() - - private fun createEthToken(): YieldToken { // TODO - return YieldToken( - name = "Ethereum", - network = NetworkType.ETHEREUM, - symbol = "ETH", - decimals = 18, - address = null, // Native token - coinGeckoId = "ethereum", - logoURI = null, - isPoints = false, - ) - } } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt index 8a116ff9b6..2b3811a26a 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt @@ -8,7 +8,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault -import com.tangem.domain.staking.model.ethpool.P2PStakingConfig +import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig import com.tangem.domain.staking.model.stakekit.StakingError import kotlinx.coroutines.flow.Flow @@ -17,24 +17,24 @@ interface P2PEthPoolRepository { /** * Fetch and store available staking vaults * - * @param network P2P network (MAINNET or TESTNET) + * @param network P2PEthPool network (MAINNET or TESTNET) */ - suspend fun fetchVaults(network: P2PEthPoolNetwork = P2PStakingConfig.activeNetwork) + suspend fun fetchVaults(network: P2PEthPoolNetwork = P2PEthPoolStakingConfig.activeNetwork) /** * Get list of available staking vaults * - * @param network P2P network (MAINNET or TESTNET) + * @param network P2PEthPool network (MAINNET or TESTNET) * @return Either error or list of vaults with APY, capacity, fees */ suspend fun getVaults( - network: P2PEthPoolNetwork = P2PStakingConfig.activeNetwork, + network: P2PEthPoolNetwork = P2PEthPoolStakingConfig.activeNetwork, ): Either> /** * Create unsigned transaction for depositing ETH into a vault * - * @param network P2P network (MAINNET or TESTNET) + * @param network P2PEthPool network (MAINNET or TESTNET) * @param delegatorAddress User's wallet address * @param vaultAddress Vault contract address * @param amount Amount of ETH to deposit @@ -53,7 +53,7 @@ interface P2PEthPoolRepository { * Unstaking adds funds to exit queue. After ~1-4 days, use [createWithdrawTransaction] * to withdraw the funds. * - * @param network P2P network (MAINNET or TESTNET) + * @param network P2PEthPool network (MAINNET or TESTNET) * @param stakerPublicKey Staker's public key (note: API doc may have Bitcoin terminology) * @param stakeTransactionHash Original stake transaction hash * @return Either error or unsigned transaction @@ -69,7 +69,7 @@ interface P2PEthPoolRepository { * * Only works when funds are available (after exit queue wait period). * - * @param network P2P network (MAINNET or TESTNET) + * @param network P2PEthPool network (MAINNET or TESTNET) * @param stakerAddress User's wallet address * @return Either error or unsigned transaction with withdrawal tickets */ @@ -81,7 +81,7 @@ interface P2PEthPoolRepository { /** * Broadcast signed transaction to blockchain * - * @param network P2P network (MAINNET or TESTNET) + * @param network P2PEthPool network (MAINNET or TESTNET) * @param signedTransaction Signed transaction in hex format (with 0x prefix) * @return Either error or broadcast result with transaction hash */ @@ -95,7 +95,7 @@ interface P2PEthPoolRepository { * * Returns current stake, rewards, exit queue status, and available amounts * - * @param network P2P network (MAINNET or TESTNET) + * @param network P2PEthPool network (MAINNET or TESTNET) * @param delegatorAddress User's wallet address * @param vaultAddress Vault contract address * @return Either error or account info @@ -109,7 +109,7 @@ interface P2PEthPoolRepository { /** * Get rewards history for account and vault * - * @param network P2P network (MAINNET or TESTNET) + * @param network P2PEthPool network (MAINNET or TESTNET) * @param delegatorAddress User's wallet address * @param vaultAddress Vault contract address * @param period Optional period filter in days (30, 60, or 90) @@ -123,17 +123,27 @@ interface P2PEthPoolRepository { ): Either> /** - * Check P2P staking availability by finding public vault + * Get flow of cached vaults. * - * @return Flow of StakingAvailability - Available with StakingOption.P2P if public vault found, + * This returns vaults from the local cache/store. + * Call [fetchVaults] first to populate the cache from the network. + * + * @return Flow of cached vaults list + */ + fun getVaultsFlow(): Flow> + + /** + * Check P2PEthPool staking availability by finding public vault + * + * @return Flow of StakingAvailability - Available with StakingOption.P2PEthPool if public vault found, * TemporaryUnavailable if not found or vaults empty */ fun getStakingAvailability(): Flow /** - * Check P2P staking availability synchronously + * Check P2PEthPool staking availability synchronously * - * @return StakingAvailability - Available with StakingOption.P2P if public vault found, + * @return StakingAvailability - Available with StakingOption.P2PEthPool if public vault found, * TemporaryUnavailable if not found or vaults empty */ suspend fun getStakingAvailabilitySync(): StakingAvailability diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt index a12b108ca4..e614a685bc 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt @@ -8,6 +8,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.models.staking.NetworkType import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingAction @@ -24,9 +25,17 @@ interface StakeKitRepository { fun getEnabledYields(): Flow> - fun getStakingAvailability(rawCurrencyId: CryptoCurrency.RawID, symbol: String): Flow + fun getStakingAvailability( + integrationId: StakingIntegrationID.StakeKit, + rawCurrencyId: CryptoCurrency.RawID, + symbol: String, + ): Flow - suspend fun getStakingAvailabilitySync(rawCurrencyId: CryptoCurrency.RawID, symbol: String): StakingAvailability + suspend fun getStakingAvailabilitySync( + integrationId: StakingIntegrationID.StakeKit, + rawCurrencyId: CryptoCurrency.RawID, + symbol: String, + ): StakingAvailability suspend fun getEntryInfo(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): StakingEntryInfo diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/usecase/StakingApyFlowUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/usecase/StakingApyFlowUseCase.kt index 22f83164b2..357ec88fa5 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/usecase/StakingApyFlowUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/usecase/StakingApyFlowUseCase.kt @@ -2,35 +2,50 @@ package com.tangem.domain.staking.usecase import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toCoinId -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingTarget +import com.tangem.domain.staking.model.toStakingTarget +import com.tangem.domain.staking.repositories.P2PEthPoolRepository import com.tangem.domain.staking.repositories.StakeKitRepository import com.tangem.domain.staking.toggles.StakingFeatureToggles import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine /** - * Emits a map of Validators values per currency for staking. + * Emits a map of StakingTarget values per currency for staking. * * Return map: - * - key: currency staking key (network.backendId + "_" + symbol) - * - value: validators + * - key: currency staking key (coinGeckoId + "_" + symbol) + * - value: list of staking targets (validators or vaults) */ class StakingApyFlowUseCase( private val stakeKitRepository: StakeKitRepository, + private val p2pEthPoolRepository: P2PEthPoolRepository, private val stakingFeatureToggles: StakingFeatureToggles, ) { - operator fun invoke(): Flow>> { - return stakeKitRepository.getEnabledYields() - .map { yields -> - yields.filterNot { yield -> - val isCardanoYield = yield.token.coinGeckoId == Blockchain.Cardano.toCoinId() - isCardanoYield && !stakingFeatureToggles.isCardanoStakingEnabled - }.associate { yield -> - val key = "${yield.token.coinGeckoId}_${yield.token.symbol}" - val apy = yield.validators - key to apy - } + operator fun invoke(): Flow>> { + return combine( + stakeKitRepository.getEnabledYields(), + p2pEthPoolRepository.getVaultsFlow(), + ) { yields, p2pVaults -> + val stakeKitMap = yields.filterNot { yield -> + val isCardanoYield = yield.token.coinGeckoId == Blockchain.Cardano.toCoinId() + isCardanoYield && !stakingFeatureToggles.isCardanoStakingEnabled + }.associate { yield -> + val key = "${yield.token.coinGeckoId}_${yield.token.symbol}" + val targets = yield.validators.map { it.toStakingTarget() } + key to targets } + + val p2pMap = if (p2pVaults.isNotEmpty()) { + val ethKey = "${Blockchain.Ethereum.toCoinId()}_${Blockchain.Ethereum.currency}" + val targets = p2pVaults.map { it.toStakingTarget() } + mapOf(ethKey to targets) + } else { + emptyMap() + } + + stakeKitMap + p2pMap + } } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingBalanceExt.kt b/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingBalanceExt.kt index 75b52aa6f4..66c7a151db 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingBalanceExt.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingBalanceExt.kt @@ -7,7 +7,7 @@ import java.math.BigDecimal /** * Provider-agnostic extension to get total balance including rewards. - * Works for both StakeKit and P2P providers. + * Works for both StakeKit and P2PEthPool providers. * * Returns sum of all staking-related balances including rewards * (staked + unstaking + withdrawable + rewards). @@ -18,7 +18,7 @@ import java.math.BigDecimal fun StakingBalance.Data.getTotalWithRewardsStakingBalance(blockchainId: String): BigDecimal { return when (this) { is StakingBalance.Data.StakeKit -> getTotalWithRewardsStakingBalanceStakeKit(blockchainId) - is StakingBalance.Data.P2P -> { + is StakingBalance.Data.P2PEthPool -> { val rewards = totalRewards if (BlockchainUtils.isIncludeStakingTotalBalance(blockchainId)) { totalStaked + unstakingAmount + withdrawableAmount + rewards @@ -31,7 +31,7 @@ fun StakingBalance.Data.getTotalWithRewardsStakingBalance(blockchainId: String): /** * Provider-agnostic extension to get total staking balance excluding rewards. - * Works for both StakeKit and P2P providers. + * Works for both StakeKit and P2PEthPool providers. * * Returns sum of all staking-related balances (staked + unstaking + withdrawable) * excluding rewards. @@ -39,7 +39,7 @@ fun StakingBalance.Data.getTotalWithRewardsStakingBalance(blockchainId: String): fun StakingBalance.Data.getTotalStakingBalance(blockchainId: String): BigDecimal { return when (this) { is StakingBalance.Data.StakeKit -> getTotalStakingBalanceStakeKit(blockchainId) - is StakingBalance.Data.P2P -> totalStaked + unstakingAmount + withdrawableAmount + is StakingBalance.Data.P2PEthPool -> totalStaked + unstakingAmount + withdrawableAmount } } diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt index 996ea8cca0..71a5d271ed 100644 --- a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt @@ -149,8 +149,8 @@ internal class StakingIdFactoryTest { expected = createStakingId(integrationId = StakingIntegrationID.StakeKit.Coin.Cardano), ), CreateModel( - currencyId = createCurrencyId(blockchain = StakingIntegrationID.P2P.EthereumPooled.blockchain), - expected = createStakingId(integrationId = StakingIntegrationID.P2P.EthereumPooled), + currencyId = createCurrencyId(blockchain = StakingIntegrationID.P2PEthPool.blockchain), + expected = createStakingId(integrationId = StakingIntegrationID.P2PEthPool), ), CreateModel( currencyId = CryptoCurrency.ID.fromValue( diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt index 745af46793..c62e943341 100644 --- a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt @@ -38,17 +38,6 @@ class StakingIntegrationIDTest { Truth.assertThat(actual).hasSize(expected) } - @Test - fun `all P2P blockchains are unique`() { - // Act - val actual = StakingIntegrationID.P2P.entries - .distinctBy(StakingIntegrationID.P2P::blockchain) - - // Assert - val expected = StakingIntegrationID.P2P.entries.size - Truth.assertThat(actual).hasSize(expected) - } - @Test fun `all sub blockchains are unique`() { // Act @@ -151,8 +140,8 @@ class StakingIntegrationIDTest { expected = StakingIntegrationID.StakeKit.Coin.Cardano, ), CreateModel( - currencyId = createCurrencyId(blockchain = StakingIntegrationID.P2P.EthereumPooled.blockchain), - expected = StakingIntegrationID.P2P.EthereumPooled, + currencyId = createCurrencyId(blockchain = StakingIntegrationID.P2PEthPool.blockchain), + expected = StakingIntegrationID.P2PEthPool, ), CreateModel( currencyId = CryptoCurrency.ID.fromValue(value = "token⟨ETH⟩polygon-ecosystem-token⚓1234567890"), diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactory.kt index 3c2726eb06..d58781bda3 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactory.kt @@ -267,7 +267,7 @@ object CryptoCurrencyStatusFactory { null } } - is StakingBalance.Data.P2P -> { + is StakingBalance.Data.P2PEthPool -> { // TODO p2p val isCurrentAddressStaking = stakingBalance.stakingId.address == address.defaultAddress.value if (isCurrentAddressStaking) stakingBalance else null diff --git a/features/markets/impl/build.gradle.kts b/features/markets/impl/build.gradle.kts index 24d38f3109..1928a996ed 100644 --- a/features/markets/impl/build.gradle.kts +++ b/features/markets/impl/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(projects.domain.markets) implementation(projects.domain.onramp.models) implementation(projects.domain.staking.models) + implementation(projects.domain.staking) implementation(projects.domain.tokens) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt index 7561518e9f..dc82bfb8a8 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt @@ -174,7 +174,7 @@ internal class TokenActionsHandler @AssistedInject constructor( AppRoute.Staking( userWalletId = cryptoCurrencyData.userWallet.walletId, cryptoCurrency = cryptoCurrencyData.status.currency, - yieldId = option.integrationId, + integrationId = option.integrationId, ), ) } diff --git a/features/staking/api/build.gradle.kts b/features/staking/api/build.gradle.kts index 749f851fc5..74149b41f2 100644 --- a/features/staking/api/build.gradle.kts +++ b/features/staking/api/build.gradle.kts @@ -16,7 +16,7 @@ dependencies { /** Domain models */ api(projects.domain.models) - implementation(projects.domain.staking.models) + implementation(projects.domain.staking) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) diff --git a/features/staking/api/src/main/kotlin/com/tangem/features/staking/api/StakingComponent.kt b/features/staking/api/src/main/kotlin/com/tangem/features/staking/api/StakingComponent.kt index bf5f6a1b9d..56a53b0333 100644 --- a/features/staking/api/src/main/kotlin/com/tangem/features/staking/api/StakingComponent.kt +++ b/features/staking/api/src/main/kotlin/com/tangem/features/staking/api/StakingComponent.kt @@ -1,5 +1,6 @@ package com.tangem.features.staking.api +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.currency.CryptoCurrency @@ -10,7 +11,7 @@ interface StakingComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, val cryptoCurrency: CryptoCurrency, - val yieldId: String, + val integrationId: StakingIntegrationID, ) interface Factory : ComponentFactory diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt index cdd95ace06..5b97de21cf 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt @@ -18,10 +18,10 @@ internal class StakingAnalyticSender( fun initialInfoScreen(value: StakingUiState) { val initialInfoState = value.initialInfoState as? StakingStates.InitialInfoState.Data - val validatorState = initialInfoState?.yieldBalance as? InnerYieldBalanceState.Data - val validatorCount = validatorState?.balances - ?.filterNot { it.validator?.address.isNullOrBlank() } - ?.distinctBy { it.validator?.address } + val balanceState = initialInfoState?.yieldBalance as? InnerYieldBalanceState.Data + val validatorCount = balanceState?.balances + ?.filterNot { it.target?.address.isNullOrBlank() } + ?.distinctBy { it.target?.address } ?.size ?: 0 analyticsEventHandler.send( @@ -34,7 +34,7 @@ internal class StakingAnalyticSender( fun confirmationScreen(value: StakingUiState) { val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data val validatorState = value.validatorState as? StakingStates.ValidatorState.Data - val validatorName = validatorState?.chosenValidator?.name ?: return + val validatorName = validatorState?.chosenTarget?.name ?: return if (confirmationState?.innerState == InnerConfirmationStakingState.COMPLETED) return @@ -80,7 +80,7 @@ internal class StakingAnalyticSender( fun sendTransactionStakingAnalytics(value: StakingUiState, cryptoCurrencyStatus: CryptoCurrencyStatus) { val validatorState = value.validatorState as? StakingStates.ValidatorState.Data - val validatorName = validatorState?.chosenValidator?.name ?: return + val validatorName = validatorState?.chosenTarget?.name ?: return analyticsEventHandler.send( Basic.TransactionSent( @@ -102,7 +102,7 @@ internal class StakingAnalyticSender( fun sendTransactionStakingClickedAnalytics(value: StakingUiState) { val validatorState = value.validatorState as? StakingStates.ValidatorState.Data - val validatorName = validatorState?.chosenValidator?.name ?: return + val validatorName = validatorState?.chosenTarget?.name ?: return analyticsEventHandler.send( StakingAnalyticsEvent.ButtonAction( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt index eaa7af9ae5..9c90294738 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt @@ -1,6 +1,5 @@ package com.tangem.features.staking.impl.deeplink -import arrow.core.getOrElse import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY @@ -8,7 +7,6 @@ import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.GetStakingAvailabilityUseCase -import com.tangem.domain.staking.GetYieldUseCase import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier @@ -28,7 +26,6 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( private val appRouter: AppRouter, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val getYieldUseCase: GetYieldUseCase, private val getStakingAvailabilityUseCase: GetStakingAvailabilityUseCase, ) : StakingDeepLinkHandler { @@ -73,19 +70,13 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( return@launch } - val isStakingEnabled = getStakingAvailabilityUseCase.invokeSync( + val availability = getStakingAvailabilityUseCase.invokeSync( userWalletId = selectedUserWalletId, cryptoCurrency = cryptoCurrency, ).getOrNull() - if (isStakingEnabled !is StakingAvailability.Available) { - return@launch - } - - val yield = getYieldUseCase.invoke( - cryptoCurrencyId = cryptoCurrency.id, - symbol = cryptoCurrency.symbol, - ).getOrElse { + val option = (availability as? StakingAvailability.Available)?.option + if (option == null) { Timber.e("Staking is unavailable for ${cryptoCurrency.name}") return@launch } @@ -94,7 +85,7 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( AppRoute.Staking( userWalletId = selectedUserWalletId, cryptoCurrency = cryptoCurrency, - yieldId = yield.id, + integrationId = option.integrationId, ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt index 7e79b03b94..9d21d90396 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt @@ -4,7 +4,7 @@ import com.tangem.common.ui.amountScreen.AmountScreenClickIntents import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.notifications.NotificationUM import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingTarget import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType import java.math.BigDecimal @@ -35,7 +35,7 @@ internal interface StakingClickIntents : AmountScreenClickIntents { fun openValidators() - fun onValidatorSelect(validator: Yield.Validator) + fun onTargetSelect(target: StakingTarget) fun openRewardsValidators() diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index fd67cc140c..a19ebcc6c7 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -45,11 +45,15 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.* import com.tangem.domain.staking.analytics.StakeScreenSource import com.tangem.domain.staking.analytics.StakingAnalyticsEvent +import com.tangem.domain.staking.model.P2PEthPoolIntegration +import com.tangem.domain.staking.model.StakeKitIntegration import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.model.StakingTarget import com.tangem.domain.staking.model.stakekit.StakingError -import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingAction +import com.tangem.domain.staking.repositories.P2PEthPoolRepository import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction import com.tangem.domain.staking.utils.getValidatorsCount @@ -134,6 +138,7 @@ internal class StakingModel @Inject constructor( private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val getActionsUseCase: GetActionsUseCase, private val getYieldUseCase: GetYieldUseCase, + private val p2pEthPoolRepository: P2PEthPoolRepository, private val checkAccountInitializedUseCase: CheckAccountInitializedUseCase, private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val getFeeUseCase: GetFeeUseCase, @@ -164,9 +169,18 @@ internal class StakingModel @Inject constructor( private val cryptoCurrencyId: CryptoCurrency.ID = params.cryptoCurrency.id private val userWalletId: UserWalletId = params.userWalletId - private val yield: Yield = runBlocking { - getYieldUseCase(params.yieldId).getOrElse { - error("yield must be not null") + private val integration: StakingIntegration = runBlocking { + when (val integrationId = params.integrationId) { + is StakingIntegrationID.StakeKit -> { + val yield = getYieldUseCase(integrationId.value).getOrElse { + error("yield must be not null") + } + StakeKitIntegration(integrationId, yield) + } + StakingIntegrationID.P2PEthPool -> { + val vaults = p2pEthPoolRepository.getVaults().getOrElse { emptyList() } + P2PEthPoolIntegration(integrationId, vaults) + } } } @@ -194,7 +208,7 @@ internal class StakingModel @Inject constructor( return invalidatePendingTransactionsUseCase( balanceItems = stakeKitBalance?.balance?.items.orEmpty(), stakingActions = stakingActions, - token = yield.token, + token = integration.token, ).getOrElse { emptyList() } } @@ -205,7 +219,7 @@ internal class StakingModel @Inject constructor( stakingBalanceUpdater.create( cryptoCurrencyStatus, userWallet, - yield, + integration, ) } @@ -213,7 +227,7 @@ internal class StakingModel @Inject constructor( stakingFeeTransactionLoader.create( cryptoCurrencyStatus = cryptoCurrencyStatus, userWallet = userWallet, - yield = yield, + integration = integration, ) } @@ -221,7 +235,7 @@ internal class StakingModel @Inject constructor( stakingTransactionLoader.create( cryptoCurrencyStatus = cryptoCurrencyStatus, userWallet = userWallet, - yield = yield, + integration = integration, isAmountSubtractAvailable = isAmountSubtractAvailable, ) } @@ -280,7 +294,7 @@ internal class StakingModel @Inject constructor( val hasNoYieldBalanceData = cryptoCurrencyStatus.value.stakingBalance !is StakingBalance.Data.StakeKit when { - isInitialInfoStep && noBalanceState && yield.allValidatorsFull && hasNoYieldBalanceData -> { + isInitialInfoStep && noBalanceState && integration.areAllTargetsFull && hasNoYieldBalanceData -> { stakingEventFactory.createStakingValidatorsUnavailableAlert() return@launch } @@ -293,12 +307,12 @@ internal class StakingModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, stakingApproval = stakingApproval, stakingAllowance = stakingAllowance, - yieldArgs = yield.args, + integration = integration, ).let(::add) - if (yield.args.enter.isPartialAmountDisabled) { + if (integration.isPartialAmountDisabled) { ValidatorSelectChangeTransformer( - selectedValidator = yield.preferredValidators.firstOrNull(), - yield = yield, + selectedTarget = integration.preferredTargets.firstOrNull(), + integration = integration, ).let(::add) SetAmountDataTransformer( clickIntents = this@StakingModel, @@ -313,7 +327,7 @@ internal class StakingModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, minimumTransactionAmount = minimumTransactionAmount, actionType = uiState.value.actionType, - yield = yield, + integration = integration, ).let(::add) } } @@ -327,7 +341,7 @@ internal class StakingModel @Inject constructor( override fun getFee() { stateController.update( SetConfirmationStateLoadingTransformer( - yield = yield, + integration = integration, appCurrency = appCurrency, cryptoCurrency = cryptoCurrencyStatus.currency, ), @@ -479,7 +493,7 @@ internal class StakingModel @Inject constructor( } override fun onAmountEnterClick() { - if (yield.preferredValidators.isEmpty()) { + if (integration.preferredTargets.isEmpty()) { stateController.updateEvent( StakingEvent.ShowAlert(StakingAlertUM.NoAvailableValidators), ) @@ -487,8 +501,8 @@ internal class StakingModel @Inject constructor( if (uiState.value.actionType is StakingActionCommonType.Enter) { stateController.updateAll( ValidatorSelectChangeTransformer( - selectedValidator = null, - yield = yield, + selectedTarget = null, + integration = integration, ), ) } @@ -502,7 +516,7 @@ internal class StakingModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, minimumTransactionAmount = minimumTransactionAmount, value = value, - yield = yield, + integration = integration, ), ) } @@ -518,7 +532,7 @@ internal class StakingModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, minimumTransactionAmount = minimumTransactionAmount, actionType = uiState.value.actionType, - yield = yield, + integration = integration, ), ) } @@ -539,16 +553,16 @@ internal class StakingModel @Inject constructor( stakingStateRouter.showValidators() } - override fun onValidatorSelect(validator: Yield.Validator) { + override fun onTargetSelect(target: StakingTarget) { analyticsEventHandler.send( StakingAnalyticsEvent.ValidatorChosen( - validator = validator.name, + validator = target.name, ), ) stateController.update( ValidatorSelectChangeTransformer( - selectedValidator = validator, - yield = yield, + selectedTarget = target, + integration = integration, ), ) } @@ -606,7 +620,7 @@ internal class StakingModel @Inject constructor( balanceType = activeStake.type, pendingActions = activeStake.pendingActions, balanceState = activeStake, - validator = activeStake.validator, + target = activeStake.target, amountValue = activeStake.cryptoValue, ) onNextClick(activeStake) @@ -619,7 +633,7 @@ internal class StakingModel @Inject constructor( balanceType = activeStake.type, pendingAction = action, balanceState = activeStake, - validator = activeStake.validator, + target = activeStake.target, amountValue = activeStake.cryptoValue, ) stateController.update(DismissBottomSheetStateTransformer) @@ -788,7 +802,7 @@ internal class StakingModel @Inject constructor( isSubtractAvailable = isAmountSubtractAvailable, feeError = feeError, stakingError = stakingError, - yield = yield, + integration = integration, ), ) } @@ -911,7 +925,7 @@ internal class StakingModel @Inject constructor( val validatorState = uiState.value.validatorState as? StakingStates.ValidatorState.Data val feeState = confirmationState?.feeState as? FeeState.Content - val validator = validatorState?.chosenValidator + val target = validatorState?.chosenTarget val feeAmount = feeState?.fee?.amount val amount = amountState?.amountTextField?.cryptoAmount saveBlockchainErrorUseCase( @@ -919,7 +933,7 @@ internal class StakingModel @Inject constructor( errorMessage = errorMessage, blockchainId = network.rawId, derivationPath = network.derivationPath.value, - destinationAddress = validator?.address.orEmpty(), + destinationAddress = target?.address.orEmpty(), tokenSymbol = (cryptoCurrencyStatus.currency as? CryptoCurrency.Token)?.symbol, amount = amount?.run { value?.toPlainString() + currencySymbol }.orEmpty(), fee = feeAmount?.run { value?.toPlainString() + currencySymbol }.orEmpty(), @@ -928,7 +942,7 @@ internal class StakingModel @Inject constructor( val email = FeedbackEmailType.StakingProblem( walletMetaInfo = metaInfo, - validatorName = validator?.name, + validatorName = target?.name, transactionTypes = transactionsInProgress.map { it.type.name }, unsignedTransactions = transactionsInProgress.map { it.unsignedTransaction }, ) @@ -1229,7 +1243,7 @@ internal class StakingModel @Inject constructor( stateController.updateAll( SetInitialDataStateTransformer( clickIntents = this@StakingModel, - yield = yield, + integration = integration, isAnyTokenStaked = isAnyTokenStaked, cryptoCurrencyStatus = status, userWalletProvider = Provider { userWallet }, @@ -1248,7 +1262,7 @@ internal class StakingModel @Inject constructor( balanceState: BalanceState, pendingActions: ImmutableList = persistentListOf(), pendingAction: PendingAction? = pendingActions.firstOrNull(), - validator: Yield.Validator?, + target: StakingTarget?, amountValue: String, ) { stateController.updateAll( @@ -1261,11 +1275,11 @@ internal class StakingModel @Inject constructor( pendingActions = pendingActions, pendingAction = pendingAction, stakingAllowance = stakingAllowance, - yieldArgs = yield.args, + integration = integration, ), ValidatorSelectChangeTransformer( - selectedValidator = validator, - yield = yield, + selectedTarget = target, + integration = integration, ), SetAmountDataTransformer( clickIntents = this, @@ -1280,7 +1294,7 @@ internal class StakingModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, value = amountValue, minimumTransactionAmount = minimumTransactionAmount, - yield = yield, + integration = integration, ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt index 4be332b317..fa590eb0bc 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt @@ -6,7 +6,7 @@ import com.tangem.domain.models.staking.BalanceType import com.tangem.domain.models.staking.PendingAction import com.tangem.domain.models.staking.PendingActionConstraints import com.tangem.domain.models.staking.RewardBlockType -import com.tangem.domain.staking.model.stakekit.* +import com.tangem.domain.staking.model.StakingTarget import kotlinx.collections.immutable.ImmutableList import java.math.BigDecimal @@ -35,10 +35,10 @@ internal data class BalanceState( val fiatAmount: BigDecimal?, val formattedFiatAmount: TextReference, val rawCurrencyId: String?, - val validator: Yield.Validator?, + val target: StakingTarget?, val pendingActions: ImmutableList, val isPending: Boolean, - val validatorAddress: String?, + val targetAddress: String?, ) @Immutable diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt index 56f8f60341..698d8f8181 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt @@ -10,7 +10,7 @@ import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.domain.models.staking.PendingAction -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingTarget import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType import com.tangem.features.staking.impl.presentation.state.events.StakingEvent @@ -97,9 +97,9 @@ internal sealed class StakingStates { override val isPrimaryButtonEnabled: Boolean, override val isClickable: Boolean, val isVisibleOnConfirmation: Boolean, - val chosenValidator: Yield.Validator, - val activeValidator: Yield.Validator?, - val availableValidators: List, + val chosenTarget: StakingTarget, + val activeTarget: StakingTarget?, + val availableTargets: List, ) : ValidatorState() data class Empty( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt index 20230aecef..1b4a71149b 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt @@ -12,7 +12,7 @@ import com.tangem.domain.models.staking.BalanceType import com.tangem.domain.models.staking.BalanceType.Companion.isClickable import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.action.StakingActionType -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.utils.getRewardStakingBalance import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.BalanceState @@ -29,24 +29,24 @@ import java.util.Calendar internal class BalanceItemConverter( private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val appCurrencyProvider: Provider, - private val yield: Yield, + private val integration: StakingIntegration, ) : Converter { override fun convert(value: BalanceItem): BalanceState? { val appCurrency = appCurrencyProvider() val cryptoCurrency = cryptoCurrencyStatus.currency - val validator = yield.validators.firstOrNull { + val target = integration.targets.firstOrNull { value.validatorAddress?.contains(it.address, ignoreCase = true) == true } val cryptoAmount = value.getBalanceValue() val fiatAmount = cryptoCurrencyStatus.value.fiatRate?.times(cryptoAmount) - val title = value.type.getTitle(validator?.name) + val title = value.type.getTitle(target?.name) return title?.let { BalanceState( groupId = value.groupId, - validator = validator, + target = target, title = title, subtitle = getSubtitle(value), type = value.type, @@ -68,7 +68,7 @@ internal class BalanceItemConverter( pendingActions = value.pendingActions.toPersistentList(), isClickable = value.isClickable(), isPending = value.isPending, - validatorAddress = value.validatorAddress, + targetAddress = value.validatorAddress, ) } } @@ -108,7 +108,7 @@ internal class BalanceItemConverter( resourceReference(R.string.staking_tap_to_unlock) } BalanceType.PREPARING -> { - val warmupPeriod = yield.metadata.warmupPeriod.days + val warmupPeriod = integration.warmupPeriodDays combinedReference( resourceReference(R.string.staking_details_warmup_period), stringReference(" "), @@ -124,7 +124,7 @@ internal class BalanceItemConverter( } private fun getUnbondingDate(date: Instant?): TextReference? { - val unbondingPeriod = yield.metadata.cooldownPeriod?.days ?: return null + val unbondingPeriod = integration.cooldownPeriodDays ?: return null if (date == null) { return combinedReference( resourceReference(R.string.staking_details_unbonding_period), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt index 68e09c30c5..e81cb4512e 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt @@ -10,7 +10,8 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.staking.BalanceItem import com.tangem.domain.models.staking.BalanceType import com.tangem.domain.models.staking.StakingBalance -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingIntegration +import com.tangem.domain.staking.model.StakingTarget import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.utils.Provider @@ -21,7 +22,7 @@ import java.math.BigDecimal internal class RewardsValidatorStateConverter( private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val appCurrencyProvider: Provider, - private val yield: Yield, + private val integration: StakingIntegration, ) : Converter { override fun convert(value: Unit): StakingStates.RewardsValidatorsState { val stakingBalance = cryptoCurrencyStatus.value.stakingBalance @@ -42,13 +43,13 @@ internal class RewardsValidatorStateConverter( private fun List.mapRewardBalances(cryptoCurrencyStatus: CryptoCurrencyStatus) = this.mapNotNull { balance -> - val validator = yield.validators.firstOrNull { + val target = integration.targets.firstOrNull { it.address.contains(balance.validatorAddress.orEmpty(), ignoreCase = true) } val cryptoValue = balance.amount val fiatValue = cryptoCurrencyStatus.value.fiatRate?.times(cryptoValue) - validator?.toBalanceState( + target?.toBalanceState( balance = balance, cryptoCurrencyStatus = cryptoCurrencyStatus, cryptoValue = cryptoValue, @@ -56,7 +57,7 @@ internal class RewardsValidatorStateConverter( ) } - private fun Yield.Validator.toBalanceState( + private fun StakingTarget.toBalanceState( balance: BalanceItem, cryptoCurrencyStatus: CryptoCurrencyStatus, cryptoValue: BigDecimal, @@ -80,7 +81,7 @@ internal class RewardsValidatorStateConverter( return BalanceState( groupId = balance.groupId, - validator = this, + target = this, title = stringReference(this.name), subtitle = null, cryptoValue = cryptoValue.parseBigDecimal(cryptoCurrency.decimals), @@ -93,7 +94,7 @@ internal class RewardsValidatorStateConverter( isClickable = true, type = balance.type, isPending = balance.isPending, - validatorAddress = balance.validatorAddress, + targetAddress = balance.validatorAddress, ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt index 8c118c5572..1c1b278c34 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt @@ -11,7 +11,7 @@ import com.tangem.domain.models.staking.BalanceType import com.tangem.domain.models.staking.RewardBlockType import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.action.StakingActionType -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.utils.getRewardStakingBalance import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState import com.tangem.features.staking.impl.presentation.state.YieldReward @@ -25,11 +25,11 @@ internal class YieldBalancesConverter( private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val appCurrencyProvider: Provider, private val balancesToShowProvider: Provider>, - private val yield: Yield, + private val integration: StakingIntegration, ) : Converter { private val balanceItemConverter by lazy(LazyThreadSafetyMode.NONE) { - BalanceItemConverter(cryptoCurrencyStatus, appCurrencyProvider, yield) + BalanceItemConverter(cryptoCurrencyStatus, appCurrencyProvider, integration) } override fun convert(value: Unit): InnerYieldBalanceState { diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt index 977585131e..4c8bc0ceae 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingBalanceUpdater.kt @@ -4,7 +4,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.staking.FetchActionsUseCase import com.tangem.domain.staking.FetchStakingYieldBalanceUseCase -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus import com.tangem.domain.tokens.FetchCurrencyStatusUseCase import com.tangem.domain.tokens.FetchPendingTransactionsUseCase @@ -27,7 +27,7 @@ internal class StakingBalanceUpdater @AssistedInject constructor( @DelayedWork private val coroutineScope: CoroutineScope, @Assisted private val userWallet: UserWallet, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, - @Assisted private val yield: Yield, + @Assisted private val integration: StakingIntegration, ) { fun updateAfterTransaction() { coroutineScope.launch { @@ -105,7 +105,7 @@ internal class StakingBalanceUpdater @AssistedInject constructor( fetchActionsUseCase( userWalletId = userWallet.walletId, cryptoCurrency = cryptoCurrencyStatus.currency, - networkType = yield.token.network, + networkType = integration.token.network, stakingActionStatus = StakingActionStatus.PROCESSING, ) } @@ -115,7 +115,7 @@ internal class StakingBalanceUpdater @AssistedInject constructor( fun create( cryptoCurrencyStatus: CryptoCurrencyStatus, userWallet: UserWallet, - yield: Yield, + integration: StakingIntegration, ): StakingBalanceUpdater } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt index 05373bb1f8..9e5ad3188c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt @@ -12,12 +12,11 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.staking.PendingAction import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.staking.EstimateGasUseCase +import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.stakekit.StakingError -import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.staking.model.stakekit.transaction.ActionParams import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate -import com.tangem.domain.tokens.model.staking.getCurrentToken import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase @@ -45,7 +44,7 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, @Assisted private val userWallet: UserWallet, - @Assisted private val yield: Yield, + @Assisted private val integration: StakingIntegration, ) { suspend fun getFee( @@ -58,9 +57,9 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data ?: error("Illegal state") - val validatorAddress = (state.validatorState as? StakingStates.ValidatorState.Data)?.chosenValidator?.address - ?: state.balanceState?.validatorAddress - ?: error("No validator address provided") + val validatorAddress = (state.validatorState as? StakingStates.ValidatorState.Data)?.chosenTarget?.address + ?: state.balanceState?.targetAddress + ?: error("No target address provided") val amount = (state.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value ?: error("No amount provided") @@ -170,11 +169,11 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( network = cryptoCurrencyStatus.currency.network, params = ActionParams( actionCommonType = stateController.value.actionType, - integrationId = yield.id, + integrationId = integration.integrationId.value, amount = amount, address = sourceAddress, validatorAddress = validatorAddress, - token = yield.getCurrentToken(cryptoCurrencyStatus.currency.id.rawCurrencyId), + token = integration.getCurrentToken(cryptoCurrencyStatus.currency.id.rawCurrencyId), passthrough = action?.passthrough, type = action?.type, ), @@ -234,7 +233,7 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( fun create( cryptoCurrencyStatus: CryptoCurrencyStatus, userWallet: UserWallet, - yield: Yield, + integration: StakingIntegration, ): StakingFeeTransactionLoader } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt index ef5030f334..f54ed73203 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt @@ -13,15 +13,14 @@ import com.tangem.domain.staking.GetConstructedStakingTransactionUseCase import com.tangem.domain.staking.GetStakingTransactionsUseCase import com.tangem.domain.staking.SaveUnsubmittedHashUseCase import com.tangem.domain.staking.SubmitHashUseCase +import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.SubmitHashData import com.tangem.domain.staking.model.stakekit.StakingError -import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.staking.model.stakekit.transaction.ActionParams import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionStatus import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionType -import com.tangem.domain.tokens.model.staking.getCurrentToken import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase @@ -56,12 +55,12 @@ internal class StakingTransactionSender @AssistedInject constructor( private val isFeeApproximateUseCase: IsFeeApproximateUseCase, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, @Assisted private val userWallet: UserWallet, - @Assisted private val yield: Yield, + @Assisted private val integration: StakingIntegration, @Assisted private val isAmountSubtractAvailable: Boolean, ) { private val balanceUpdater: StakingBalanceUpdater - get() = stakingBalanceUpdater.create(cryptoCurrencyStatus, userWallet, yield) + get() = stakingBalanceUpdater.create(cryptoCurrencyStatus, userWallet, integration) suspend fun constructAndSendTransactions( onConstructSuccess: (List) -> Unit, @@ -193,7 +192,7 @@ internal class StakingTransactionSender @AssistedInject constructor( val amountState = state.amountState as? AmountState.Data ?: error("No amount provided") - val validatorAddress = validatorState.chosenValidator.address + val validatorAddress = validatorState.chosenTarget.address val amount = getAmount(amountState, fee, confirmationState.reduceAmountBy) return getStakingTransactionsUseCase( @@ -201,11 +200,11 @@ internal class StakingTransactionSender @AssistedInject constructor( network = cryptoCurrencyStatus.currency.network, params = ActionParams( actionCommonType = state.actionType, - integrationId = yield.id, + integrationId = integration.integrationId.value, amount = amount, address = defaultAddress, validatorAddress = validatorAddress, - token = yield.getCurrentToken(cryptoCurrencyStatus.currency.id.rawCurrencyId), + token = integration.getCurrentToken(cryptoCurrencyStatus.currency.id.rawCurrencyId), passthrough = action?.passthrough, type = action?.type, ), @@ -306,7 +305,7 @@ internal class StakingTransactionSender @AssistedInject constructor( fun create( cryptoCurrencyStatus: CryptoCurrencyStatus, userWallet: UserWallet, - yield: Yield, + integration: StakingIntegration, isAmountSubtractAvailable: Boolean, ): StakingTransactionSender } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt index 1b6868f841..bcad264866 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt @@ -7,6 +7,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.staking.BalanceType import com.tangem.domain.models.staking.RewardBlockType import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.toStakingTarget import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState @@ -81,7 +82,7 @@ internal object InitialStakingStatePreview { fiatAmount = null, formattedFiatAmount = stringReference("100 $"), rawCurrencyId = null, - validator = Yield.Validator( + target = Yield.Validator( address = "address", status = Yield.Validator.ValidatorStatus.ACTIVE, name = "Binance", @@ -92,13 +93,13 @@ internal object InitialStakingStatePreview { votingPower = null, preferred = false, isStrategicPartner = false, - ), + ).toStakingTarget(), pendingActions = persistentListOf(), isClickable = true, type = BalanceType.STAKED, subtitle = null, isPending = false, - validatorAddress = "", + targetAddress = "", ), ), ), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ValidatorStatePreviewData.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ValidatorStatePreviewData.kt index b65729f59a..8ce4a816d3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ValidatorStatePreviewData.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ValidatorStatePreviewData.kt @@ -1,7 +1,11 @@ package com.tangem.features.staking.impl.presentation.state.previewdata +import com.tangem.domain.staking.model.common.RewardInfo +import com.tangem.domain.staking.model.common.RewardType +import com.tangem.domain.staking.model.StakingTarget import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.Yield.Validator.ValidatorStatus +import com.tangem.domain.staking.model.toStakingTarget import com.tangem.features.staking.impl.presentation.state.StakingStates import java.math.BigDecimal @@ -13,9 +17,9 @@ internal object ValidatorStatePreviewData { status = ValidatorStatus.ACTIVE, name = "Luganodes", image = "https://assets.stakek.it/validators/luganodes.png", - rewardInfo = Yield.RewardInfo( + rewardInfo = RewardInfo( rate = BigDecimal("0.054823398040640445"), - type = Yield.RewardType.APR, + type = RewardType.APR, ), commission = 0.1, stakedBalance = "355544384.45009977", @@ -29,9 +33,9 @@ internal object ValidatorStatePreviewData { status = ValidatorStatus.ACTIVE, name = "InfStones", image = "https://assets.stakek.it/validators/infstones.png", - rewardInfo = Yield.RewardInfo( + rewardInfo = RewardInfo( rate = BigDecimal("0.057786472172836965"), - type = Yield.RewardType.APR, + type = RewardType.APR, ), commission = 0.05, stakedBalance = "12495684.05643019", @@ -45,9 +49,9 @@ internal object ValidatorStatePreviewData { status = ValidatorStatus.ACTIVE, name = "Kiln", image = "https://assets.stakek.it/validators/kiln.png", - rewardInfo = Yield.RewardInfo( + rewardInfo = RewardInfo( rate = BigDecimal("0.057786472172836965"), - type = Yield.RewardType.APR, + type = RewardType.APR, ), commission = 0.05, stakedBalance = "85400369.96393165", @@ -58,11 +62,13 @@ internal object ValidatorStatePreviewData { ), ) + private val targetList: List = validatorList.map { it.toStakingTarget() } + val validatorState = StakingStates.ValidatorState.Data( - availableValidators = validatorList, - chosenValidator = validatorList.first(), + availableTargets = targetList, + chosenTarget = targetList.first(), isPrimaryButtonEnabled = true, - activeValidator = null, + activeTarget = null, isClickable = true, isVisibleOnConfirmation = true, ) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt index 1b90c1dc49..5b91544cd2 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt @@ -3,7 +3,7 @@ package com.tangem.features.staking.impl.presentation.state.stub import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.notifications.NotificationUM import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingTarget import com.tangem.features.staking.impl.presentation.model.StakingClickIntents import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType @@ -40,7 +40,7 @@ internal object StakingClickIntentsStub : StakingClickIntents { override fun openValidators() {} - override fun onValidatorSelect(validator: Yield.Validator) {} + override fun onTargetSelect(target: StakingTarget) {} override fun openRewardsValidators() {} diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInitTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInitTransformer.kt index 7fb48b2a61..31471a94ec 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInitTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInitTransformer.kt @@ -5,7 +5,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.staking.PendingAction import com.tangem.domain.models.staking.action.StakingActionType import com.tangem.domain.staking.model.StakingApproval -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.presentation.state.* import com.tangem.features.staking.impl.presentation.state.utils.isCompositePendingActions @@ -24,7 +24,7 @@ internal class SetConfirmationStateInitTransformer( private val stakingApproval: StakingApproval, private val stakingAllowance: BigDecimal, private val cryptoCurrencyStatus: CryptoCurrencyStatus, - private val yieldArgs: Yield.Args, + private val integration: StakingIntegration, private val pendingActions: ImmutableList? = null, private val pendingAction: PendingAction? = pendingActions?.firstOrNull(), ) : Transformer { @@ -66,7 +66,7 @@ internal class SetConfirmationStateInitTransformer( } private fun getActionType(prevState: StakingUiState): StakingActionCommonType { - val isPartialEnterAmountDisabled = yieldArgs.enter.isPartialAmountDisabled + val isPartialEnterAmountDisabled = integration.isPartialAmountDisabled val isPartialExitAmountDisabled = isPartiallyUnstakeDisabled(prevState) return when { isEnter -> StakingActionCommonType.Enter(isPartialEnterAmountDisabled) @@ -87,12 +87,12 @@ internal class SetConfirmationStateInitTransformer( private fun isPartiallyUnstakeDisabled(state: StakingUiState): Boolean { val isSolana = BlockchainUtils.isSolana(state.cryptoCurrencyBlockchainId) - val isValidatorPreferred = balanceState?.validator?.preferred == true + val isTargetPreferred = balanceState?.target?.isPreferred == true - return if (isSolana && !isValidatorPreferred) { + return if (isSolana && !isTargetPreferred) { true } else { - yieldArgs.exit?.isPartialAmountDisabled == true + integration.exitArgs?.isPartialAmountDisabled == true } } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateLoadingTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateLoadingTransformer.kt index 5809667c31..4ec20302ce 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateLoadingTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateLoadingTransformer.kt @@ -8,7 +8,7 @@ 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.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.FeeState @@ -18,7 +18,7 @@ import com.tangem.features.staking.impl.presentation.state.utils.getRewardSchedu import com.tangem.utils.transformer.Transformer internal class SetConfirmationStateLoadingTransformer( - private val yield: Yield, + private val integration: StakingIntegration, private val appCurrency: AppCurrency, private val cryptoCurrency: CryptoCurrency, ) : Transformer { @@ -48,7 +48,7 @@ internal class SetConfirmationStateLoadingTransformer( ) } val rewardSchedule = getRewardScheduleText( - rewardSchedule = yield.metadata.rewardSchedule, + rewardSchedule = integration.rewardSchedule, networkId = cryptoCurrency.network.rawId, decapitalize = true, ) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index 2f55f146f8..86fdcfd533 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -19,6 +19,9 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.staking.BalanceItem import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.staking.model.common.RewardType +import com.tangem.domain.staking.model.StakingIntegration +import com.tangem.domain.staking.model.StakingTarget import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.model.StakingClickIntents @@ -41,7 +44,7 @@ import java.math.BigDecimal @Suppress("LongParameterList") internal class SetInitialDataStateTransformer( private val clickIntents: StakingClickIntents, - private val yield: Yield, + private val integration: StakingIntegration, private val isAnyTokenStaked: Boolean, private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val userWalletProvider: Provider, @@ -55,7 +58,7 @@ internal class SetInitialDataStateTransformer( private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) private val rewardsValidatorStateConverter by lazy(LazyThreadSafetyMode.NONE) { - RewardsValidatorStateConverter(cryptoCurrencyStatus, appCurrencyProvider, yield) + RewardsValidatorStateConverter(cryptoCurrencyStatus, appCurrencyProvider, integration) } private val yieldBalancesConverter by lazy(LazyThreadSafetyMode.NONE) { @@ -63,7 +66,7 @@ internal class SetInitialDataStateTransformer( cryptoCurrencyStatus = cryptoCurrencyStatus, appCurrencyProvider = appCurrencyProvider, balancesToShowProvider = balancesToShowProvider, - yield = yield, + integration = integration, ) } @@ -116,27 +119,27 @@ internal class SetInitialDataStateTransformer( } private fun createAnnualPercentageItem(): RoundedListWithDividersItemData { - val validators = yield.preferredValidators - val rateRangeInfo = getPercentageRange(validators) + val targets = integration.preferredTargets + val rateRangeInfo = getPercentageRange(targets) return RoundedListWithDividersItemData( id = R.string.staking_details_annual_percentage_rate, startText = getRateStartText(rateRangeInfo.first), endText = rateRangeInfo.second, iconClick = { when (rateRangeInfo.first) { - Yield.RewardType.APR -> clickIntents.onInfoClick(InfoType.ANNUAL_PERCENTAGE_RATE) - Yield.RewardType.APY -> clickIntents.onInfoClick(InfoType.ANNUAL_PERCENTAGE_YIELD) - Yield.RewardType.UNKNOWN -> {} + RewardType.APR -> clickIntents.onInfoClick(InfoType.ANNUAL_PERCENTAGE_RATE) + RewardType.APY -> clickIntents.onInfoClick(InfoType.ANNUAL_PERCENTAGE_YIELD) + RewardType.UNKNOWN -> {} } }, isEndTextHighlighted = false, ) } - private fun getRateStartText(rewardType: Yield.RewardType): TextReference { + private fun getRateStartText(rewardType: RewardType): TextReference { return when (rewardType) { - Yield.RewardType.APR -> TextReference.Res(R.string.staking_details_annual_percentage_rate) - Yield.RewardType.APY -> TextReference.Res(R.string.staking_details_annual_percentage_yield) + RewardType.APR -> TextReference.Res(R.string.staking_details_annual_percentage_rate) + RewardType.APY -> TextReference.Res(R.string.staking_details_annual_percentage_yield) else -> TextReference.EMPTY } } @@ -153,7 +156,7 @@ internal class SetInitialDataStateTransformer( } private fun createUnbondingPeriodItem(): RoundedListWithDividersItemData? { - val cooldownPeriodDays = yield.metadata.cooldownPeriod?.days ?: return null + val cooldownPeriodDays = integration.cooldownPeriodDays ?: return null return RoundedListWithDividersItemData( id = R.string.staking_details_unbonding_period, startText = TextReference.Res(R.string.staking_details_unbonding_period), @@ -169,7 +172,7 @@ internal class SetInitialDataStateTransformer( private fun createMinimumRequirementItem( cryptoCurrencyStatus: CryptoCurrencyStatus, ): RoundedListWithDividersItemData? { - val minimumCryptoAmount = yield.args.enter.args[Yield.Args.ArgType.AMOUNT]?.minimum ?: return null + val minimumCryptoAmount = integration.enterMinimumAmount ?: return null val blockchainId = cryptoCurrencyStatus.currency.network.rawId if (!showMinimumRequirementInfo(blockchainId)) return null @@ -183,7 +186,7 @@ internal class SetInitialDataStateTransformer( } private fun createRewardClaimingItem(): RoundedListWithDividersItemData? { - val rewardClaiming = yield.metadata.rewardClaiming + val rewardClaiming = integration.rewardClaiming val endTextId = rewardClaimingResources[rewardClaiming] ?: return null return RoundedListWithDividersItemData( @@ -195,7 +198,7 @@ internal class SetInitialDataStateTransformer( } private fun createWarmupPeriodItem(): RoundedListWithDividersItemData? { - val warmupPeriodDays = yield.metadata.warmupPeriod.days + val warmupPeriodDays = integration.warmupPeriodDays if (warmupPeriodDays == 0) return null return RoundedListWithDividersItemData( @@ -212,7 +215,7 @@ internal class SetInitialDataStateTransformer( private fun createRewardScheduleItem(): RoundedListWithDividersItemData? { val endTextReference = getRewardScheduleText( - rewardSchedule = yield.metadata.rewardSchedule, + rewardSchedule = integration.rewardSchedule, networkId = cryptoCurrencyStatus.currency.network.rawId, decapitalize = false, ) ?: return null @@ -252,15 +255,19 @@ internal class SetInitialDataStateTransformer( ) } - private fun getPercentageRange(validators: List): Pair { - if (validators.isEmpty()) { - return Yield.RewardType.APR to stringReference(DASH_SIGN) + private fun getPercentageRange(targets: List): Pair { + if (targets.isEmpty()) { + return RewardType.APR to stringReference(DASH_SIGN) } - val rewardInfos = validators - .filter { it.preferred } + val rewardInfos = targets + .filter { it.isPreferred } .takeIf { it.isNotEmpty() } ?.mapNotNull { it.rewardInfo } - ?: validators.mapNotNull { it.rewardInfo } + ?: targets.mapNotNull { it.rewardInfo } + + if (rewardInfos.isEmpty()) { + return RewardType.APR to stringReference(DASH_SIGN) + } val infoWithMinRate = rewardInfos.minBy { it.rate } val infoWithMaxRate = rewardInfos.maxBy { it.rate } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt index 0603ec9e7f..9c9c0d1595 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt @@ -4,7 +4,7 @@ import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTransformer import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.utils.transformer.Transformer @@ -13,7 +13,7 @@ internal class AmountChangeStateTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val minimumTransactionAmount: EnterAmountBoundary?, private val value: String, - private val yield: Yield, + private val integration: StakingIntegration, ) : Transformer { private val maxEnterAmountConverter = MaxEnterAmountConverter() @@ -41,7 +41,7 @@ internal class AmountChangeStateTransformer( amountState = AmountRequirementStateTransformer( cryptoCurrencyStatus = cryptoCurrencyStatus, maxAmount = maxEnterAmount, - yield = yield, + integration = integration, actionType = prevState.actionType, ).transform(updatedAmountState), ) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt index cee87196ee..94476c6be9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt @@ -4,7 +4,7 @@ import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter import com.tangem.common.ui.amountScreen.converters.field.AmountFieldSetMaxAmountTransformer import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.utils.transformer.Transformer @@ -13,7 +13,7 @@ internal class AmountMaxValueStateTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val minimumTransactionAmount: EnterAmountBoundary?, private val actionType: StakingActionCommonType, - private val yield: Yield, + private val integration: StakingIntegration, ) : Transformer { private val maxEnterAmountConverter = MaxEnterAmountConverter() @@ -38,7 +38,7 @@ internal class AmountMaxValueStateTransformer( amountState = AmountRequirementStateTransformer( maxAmount = maxEnterAmount, cryptoCurrencyStatus = cryptoCurrencyStatus, - yield = yield, + integration = integration, actionType = prevState.actionType, ).transform(updatedAmountState), ) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt index f24052ac95..8af103c0f1 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt @@ -12,6 +12,7 @@ import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.stakekit.AddressArgument import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType @@ -26,7 +27,7 @@ import java.math.RoundingMode internal class AmountRequirementStateTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val maxAmount: EnterAmountBoundary, - private val yield: Yield, + private val integration: StakingIntegration, private val actionType: StakingActionCommonType, ) : Transformer { override fun transform(prevState: AmountState): AmountState { @@ -96,11 +97,11 @@ internal class AmountRequirementStateTransformer( return when (actionType) { is StakingActionCommonType.Enter -> { - val enterRequirements = yield.args.enter.args[Yield.Args.ArgType.AMOUNT] + val enterRequirements = integration.enterArgs?.args?.get(Yield.Args.ArgType.AMOUNT) enterRequirements?.getError(amountDecimal, R.string.staking_amount_requirement_error) } is StakingActionCommonType.Exit -> { - val exitRequirements = yield.args.exit?.args?.get(Yield.Args.ArgType.AMOUNT) + val exitRequirements = integration.exitArgs?.args?.get(Yield.Args.ArgType.AMOUNT) exitRequirements?.getError(amountDecimal, R.string.staking_unstake_amount_requirement_error) } else -> null diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt index f0b2ca6298..0dec39a031 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt @@ -37,7 +37,7 @@ internal class ShowApprovalBottomSheetTransformer( val fee = feeState.fee ?: return prevState val walletAddress = cryptoCurrencyValue.networkAddress?.defaultAddress?.value.orEmpty() - val validatorAddress = validatorState.chosenValidator.address + val targetAddress = validatorState.chosenTarget.address val feeCryptoValue = fee.amount.value.format { crypto(fee.amount.currencySymbol, fee.amount.decimals) } @@ -57,7 +57,7 @@ internal class ShowApprovalBottomSheetTransformer( amount = amountState.amountTextField.value, approveType = ApproveType.UNLIMITED, walletAddress = walletAddress, - spenderAddress = validatorAddress, + spenderAddress = targetAddress, fee = resourceReference( R.string.common_crypto_fiat_format, wrappedList(feeCryptoValue, feeFiatValue), 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 45351b95ed..9912043b5d 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 @@ -15,9 +15,9 @@ import com.tangem.core.ui.extensions.stringReference 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.staking.model.StakingIntegration import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.model.stakekit.StakingErrors -import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning @@ -47,12 +47,12 @@ internal class AddStakingNotificationsTransformer( private val stakingError: StakingError?, private val currencyCheck: CryptoCurrencyCheck, private val isSubtractAvailable: Boolean, - private val yield: Yield, + private val integration: StakingIntegration, ) : Transformer { private val stakingInfoNotificationsFactory = StakingInfoNotificationsFactory( cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider, - yield = yield, + integration = integration, isSubtractAvailable = isSubtractAvailable, ) @@ -77,7 +77,7 @@ internal class AddStakingNotificationsTransformer( isSubtractAvailable = isSubtractAvailable, reduceAmountBy = reduceAmountBy, ) - val minimumRequirement = yield.args.enter.args[Yield.Args.ArgType.AMOUNT]?.minimum.orZero() + val minimumRequirement = integration.enterMinimumAmount.orZero() val sendingAmount = if (isEnterAction) { checkAndCalculateSubtractedAmount( isAmountSubtractAvailable = isSubtractAvailable, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt index bdda0a7ac5..7b6e21df7d 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt @@ -8,6 +8,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.staking.BalanceType import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.action.StakingActionType +import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.R @@ -26,7 +27,7 @@ import java.math.BigDecimal internal class StakingInfoNotificationsFactory( private val cryptoCurrencyStatusProvider: Provider, - private val yield: Yield, + private val integration: StakingIntegration, private val isSubtractAvailable: Boolean, ) { @@ -94,7 +95,7 @@ internal class StakingInfoNotificationsFactory( resourceReference(R.string.staking_notification_withdraw_text) } StakingActionType.UNLOCK_LOCKED -> { - val cooldownPeriodDays = yield.metadata.cooldownPeriod?.days + val cooldownPeriodDays = integration.cooldownPeriodDays if (cooldownPeriodDays != null) { resourceReference(R.string.staking_unlocked_locked) to resourceReference( R.string.staking_notification_unlock_text, @@ -213,7 +214,7 @@ internal class StakingInfoNotificationsFactory( if (prevState.actionType !is StakingActionCommonType.Exit) return val maxAmount = prevState.balanceState?.cryptoAmount ?: return - val exitRequirements = yield.args.exit?.args?.get(Yield.Args.ArgType.AMOUNT) ?: return + val exitRequirements = integration.exitArgs?.args?.get(Yield.Args.ArgType.AMOUNT) ?: return val amountLeft = maxAmount - actionAmount val isNotEnoughLeft = !amountLeft.isZero() && amountLeft < exitRequirements.minimum.orZero() @@ -224,7 +225,7 @@ internal class StakingInfoNotificationsFactory( } private fun MutableList.addUnstakeInfoNotification() { - val cooldownPeriodDays = yield.metadata.cooldownPeriod?.days + val cooldownPeriodDays = integration.cooldownPeriodDays val cryptoCurrencyNetworkIdValue = cryptoCurrencyStatusProvider().currency.network.rawId if (cooldownPeriodDays != null) { @@ -248,18 +249,17 @@ internal class StakingInfoNotificationsFactory( val initialInfoState = prevState.initialInfoState as? StakingStates.InitialInfoState.Data val stakingBalances = (initialInfoState?.yieldBalance as? InnerYieldBalanceState.Data)?.balances - val validatorAddress = prevState.balanceState?.validator?.address ?: return + val targetAddress = prevState.balanceState?.target?.address ?: return - val stakesCountWithCertainValidator = stakingBalances.orEmpty() - .filter { - it.type == BalanceType.STAKED || - it.type == BalanceType.PREPARING || - it.type == BalanceType.UNSTAKED + val stakesCountWithCertainTarget = stakingBalances.orEmpty() + .count { state -> + (state.type == BalanceType.STAKED || + state.type == BalanceType.PREPARING || + state.type == BalanceType.UNSTAKED) && + state.target?.address == targetAddress } - .filter { it.validator?.address == validatorAddress } - .size - if (stakesCountWithCertainValidator > 1) { + if (stakesCountWithCertainTarget > 1) { add( StakingNotification.Info.Ordinary( title = resourceReference(R.string.staking_notification_ton_have_to_unstake_all_title), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt index c940c12e8a..ddaad3e60e 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt @@ -1,6 +1,7 @@ package com.tangem.features.staking.impl.presentation.state.transformers.validator -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingIntegration +import com.tangem.domain.staking.model.StakingTarget import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.models.staking.action.StakingActionType import com.tangem.features.staking.impl.presentation.state.StakingStates @@ -9,8 +10,8 @@ import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.utils.transformer.Transformer internal class ValidatorSelectChangeTransformer( - private val yield: Yield, - private val selectedValidator: Yield.Validator?, + private val integration: StakingIntegration, + private val selectedTarget: StakingTarget?, ) : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { @@ -23,27 +24,27 @@ internal class ValidatorSelectChangeTransformer( val isFromInfoScreen = prevState.currentStep == StakingStep.InitialInfo val isVoteLocked = confirmationState?.pendingAction?.type == StakingActionType.VOTE_LOCKED - val activeValidator = selectedValidator.takeIf { isFromInfoScreen && isRestake } - ?: validatorState?.activeValidator - val filteredValidators = yield.preferredValidators.filterNot { it == activeValidator } + val activeTarget = selectedTarget.takeIf { isFromInfoScreen && isRestake } + ?: validatorState?.activeTarget + val filteredTargets = integration.preferredTargets.filterNot { it == activeTarget } - val selectedValidator = if (isRestake && isFromInfoScreen) { - filteredValidators.firstOrNull() + val selectedTarget = if (isRestake && isFromInfoScreen) { + filteredTargets.firstOrNull() } else { - selectedValidator + selectedTarget } - if (selectedValidator == null && yield.preferredValidators.isEmpty()) { + if (selectedTarget == null && integration.preferredTargets.isEmpty()) { return prevState } return prevState.copy( validatorState = StakingStates.ValidatorState.Data( - chosenValidator = selectedValidator ?: yield.preferredValidators.first(), - availableValidators = filteredValidators, + chosenTarget = selectedTarget ?: integration.preferredTargets.first(), + availableTargets = filteredTargets, isPrimaryButtonEnabled = true, - isClickable = yield.preferredValidators.size > 1, - activeValidator = activeValidator, + isClickable = integration.preferredTargets.size > 1, + activeTarget = activeTarget, isVisibleOnConfirmation = isEnter || isRestake || isVoteLocked, ), ) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt index 454a1aa5fb..ba5cc07eee 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt @@ -1,6 +1,7 @@ package com.tangem.features.staking.impl.presentation.state.utils import com.tangem.core.ui.extensions.* +import com.tangem.domain.staking.model.common.RewardType import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.COSMOS_SCHEDULE @@ -52,18 +53,18 @@ internal fun getRewardScheduleText( } } -internal fun getRewardTypeShortText(rewardType: Yield.RewardType): TextReference { +internal fun getRewardTypeShortText(rewardType: RewardType): TextReference { return when (rewardType) { - Yield.RewardType.APR -> TextReference.Res(R.string.staking_details_apr) - Yield.RewardType.APY -> TextReference.Res(R.string.staking_details_apy) + RewardType.APR -> TextReference.Res(R.string.staking_details_apr) + RewardType.APY -> TextReference.Res(R.string.staking_details_apy) else -> TextReference.EMPTY } } -internal fun getRewardTypeLongText(rewardType: Yield.RewardType): TextReference { +internal fun getRewardTypeLongText(rewardType: RewardType): TextReference { return when (rewardType) { - Yield.RewardType.APR -> TextReference.Res(R.string.staking_details_annual_percentage_rate) - Yield.RewardType.APY -> TextReference.Res(R.string.staking_details_annual_percentage_yield) + RewardType.APR -> TextReference.Res(R.string.staking_details_annual_percentage_rate) + RewardType.APY -> TextReference.Res(R.string.staking_details_annual_percentage_yield) else -> TextReference.EMPTY } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt index 2fa13297d4..20f8688108 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt @@ -15,7 +15,7 @@ import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.core.ui.res.TangemTheme -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.common.RewardType import com.tangem.features.staking.impl.presentation.model.StakingClickIntents import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.StakingStates @@ -30,7 +30,7 @@ internal fun StakingClaimRewardsValidatorContent( ) { if (state !is StakingStates.RewardsValidatorsState.Data) return Column( - modifier = Modifier // Do not put fillMaxSize() in here + modifier = modifier // This function shouldn't itself apply fillMaxSize(); callers should avoid passing it here .background(TangemTheme.colors.background.secondary) .padding(horizontal = TangemTheme.dimens.spacing12) .verticalScroll(rememberScrollState()), @@ -42,9 +42,9 @@ internal fun StakingClaimRewardsValidatorContent( caption = item.getAprTextNeutral(), infoTitle = item.formattedFiatAmount, infoSubtitle = item.formattedCryptoAmount, - imageUrl = item.validator?.image.orEmpty(), + imageUrl = item.target?.image.orEmpty(), onImageError = { ValidatorImagePlaceholder() }, - modifier = modifier + modifier = Modifier .roundedShapeItemDecoration(index, state.rewards.lastIndex, false) .background(TangemTheme.colors.background.action) .clickable( @@ -65,11 +65,11 @@ internal fun StakingClaimRewardsValidatorContent( @Suppress("UnusedPrivateMember") @Composable private fun BalanceState.getAprTextColored() = combinedReference( - getRewardTypeShortText(validator?.rewardInfo?.type ?: Yield.RewardType.UNKNOWN), + getRewardTypeShortText(target?.rewardInfo?.type ?: RewardType.UNKNOWN), annotatedReference { appendSpace() appendColored( - text = validator?.rewardInfo?.rate?.orZero().format { percent() }, + text = target?.rewardInfo?.rate?.orZero().format { percent() }, color = TangemTheme.colors.text.accent, ) }, @@ -77,6 +77,6 @@ private fun BalanceState.getAprTextColored() = combinedReference( @Composable private fun BalanceState.getAprTextNeutral() = combinedReference( - getRewardTypeShortText(validator?.rewardInfo?.type ?: Yield.RewardType.UNKNOWN), - stringReference(" " + validator?.rewardInfo?.rate?.orZero().format { percent() }), + getRewardTypeShortText(target?.rewardInfo?.type ?: RewardType.UNKNOWN), + stringReference(" " + target?.rewardInfo?.rate?.orZero().format { percent() }), ) \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt index d838576585..1a2d54564a 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt @@ -46,7 +46,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.StakingDetailsScreenTestTags import com.tangem.domain.models.staking.BalanceType import com.tangem.domain.models.staking.RewardBlockType -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.common.RewardType import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.model.StakingClickIntents import com.tangem.features.staking.impl.presentation.state.BalanceState @@ -317,11 +317,11 @@ private fun StakeButtonBlock(buttonState: NavigationButtonsState) { @Suppress("UnusedPrivateMember") @Composable private fun BalanceState.getAprTextColored() = combinedReference( - getRewardTypeShortText(validator?.rewardInfo?.type ?: Yield.RewardType.UNKNOWN), + getRewardTypeShortText(target?.rewardInfo?.type ?: RewardType.UNKNOWN), annotatedReference { appendSpace() appendColored( - text = validator?.rewardInfo?.rate?.orZero().format { percent() }, + text = target?.rewardInfo?.rate?.orZero().format { percent() }, color = TangemTheme.colors.text.accent, ) }, @@ -329,8 +329,8 @@ private fun BalanceState.getAprTextColored() = combinedReference( @Composable private fun BalanceState.getAprTextNeutral() = combinedReference( - getRewardTypeShortText(validator?.rewardInfo?.type ?: Yield.RewardType.UNKNOWN), - stringReference(" " + validator?.rewardInfo?.rate?.orZero().format { percent() }), + getRewardTypeShortText(target?.rewardInfo?.type ?: RewardType.UNKNOWN), + stringReference(" " + target?.rewardInfo?.rate?.orZero().format { percent() }), ) @Composable @@ -347,7 +347,7 @@ private fun BalanceState.getImage() = when (type) { BalanceType.UNSTAKED, BalanceType.LOCKED, -> null - else -> validator?.image + else -> target?.image } private val textGradientColors = listOf( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt index c98b98f368..3f79c1729d 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt @@ -27,7 +27,8 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.common.RewardType +import com.tangem.domain.staking.model.StakingTarget import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.model.StakingClickIntents import com.tangem.features.staking.impl.presentation.state.StakingStates @@ -54,24 +55,24 @@ internal fun StakingValidatorListContent( .padding(horizontal = TangemTheme.dimens.spacing16), ) { if (state is StakingStates.ValidatorState.Data) { - val validators = state.availableValidators + val targets = state.availableTargets items( - count = validators.size, - key = { validators[it].address }, - contentType = { validators[it]::class.java }, + count = targets.size, + key = { targets[it].address }, + contentType = { targets[it]::class.java }, ) { index -> - val item = validators[index] + val item = targets[index] InputRowImageSelector( subtitle = stringReference(item.name), caption = item.getAprTextNeutral(), imageUrl = item.image.orEmpty(), - isSelected = item == state.chosenValidator, - onSelect = { clickIntents.onValidatorSelect(item) }, + isSelected = item == state.chosenTarget, + onSelect = { clickIntents.onTargetSelect(item) }, modifier = Modifier .roundedShapeItemDecoration( currentIndex = index, - lastIndex = validators.lastIndex, + lastIndex = targets.lastIndex, radius = TangemTheme.dimens.radius12, addDefaultPadding = false, @@ -106,21 +107,21 @@ internal fun StakingValidatorListContent( */ @Suppress("UnusedPrivateMember") @Composable -private fun Yield.Validator.getAprTextColored() = combinedReference( - getRewardTypeLongText(rewardInfo?.type ?: Yield.RewardType.UNKNOWN), +private fun StakingTarget.getAprTextColored() = combinedReference( + getRewardTypeLongText(rewardInfo?.type ?: RewardType.UNKNOWN), annotatedReference { appendSpace() appendColored( - text = rewardInfo?.rate?.orZero().format { percent() }, + text = rewardInfo?.rate.orZero().format { percent() }, color = TangemTheme.colors.text.accent, ) }, ) @Composable -private fun Yield.Validator.getAprTextNeutral() = combinedReference( - getRewardTypeLongText(rewardInfo?.type ?: Yield.RewardType.UNKNOWN), - stringReference(" " + rewardInfo?.rate?.orZero().format { percent() }), +private fun StakingTarget.getAprTextNeutral() = combinedReference( + getRewardTypeLongText(rewardInfo?.type ?: RewardType.UNKNOWN), + stringReference(" " + rewardInfo?.rate.orZero().format { percent() }), ) @Composable diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/ValidatorBlock.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/ValidatorBlock.kt index 53402f0c11..44012d5abc 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/ValidatorBlock.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/ValidatorBlock.kt @@ -25,8 +25,9 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.test.StakingSendDetailsScreenTestTags -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.common.RewardType import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.StakingStates.ValidatorState import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.utils.getRewardTypeShortText import com.tangem.features.staking.impl.presentation.ui.ValidatorImagePlaceholder @@ -63,20 +64,20 @@ internal fun ValidatorBlock(validatorState: StakingStates.ValidatorState, isClic modifier = Modifier.padding(12.dp), ) { InputRowAsyncImage( - imageUrl = validatorState.chosenValidator.image.orEmpty(), + imageUrl = state.chosenTarget.image.orEmpty(), onImageError = { ValidatorImagePlaceholder() }, modifier = Modifier .size(24.dp) .clip(TangemTheme.shapes.roundedCornersXLarge), ) Text( - text = validatorState.chosenValidator.name, + text = state.chosenTarget.name, style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, ) SpacerWMax() Text( - text = validatorState.getInfoTitleNeutral().resolveReference(), + text = state.getInfoTitleNeutral().resolveReference(), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.tertiary, ) @@ -91,16 +92,16 @@ internal fun ValidatorBlock(validatorState: StakingStates.ValidatorState, isClic @Composable private fun StakingStates.ValidatorState.Data.getInfoTitleColored() = combinedReference( annotatedReference { - append(getRewardTypeShortText(chosenValidator.rewardInfo?.type ?: Yield.RewardType.UNKNOWN).resolveReference()) + append(getRewardTypeShortText(chosenTarget.rewardInfo?.type ?: RewardType.UNKNOWN).resolveReference()) appendSpace() appendColored( - text = chosenValidator.rewardInfo?.rate.orZero().format { percent() }, + text = chosenTarget.rewardInfo?.rate.orZero().format { percent() }, color = TangemTheme.colors.text.accent, ) }, ) private fun StakingStates.ValidatorState.Data.getInfoTitleNeutral() = combinedReference( - getRewardTypeShortText(chosenValidator.rewardInfo?.type ?: Yield.RewardType.UNKNOWN), - stringReference(" " + chosenValidator.rewardInfo?.rate?.orZero().format { percent() }), + getRewardTypeShortText(chosenTarget.rewardInfo?.type ?: RewardType.UNKNOWN), + stringReference(" " + chosenTarget.rewardInfo?.rate.orZero().format { percent() }), ) \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/DefaultTokenDetailsRouter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/DefaultTokenDetailsRouter.kt index 353c7016ce..c59ef273b8 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/DefaultTokenDetailsRouter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/DefaultTokenDetailsRouter.kt @@ -2,6 +2,7 @@ package com.tangem.feature.tokendetails.presentation.router import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener @@ -37,12 +38,16 @@ internal class DefaultTokenDetailsRouter @Inject constructor( ) } - override fun openStaking(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, yieldId: String) { + override fun openStaking( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + integrationId: StakingIntegrationID, + ) { router.push( AppRoute.Staking( userWalletId = userWalletId, cryptoCurrency = cryptoCurrency, - yieldId = yieldId, + integrationId = integrationId, ), ) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/InnerTokenDetailsRouter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/InnerTokenDetailsRouter.kt index f6cf5397ab..6bec13bb2d 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/InnerTokenDetailsRouter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/router/InnerTokenDetailsRouter.kt @@ -1,5 +1,6 @@ package com.tangem.feature.tokendetails.presentation.router +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId @@ -16,5 +17,5 @@ internal interface InnerTokenDetailsRouter { fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) - fun openStaking(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, yieldId: String) + fun openStaking(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, integrationId: StakingIntegrationID) } \ 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 0b39af391f..b660c09fdd 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 @@ -54,7 +54,6 @@ import com.tangem.domain.promo.models.PromoId import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.staking.GetStakingAvailabilityUseCase import com.tangem.domain.staking.GetStakingEntryInfoUseCase -import com.tangem.domain.staking.GetYieldUseCase import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.tokens.* import com.tangem.domain.tokens.legacy.TradeCryptoAction @@ -124,7 +123,6 @@ internal class TokenDetailsModel @Inject constructor( private val getExtendedPublicKeyForCurrencyUseCase: GetExtendedPublicKeyForCurrencyUseCase, private val getStakingEntryInfoUseCase: GetStakingEntryInfoUseCase, private val getStakingAvailabilityUseCase: GetStakingAvailabilityUseCase, - private val getYieldUseCase: GetYieldUseCase, private val networkHasDerivationUseCase: NetworkHasDerivationUseCase, private val isDemoCardUseCase: IsDemoCardUseCase, private val associateAssetUseCase: AssociateAssetUseCase, @@ -1119,18 +1117,28 @@ internal class TokenDetailsModel @Inject constructor( private fun openStaking() { modelScope.launch { - getYieldUseCase.invoke( - cryptoCurrencyId = cryptoCurrency.id, - symbol = cryptoCurrency.symbol, - ).onRight { yield -> - router.openStaking(userWalletId, cryptoCurrency, yield.id) - }.onLeft { - Timber.e("Staking is unavailable for ${cryptoCurrency.name}") - uiMessageSender.send(SnackbarMessage(resourceReference(R.string.staking_error_no_validators_title))) - } + getStakingAvailabilityUseCase.invokeSync(userWalletId, cryptoCurrency) + .onRight { availability -> + val option = (availability as? StakingAvailability.Available)?.option + if (option != null) { + router.openStaking( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + integrationId = option.integrationId, + ) + } else { + showStakingUnavailable() + } + } + .onLeft { showStakingUnavailable() } } } + private fun showStakingUnavailable() { + Timber.e("Staking is unavailable for ${cryptoCurrency.name}") + uiMessageSender.send(SnackbarMessage(resourceReference(R.string.staking_error_no_validators_title))) + } + private fun checkForActionUpdates() { combine( tokenDetailsDeepLinkActionListener.tokenDetailsActionFlow, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt index e87c73a48e..d93f8c4469 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt @@ -62,12 +62,12 @@ internal class TokenDetailsStakingInfoConverter( val hasPendingBalances = when (stakingBalance) { is StakingBalance.Data.StakeKit -> stakingBalance.balance.items.isNotEmpty() - is StakingBalance.Data.P2P -> !stakingBalance.unstakingAmount.isNullOrZero() + is StakingBalance.Data.P2PEthPool -> !stakingBalance.unstakingAmount.isNullOrZero() null -> false } val pendingAmount = when (stakingBalance) { is StakingBalance.Data.StakeKit -> stakingBalance.balance.items.sumOf { it.amount } - is StakingBalance.Data.P2P -> stakingBalance.unstakingAmount + is StakingBalance.Data.P2PEthPool -> stakingBalance.unstakingAmount null -> BigDecimal.ZERO } @@ -98,7 +98,7 @@ internal class TokenDetailsStakingInfoConverter( stakingAmount = stakingCryptoAmount, rewardAmount = when (stakingBalance) { is StakingBalance.Data.StakeKit -> stakingBalance.getRewardStakingBalance() - is StakingBalance.Data.P2P -> stakingBalance.totalRewards + is StakingBalance.Data.P2PEthPool -> stakingBalance.totalRewards else -> BigDecimal.ZERO }, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt index 09c16514ad..9cc74286c9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt @@ -505,6 +505,8 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( ) { stateHolder.update(CloseBottomSheetTransformer(userWalletId = userWalletId)) + val integrationId = option?.integrationId ?: return + modelScope.launch { val cryptoCurrency = cryptoCurrencyStatus.currency @@ -512,7 +514,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( AppRoute.Staking( userWalletId = userWalletId, cryptoCurrency = cryptoCurrency, - yieldId = option?.integrationId ?: return@launch, + integrationId = integrationId, ), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt index 0dec24b4b9..1bcd5cd45a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt @@ -2,7 +2,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingTarget import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState @@ -19,7 +19,7 @@ internal class SetTokenListTransformer( private val appCurrency: AppCurrency, private val clickIntents: WalletClickIntents, private val yieldSupplyApyMap: Map = emptyMap(), - private val stakingApyMap: Map> = emptyMap(), + private val stakingApyMap: Map> = emptyMap(), private val shouldShowMainPromo: Boolean, ) : WalletStateTransformer(userWallet.walletId) { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt index 257e68fc6b..3a8a6b60d4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt @@ -18,7 +18,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.tokenlist.TokenList.GroupedByNetwork.NetworkGroup import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingTarget 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.WalletTokensListState @@ -38,7 +38,7 @@ internal class TokenListStateConverter( private val selectedWallet: UserWallet, private val clickIntents: WalletClickIntents, private val yieldModuleApyMap: Map, - private val stakingApyMap: Map>, + private val stakingApyMap: Map>, private val shouldShowMainPromo: Boolean, ) : Converter { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt index d1cbb38064..88201bad3e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt @@ -2,7 +2,7 @@ package com.tangem.feature.wallet.presentation.wallet.subscribers import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingTarget import com.tangem.domain.staking.usecase.StakingApyFlowUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoUseCase @@ -50,7 +50,7 @@ internal class AccountListSubscriber @AssistedInject constructor( return yieldSupplyApyFlowUseCase().distinctUntilChanged() } - private fun stakingApyFlow(): Flow>> { + private fun stakingApyFlow(): Flow>> { return stakingApyFlowUseCase().distinctUntilChanged() } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt index ae2648f6d7..2e12662158 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt @@ -9,7 +9,7 @@ import com.tangem.domain.core.utils.getOrElse import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.tokenlist.TokenList -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingTarget import com.tangem.domain.tokens.error.TokenListError import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.account.AccountDependencies @@ -48,7 +48,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() { expandedAccounts: Set, isAccountMode: Boolean, yieldSupplyApyMap: Map = emptyMap(), - stakingApyMap: Map> = emptyMap(), + stakingApyMap: Map> = emptyMap(), shouldShowMainPromo: Boolean = false, ) { val mainAccount = accountList.mainAccount @@ -89,7 +89,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() { appCurrency: AppCurrency, portfolioId: PortfolioId, yieldSupplyApyMap: Map = emptyMap(), - stakingApyMap: Map> = emptyMap(), + stakingApyMap: Map> = emptyMap(), shouldShowMainPromo: Boolean, ) { val tokenList = maybeTokenList.getOrElse( @@ -128,7 +128,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() { params: TokenConverterParams, appCurrency: AppCurrency, yieldSupplyApyMap: Map = emptyMap(), - stakingApyMap: Map> = emptyMap(), + stakingApyMap: Map> = emptyMap(), shouldShowMainPromo: Boolean, ) { stateController.update( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt index 36de78ef22..2adfb41c3e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt @@ -11,7 +11,7 @@ import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingTarget import com.tangem.domain.staking.usecase.StakingApyFlowUseCase import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase @@ -128,7 +128,7 @@ internal abstract class BasicTokenListSubscriber( params: TokenConverterParams, appCurrency: AppCurrency, yieldSupplyApyMap: Map, - stakingApyMap: Map>, + stakingApyMap: Map>, shouldShowMainPromo: Boolean, ) { stateHolder.update( @@ -156,7 +156,7 @@ internal abstract class BasicTokenListSubscriber( private fun yieldSupplyApyFlow(): Flow> = yieldSupplyApyFlowUseCase() .distinctUntilChanged() - private fun stakingApyFlow(): Flow>> = stakingApyFlowUseCase() + private fun stakingApyFlow(): Flow>> = stakingApyFlowUseCase() .distinctUntilChanged() private fun yieldSupplyGetShouldShowMainPromoFlow(): Flow = yieldSupplyGetShouldShowMainPromoUseCase() From 078e8ed336b6497884ed56504f35e18a622ef7bb Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 18 Dec 2025 12:40:50 +0100 Subject: [PATCH 005/172] Updated on 2026-08-14 --- .../components/DefaultFeedEntryComponent.kt | 14 +- .../feed/components/FeedEntryChildFactory.kt | 9 +- .../list/DefaultMarketsTokenListComponent.kt | 38 +- .../tangem/features/feed/di/ModelModule.kt | 6 + .../converter/MarketsTokenItemConverter.kt | 2 +- .../feed/model/feed/FeedComponentModel.kt | 7 +- .../feed/model/feed/FeedModelClickIntents.kt | 2 +- .../model/market/list/MarketsListModel.kt | 329 ++++++++++++++++ .../analytics/MarketsListAnalyticsEvent.kt | 49 +++ .../MarketsListBatchFlowManager.kt | 372 ++++++++++++++++++ .../statemanager/MarketsListUMStateManager.kt | 267 +++++++++++++ .../model/market/list/utils/LoggingUtils.kt | 58 +++ .../feed/ui/EntryBottomSheetContent.kt | 10 +- .../tangem/features/feed/ui/feed/FeedList.kt | 60 ++- .../preview/FeedListPreviewDataProvider.kt | 2 +- .../features/feed/ui/feed/state/FeedListUM.kt | 2 +- .../feed/state/FeedMarketsBatchFlowManager.kt | 4 +- .../feed/ui/market/list/MarketsList.kt | 350 ++++++++++++++++ .../list/components/MarketsListLazyColumn.kt | 214 ++++++++++ .../MarketsListSortByBottomSheet.kt | 85 ++++ .../StakingInMarketsPromoNotification.kt | 157 ++++++++ .../YieldSupplyInMarketsPromoNotification.kt | 157 ++++++++ .../market/{ => list}/state/MarketsListUM.kt | 8 +- .../list/state/MarketsNotificationUM.kt | 22 ++ .../list/state/SortByBottomSheetContentUM.kt | 8 + 25 files changed, 2184 insertions(+), 48 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/analytics/MarketsListAnalyticsEvent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/utils/LoggingUtils.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListSortByBottomSheet.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/StakingInMarketsPromoNotification.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/YieldSupplyInMarketsPromoNotification.kt rename features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/{ => list}/state/MarketsListUM.kt (91%) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsNotificationUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/SortByBottomSheetContentUM.kt 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 dbcfde3de2..0519eeeaf2 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 @@ -21,10 +21,11 @@ import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent +import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent import com.tangem.features.feed.entry.components.FeedEntryComponent import com.tangem.features.feed.model.feed.FeedModelClickIntents import com.tangem.features.feed.ui.EntryBottomSheetContent -import com.tangem.features.feed.ui.market.state.SortByTypeUM +import com.tangem.features.feed.ui.market.list.state.SortByTypeUM import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -60,7 +61,16 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( } override fun onMarketOpenClick(sortBy: SortByTypeUM) { - innerRouter.push(FeedEntryChildFactory.Child.TokenList) + innerRouter.push( + route = FeedEntryChildFactory.Child.TokenList( + params = DefaultMarketsTokenListComponent.Params( + onBackClicked = { onChildBack() }, + onTokenClick = { token, currency -> onMarketItemClick(token, currency) }, + preselectedSortType = sortBy, + shouldAlwaysShowSearchBar = sortBy == SortByTypeUM.Rating, + ), + ), + ) } override fun onArticleClick(articleId: Int) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index 081ec2ab4d..30b8ee5e79 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -24,7 +24,7 @@ internal class FeedEntryChildFactory @Inject constructor() { @Serializable @Immutable - data object TokenList : Child + data class TokenList(val params: DefaultMarketsTokenListComponent.Params) : Child @Serializable @Immutable @@ -54,12 +54,7 @@ internal class FeedEntryChildFactory @Inject constructor() { is Child.TokenList -> { DefaultMarketsTokenListComponent( appComponentContext = appComponentContext, - onTokenClick = { token, appCurrency -> - feedEntryClickIntents.onMarketItemClick( - token, - appCurrency, - ) - }, + params = child.params, ) } Child.NewsDetails -> { 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 ca5d351c48..a2f2670051 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 @@ -1,27 +1,57 @@ package com.tangem.features.feed.components.market.list import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.LifecycleStartEffect +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.ComposableModularContentComponent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.feed.model.market.list.MarketsListModel +import com.tangem.features.feed.ui.market.list.MarketsList +import com.tangem.features.feed.ui.market.list.TopBarWithSearch +import com.tangem.features.feed.ui.market.list.state.SortByTypeUM +import kotlinx.serialization.Serializable -@Suppress("UnusedPrivateProperty") // TODO will be remove in next PR internal class DefaultMarketsTokenListComponent( appComponentContext: AppComponentContext, - private val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit)? = null, + private val params: Params, ) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + private val model: MarketsListModel = getOrCreateModel(params = params) + @Composable override fun Title() { + val state by model.state.collectAsStateWithLifecycle() + TopBarWithSearch(state.searchBar) } @Composable override fun Content(modifier: Modifier) { + LifecycleStartEffect(Unit) { + model.isVisibleOnScreen.value = true + onStopOrDispose { + model.isVisibleOnScreen.value = false + } + } + val state by model.state.collectAsStateWithLifecycle() + MarketsList( + modifier = modifier, + state = state, + ) } @Composable - override fun Footer() { - } + override fun Footer() = Unit + + @Serializable + data class Params( + val onBackClicked: () -> Unit, + val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), + val preselectedSortType: SortByTypeUM, + val shouldAlwaysShowSearchBar: Boolean, + ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt index f00b75a4b1..fbce8929f3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt @@ -3,6 +3,7 @@ package com.tangem.features.feed.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.feed.model.feed.FeedComponentModel +import com.tangem.features.feed.model.market.list.MarketsListModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -17,4 +18,9 @@ internal interface ModelModule { @IntoMap @ClassKey(FeedComponentModel::class) fun bindsFeedComponentModel(model: FeedComponentModel): Model + + @Binds + @IntoMap + @ClassKey(MarketsListModel::class) + fun provideMarketsListModel(model: MarketsListModel): Model } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/MarketsTokenItemConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/MarketsTokenItemConverter.kt index 6bc58aab6e..d7ba20e636 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/MarketsTokenItemConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/MarketsTokenItemConverter.kt @@ -12,7 +12,7 @@ import com.tangem.core.ui.format.bigdecimal.* import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket import com.tangem.features.feed.impl.R -import com.tangem.features.feed.ui.market.state.MarketsListUM +import com.tangem.features.feed.ui.market.list.state.MarketsListUM import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 6b7fb68da4..9c7a9c96b1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -18,7 +18,7 @@ import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.feed.state.* -import com.tangem.features.feed.ui.market.state.SortByTypeUM +import com.tangem.features.feed.ui.market.list.state.SortByTypeUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList @@ -31,6 +31,7 @@ import kotlinx.coroutines.launch import org.joda.time.DateTime import org.joda.time.DateTimeZone import javax.inject.Inject +import kotlin.collections.all @Stable @ModelScoped @@ -141,7 +142,9 @@ internal class FeedComponentModel @Inject constructor( query = "", onQueryChange = {}, isActive = false, - onActiveChange = { }, + onActiveChange = { + if (it) params.feedClickIntents.onMarketOpenClick(SortByTypeUM.Rating) + }, ), feedListCallbacks = FeedListCallbacks( onSearchClick = {}, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt index 7e518f9c26..4b4c342fe0 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt @@ -2,7 +2,7 @@ package com.tangem.features.feed.model.feed import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams -import com.tangem.features.feed.ui.market.state.SortByTypeUM +import com.tangem.features.feed.ui.market.list.state.SortByTypeUM /** * Callback interface for feed model navigation actions. diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt new file mode 100644 index 0000000000..ca976fa47b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt @@ -0,0 +1,329 @@ +package com.tangem.features.feed.model.market.list + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase +import com.tangem.domain.markets.ShouldShowYieldModeMarketPromoUseCase +import com.tangem.domain.markets.TokenMarketListConfig +import com.tangem.domain.markets.toSerializableParam +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.promo.PromoRepository +import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent +import com.tangem.features.feed.model.market.list.analytics.MarketsListAnalyticsEvent +import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager +import com.tangem.features.feed.model.market.list.statemanager.MarketsListUMStateManager +import com.tangem.features.feed.ui.market.list.state.ListUM +import com.tangem.features.feed.ui.market.list.state.MarketsListUM +import com.tangem.features.feed.ui.market.list.state.MarketsNotificationUM +import com.tangem.features.feed.ui.market.list.state.SortByTypeUM +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L +private const val SEARCH_QUERY_DEBOUNCE_MILLIS = 800L + +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) +@ModelScoped +@Stable +@Suppress("LongParameterList", "PropertyUsedBeforeDeclaration") +internal class MarketsListModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + shouldShowYieldModeMarketPromoUseCase: ShouldShowYieldModeMarketPromoUseCase, + paramsContainer: ParamsContainer, + private val promoRepository: PromoRepository, + private val analyticsEventHandler: AnalyticsEventHandler, +) : Model() { + + private val updateQuotesJob = JobHolder() + + private val params = paramsContainer.require() + + private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + }.stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + + private val visibleItemIds = MutableStateFlow>(emptyList()) + + private val marketsListUMStateManager by lazy { + MarketsListUMStateManager( + currentVisibleIds = Provider { visibleItemIds.value }, + onLoadMoreUiItems = { activeListManager.loadMore() }, + visibleItemsChanged = { visibleItemIds.value = it }, + onRetryButtonClicked = { activeListManager.reload() }, + onTokenClick = { onTokenUIClicked(it) }, + onShowTokensUnder100kClicked = { analyticsEventHandler.send(MarketsListAnalyticsEvent.ShowTokens()) }, + shouldAlwaysShowSearchBar = Provider { params.shouldAlwaysShowSearchBar }, + preselectedSortType = Provider { params.preselectedSortType }, + ) + } + + private val mainMarketsListManager by lazy { + MarketsListBatchFlowManager( + getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, + batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main, + currentAppCurrency = Provider { currentAppCurrency.value }, + currentTrendInterval = Provider { marketsListUMStateManager.selectedInterval }, + currentSortByType = Provider { marketsListUMStateManager.selectedSortByType }, + currentSearchText = Provider { null }, + modelScope = modelScope, + dispatchers = dispatchers, + ) + } + + private val searchMarketsListManager by lazy { + MarketsListBatchFlowManager( + getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, + batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, + currentAppCurrency = Provider { currentAppCurrency.value }, + currentTrendInterval = Provider { marketsListUMStateManager.selectedInterval }, + currentSortByType = Provider { SortByTypeUM.Rating }, + currentSearchText = Provider { marketsListUMStateManager.searchQuery }, + modelScope = modelScope, + dispatchers = dispatchers, + ) + } + + private var activeListManager: MarketsListBatchFlowManager = mainMarketsListManager + + val isVisibleOnScreen = MutableStateFlow(false) + + val state = marketsListUMStateManager.state.asStateFlow() + + init { + modelScope.launch { + marketsListUMStateManager.isInSearchStateFlow.flatMapLatest { isInSearchMode -> + if (isInSearchMode) { + combine( + flow = searchMarketsListManager.uiItems, + flow2 = searchMarketsListManager.isInInitialLoadingErrorState, + flow3 = searchMarketsListManager.isSearchNotFoundState, + flow4 = shouldShowYieldModeMarketPromoUseCase( + appCurrency = currentAppCurrency.value, + interval = marketsListUMStateManager.selectedInterval.toBatchRequestInterval(), + ), + ) { uiItems, isInInitialLoadingErrorState, isSearchNotFoundState, isYieldModePromo -> + MarketsItemsData( + items = uiItems, + isInErrorState = isInInitialLoadingErrorState, + isSearchNotFound = isSearchNotFoundState, + shouldShowYieldModePromo = isYieldModePromo, + ) + } + } else { + combine( + flow = mainMarketsListManager.uiItems, + flow2 = mainMarketsListManager.isInInitialLoadingErrorState, + flow3 = shouldShowYieldModeMarketPromoUseCase( + appCurrency = currentAppCurrency.value, + interval = marketsListUMStateManager.selectedInterval.toBatchRequestInterval(), + ), + ) { uiItems, isInInitialLoadingErrorState, shouldShowYieldModePromo -> + MarketsItemsData( + items = uiItems, + isInErrorState = isInInitialLoadingErrorState, + isSearchNotFound = false, + shouldShowYieldModePromo = shouldShowYieldModePromo, + ) + } + } + }.collect { marketsItemsData -> + val shouldShowYieldModePromo = marketsItemsData.shouldShowYieldModePromo + if (marketsListUMStateManager.state.value.marketsNotificationUM == null && shouldShowYieldModePromo) { + analyticsEventHandler.send(MarketsListAnalyticsEvent.YieldModePromoShown()) + } + + marketsListUMStateManager.onUiItemsChanged( + uiItems = marketsItemsData.items, + isInErrorState = marketsItemsData.isInErrorState, + isSearchNotFound = marketsItemsData.isSearchNotFound, + marketsNotificationUM = if (shouldShowYieldModePromo) { + MarketsNotificationUM.YieldSupplyPromo( + onClick = { + analyticsEventHandler.send(MarketsListAnalyticsEvent.YieldModeMoreInfoClicked()) + marketsListUMStateManager.selectedSortByType = SortByTypeUM.YieldSupply + }, + onCloseClick = { onYieldModeNotificationCloseClick() }, + ) + } else { + null + }, + ) + } + } + + state.onEach { marketsListUM -> + if (marketsListUM.list !is ListUM.Content) { + visibleItemIds.value = emptyList() + } + }.launchIn(modelScope) + + // update all lists when user's currency has changed + currentAppCurrency.drop(1).onEach { + mainMarketsListManager.reload() + if (marketsListUMStateManager.isInSearchState) { + searchMarketsListManager.reload() + } + }.launchIn(modelScope) + + // load charts when new batch is being loaded + mainMarketsListManager.onLastBatchLoadedSuccess.onEach { batchKey -> + mainMarketsListManager.loadCharts(setOf(batchKey), marketsListUMStateManager.selectedInterval) + modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) + }.launchIn(modelScope) + + // listen currently selected interval, update charts if sorting=rating, or reload all list + modelScope.launch(dispatchers.default) { + marketsListUMStateManager.state.map { it.selectedInterval }.distinctUntilChanged().drop(1) + .collectLatest { interval -> + when (marketsListUMStateManager.selectedSortByType) { + SortByTypeUM.Rating -> { + mainMarketsListManager.updateUIWithSameState() + val batchKeys = mainMarketsListManager.getBatchKeysByItemIds(visibleItemIds.value) + mainMarketsListManager.loadCharts(batchKeys, interval) + } + else -> mainMarketsListManager.reload() + } + } + } + + // reload list when sorting type has changed + modelScope.launch { + marketsListUMStateManager + .state + .map { it.selectedSortBy } + .distinctUntilChanged() + .drop(1) + .collectLatest { _ -> + mainMarketsListManager.reload() + } + } + + // listen current visible batch and update charts + modelScope.launch { + visibleItemIds.mapNotNull { listOfIds -> + if (listOfIds.isNotEmpty()) { + activeListManager.getBatchKeysByItemIds(visibleItemIds.value) + } else { + null + } + }.distinctUntilChanged().collectLatest { visibleBatchKeys -> + // TODO load batch on scroll heat area + activeListManager.loadCharts(visibleBatchKeys, marketsListUMStateManager.selectedInterval) + } + } + + // ===Search=== + + modelScope.launch { + marketsListUMStateManager.isInSearchStateFlow.collectLatest { isInSearchMode -> + activeListManager = if (isInSearchMode) { + searchMarketsListManager + } else { + searchMarketsListManager.clearStateAndStopAllActions() + mainMarketsListManager + } + } + } + + modelScope.launch { + marketsListUMStateManager.searchQueryFlow.debounce(timeoutMillis = SEARCH_QUERY_DEBOUNCE_MILLIS) + .distinctUntilChanged().onEach { + if (it.isEmpty()) searchMarketsListManager.clearStateAndStopAllActions() + }.filter { it.isNotEmpty() && activeListManager == searchMarketsListManager }.collectLatest { + searchMarketsListManager.reload(searchText = it) + } + } + + modelScope.launch { + searchMarketsListManager.onLastBatchLoadedSuccess.collectLatest { batchKey -> + searchMarketsListManager.loadCharts(setOf(batchKey), marketsListUMStateManager.selectedInterval) + modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) + } + } + + searchMarketsListManager + .isSearchNotFoundState + .onEach { isTokenFound -> + if (isTokenFound) { + analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(isTokenFound = false)) + } + }.launchIn(modelScope) + + searchMarketsListManager.onFirstBatchLoadedSuccess.onEach { + analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(isTokenFound = true)) + }.launchIn(modelScope) + + // analytics + initAnalytics() + + // initial loading + mainMarketsListManager.reload() + } + + private fun MarketsListUM.TrendInterval.toBatchRequestInterval(): TokenMarketListConfig.Interval { + return when (this) { + MarketsListUM.TrendInterval.H24 -> TokenMarketListConfig.Interval.H24 + MarketsListUM.TrendInterval.D7 -> TokenMarketListConfig.Interval.WEEK + MarketsListUM.TrendInterval.M1 -> TokenMarketListConfig.Interval.MONTH + } + } + + private fun initAnalytics() { + state.filter { it.isInSearchMode.not() } + .map { MarketsListAnalyticsEvent.SortBy(it.selectedSortBy, it.selectedInterval) }.distinctUntilChanged() + .onEach { + analyticsEventHandler.send(it) + }.launchIn(modelScope) + } + + private fun onTokenUIClicked(token: MarketsListItemUM) { + modelScope.launch { + activeListManager.getTokenById(token.id)?.let { found -> + params.onTokenClick(found.toSerializableParam(), currentAppCurrency.value) + } + } + } + + private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) { + launch { + while (true) { + delay(timeMillis) + isVisibleOnScreen.first { it } + activeListManager.updateQuotes() + } + }.saveIn(updateQuotesJob) + } + + private fun onYieldModeNotificationCloseClick() { + analyticsEventHandler.send(MarketsListAnalyticsEvent.YieldModePromoClosed()) + modelScope.launch { + promoRepository.setMarketsYieldSupplyNotificationHideClicked() + } + } + + private class MarketsItemsData( + val items: ImmutableList, + val isInErrorState: Boolean, + val isSearchNotFound: Boolean, + val shouldShowYieldModePromo: Boolean, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/analytics/MarketsListAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/analytics/MarketsListAnalyticsEvent.kt new file mode 100644 index 0000000000..de0d59ab8c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/analytics/MarketsListAnalyticsEvent.kt @@ -0,0 +1,49 @@ +package com.tangem.features.feed.model.market.list.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.features.feed.ui.market.list.state.MarketsListUM +import com.tangem.features.feed.ui.market.list.state.SortByTypeUM + +internal sealed class MarketsListAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Markets", event = event, params = params) { + + data class SortBy( + val sortByTypeUM: SortByTypeUM, + val interval: MarketsListUM.TrendInterval, + ) : MarketsListAnalyticsEvent( + event = "Sort By", + params = mapOf( + "Type" to when (sortByTypeUM) { + SortByTypeUM.Rating -> "Rating" + SortByTypeUM.Trending -> "Trending" + SortByTypeUM.ExperiencedBuyers -> "Buyers" + SortByTypeUM.TopGainers -> "Gainers" + SortByTypeUM.TopLosers -> "Losers" + SortByTypeUM.Staking -> "Staking" + SortByTypeUM.YieldSupply -> "Yield Supply" + }, + "Period" to when (interval) { + MarketsListUM.TrendInterval.H24 -> "24h" + MarketsListUM.TrendInterval.D7 -> "7d" + MarketsListUM.TrendInterval.M1 -> "1m" + }, + ), + ) + + class YieldModePromoShown : MarketsListAnalyticsEvent(event = "Notice - Yield Mode Promo") + + class YieldModePromoClosed : MarketsListAnalyticsEvent(event = "Yield Mode Promo Closed") + + class YieldModeMoreInfoClicked : MarketsListAnalyticsEvent(event = "Yield Mode More Info") + + data class TokenSearched(val isTokenFound: Boolean) : MarketsListAnalyticsEvent( + event = "Token Searched", + params = mapOf( + "Result" to if (isTokenFound) "Yes" else "No", + ), + ) + + class ShowTokens : MarketsListAnalyticsEvent(event = "Button - Show Tokens") +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt new file mode 100644 index 0000000000..4bf209e2e0 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt @@ -0,0 +1,372 @@ +package com.tangem.features.feed.model.market.list.statemanager + +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.* +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.feed.model.converter.MarketsTokenItemConverter +import com.tangem.features.feed.model.market.list.utils.logAction +import com.tangem.features.feed.model.market.list.utils.logStatus +import com.tangem.features.feed.model.market.list.utils.logUpdateResults +import com.tangem.features.feed.ui.market.list.state.MarketsListUM +import com.tangem.features.feed.ui.market.list.state.SortByTypeUM +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchAction +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.PaginationStatus +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +private const val LOG_EVENTS = true + +@Suppress("LongParameterList", "LargeClass") +internal class MarketsListBatchFlowManager( + getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, + private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType, + private val currentTrendInterval: Provider, + private val currentAppCurrency: Provider, + private val currentSearchText: Provider, + private val currentSortByType: Provider, + private val modelScope: CoroutineScope, + private val dispatchers: CoroutineDispatcherProvider, +) { + private val actionsFlow = MutableSharedFlow>() + private val updateStateJob = JobHolder() + + private val batchFlow = getMarketsTokenListFlowUseCase( + batchingContext = TokenListBatchingContext( + actionsFlow = actionsFlow, + coroutineScope = modelScope, + ), + batchFlowType = batchFlowType, + ) + + private val resultBatches = MutableStateFlow(ResultBatches()) + private val uiBatches = resultBatches.map { it.uiBatches } + + val uiItems: StateFlow> + get() = uiBatches + .map { batches -> + batches.asSequence() + .map { it.data } + .flatten() + .toImmutableList() + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = persistentListOf(), + ) + + val onLastBatchLoadedSuccess = batchFlow.state + .distinctUntilChanged { old, new -> old.status == new.status && old.data.size == new.data.size } + .mapNotNull { batchListState -> + when (val status = batchListState.status) { + is PaginationStatus.Paginating -> { + if (status.lastResult is BatchFetchResult.Success) { + batchListState.data.lastOrNull()?.key + } else { + null + } + } + is PaginationStatus.EndOfPagination -> { + batchListState.data.lastOrNull()?.key + } + else -> null + } + } + + val onFirstBatchLoadedSuccess = batchFlow.state + .distinctUntilChanged { old, new -> old.status == new.status && old.data.size == new.data.size } + .mapNotNull { batchListState -> + when (val status = batchListState.status) { + is PaginationStatus.Paginating -> { + if (status.lastResult is BatchFetchResult.Success) { + batchListState.data.size == 1 + } else { + null + } + } + is PaginationStatus.EndOfPagination -> { + batchListState.data.size == 1 + } + else -> null + } + } + .filter { it } + + val isInInitialLoadingErrorState = batchFlow.state + .map { it.status is PaginationStatus.InitialLoadingError } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + + val isSearchNotFoundState = batchFlow.state + .map { batchListState -> + currentSearchText().isNullOrEmpty().not() && + batchListState.status is PaginationStatus.EndOfPagination && + batchListState.data.isEmpty() + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + + init { + batchFlow.state + .map { it.data } + .distinctUntilChanged { a, b -> + a.size == b.size && + a.map { it.key } == b.map { it.key } && + a.map { it.data }.flatten() == b.map { it.data }.flatten() + } + .onEach { + coroutineScope { + launch { + updateState(it) + }.saveIn(updateStateJob) + } + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + + if (LOG_EVENTS) { + batchFlow.updateResults + .onEach { logUpdateResults(batchFlowType.name, it) } + .launchIn(modelScope) + + batchFlow.state + .map { it.status } + .onEach { logStatus(batchFlowType.name, it) } + .launchIn(modelScope) + + actionsFlow + .onEach { logAction(batchFlowType.name, it) } + .launchIn(modelScope) + } + } + + private suspend fun updateState(newList: List>>, forceUpdate: Boolean = false) = + withContext(dispatchers.default) { + resultBatches.update { resultBatches -> + val items = resultBatches.uiBatches + val previousList = resultBatches.processedItems + + val converter = MarketsTokenItemConverter(currentTrendInterval(), appCurrency = currentAppCurrency()) + + if (newList.isEmpty()) { + return@update ResultBatches(processedItems = emptyList()) + } + + val isInitialLoading = + forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key + + val outItems = if (isInitialLoading) { + newList.map { batch -> + Batch( + key = batch.key, + data = converter.convertList(batch.data), + ) + } + } else { + if (previousList.size != newList.size) { + val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) + val newBatches = newList.filter { keysToAdd.contains(it.key) } + + items + newBatches.map { batch -> + Batch( + key = batch.key, + data = converter.convertList(batch.data), + ) + } + } else { + items.mapIndexed { batchIndex, batch -> + val prevBatch = previousList[batchIndex] + val newBatch = newList[batchIndex] + if (previousList == newBatch) return@mapIndexed batch + + Batch( + key = batch.key, + data = batch.data.mapIndexed { index, marketsListItemUM -> + val prevItem = prevBatch.data[index] + val newItem = newBatch.data[index] + + converter.update( + prevItem, + marketsListItemUM, + newItem, + ) + }, + ) + } + } + } + + currentCoroutineContext().ensureActive() + + ResultBatches( + uiBatches = outItems, + processedItems = newList, + ) + } + } + + fun reload(searchText: String? = null) { + modelScope.launch { + resultBatches.value = ResultBatches() + actionsFlow.emit( + BatchAction.Reload( + requestParams = TokenMarketListConfig( + fiatPriceCurrency = currentAppCurrency().code, + searchText = if (currentSearchText() == null) { + null + } else { + searchText ?: currentSearchText() + }, + priceChangeInterval = currentTrendInterval().toBatchRequestInterval(), + order = currentSortByType().toRequestOrder(), + ), + ), + ) + } + } + + fun loadMore() { + modelScope.launch { + actionsFlow.emit(BatchAction.LoadMore()) + } + } + + fun updateUIWithSameState() { + modelScope.launch(dispatchers.default) { + val current = batchFlow.state.value.data + updateState(current, forceUpdate = true) + }.saveIn(updateStateJob) + } + + fun loadCharts(batchKeys: Set, interval: MarketsListUM.TrendInterval) { + if (batchKeys.isEmpty()) return + + modelScope.launch { + val currentData = batchFlow.state.value.data + val alreadyLoadedChartsBatchKeys = currentData + .filter { batch -> + val first = batch.data.firstOrNull() ?: return@filter false + val chartByInterval = when (interval) { + MarketsListUM.TrendInterval.H24 -> first.tokenCharts.h24 + MarketsListUM.TrendInterval.D7 -> first.tokenCharts.week + MarketsListUM.TrendInterval.M1 -> first.tokenCharts.month + } + chartByInterval != null + } + .map { it.key } + .toSet() + + val batchesKeysToLoad = batchKeys.minus(alreadyLoadedChartsBatchKeys) + + if (batchesKeysToLoad.isNotEmpty()) { + actionsFlow.emit( + BatchAction.UpdateBatches( + keys = batchesKeysToLoad, + updateRequest = TokenMarketUpdateRequest.UpdateChart( + interval = interval.toBatchRequestInterval(), + currency = currentAppCurrency().code, + ), + async = true, + operationId = batchesKeysToLoad.toString() + interval.toString(), + ), + ) + } + } + } + + fun updateQuotes() { + modelScope.launch { + actionsFlow.emit( + BatchAction.CancelUpdates { + it.updateRequest is TokenMarketUpdateRequest.UpdateQuotes + }, + ) + + actionsFlow.emit( + BatchAction.UpdateBatches( + keys = batchFlow + .state + .value + .data + .map { it.key } + .toSet(), + updateRequest = TokenMarketUpdateRequest.UpdateQuotes( + currencyId = currentAppCurrency().code, + ), + async = true, + operationId = "update quotes", + ), + ) + } + } + + fun clearStateAndStopAllActions() { + resultBatches.value = ResultBatches() + modelScope.launch { + actionsFlow.emit(BatchAction.Reset) + } + } + + fun getBatchKeysByItemIds(ids: List): Set { + val currentData = batchFlow.state.value.data + + return currentData + .filter { d -> d.data.any { ids.contains(it.id) } } + .map { it.key } + .toSet() + } + + fun getTokenById(id: CryptoCurrency.RawID): TokenMarket? { + return batchFlow + .state + .value + .data + .map { it.data } + .flatten() + .find { it.id == id } + } + + private fun SortByTypeUM.toRequestOrder(): TokenMarketListConfig.Order { + return when (this) { + SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating + SortByTypeUM.Trending -> TokenMarketListConfig.Order.Trending + SortByTypeUM.ExperiencedBuyers -> TokenMarketListConfig.Order.Buyers + SortByTypeUM.TopGainers -> TokenMarketListConfig.Order.TopGainers + SortByTypeUM.TopLosers -> TokenMarketListConfig.Order.TopLosers + SortByTypeUM.Staking -> TokenMarketListConfig.Order.Staking + SortByTypeUM.YieldSupply -> TokenMarketListConfig.Order.YieldSupply + } + } + + private fun MarketsListUM.TrendInterval.toBatchRequestInterval(): TokenMarketListConfig.Interval { + return when (this) { + MarketsListUM.TrendInterval.H24 -> TokenMarketListConfig.Interval.H24 + MarketsListUM.TrendInterval.D7 -> TokenMarketListConfig.Interval.WEEK + MarketsListUM.TrendInterval.M1 -> TokenMarketListConfig.Interval.MONTH + } + } + + private data class ResultBatches( + val uiBatches: List>> = emptyList(), + val processedItems: List>>? = null, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt new file mode 100644 index 0000000000..5ecb6df31a --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt @@ -0,0 +1,267 @@ +package com.tangem.features.feed.model.market.list.statemanager + +import androidx.compose.runtime.Stable +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.event.triggeredEvent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.list.state.* +import com.tangem.utils.Provider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +@Stable +@Suppress("LongParameterList", "LargeClass") +internal class MarketsListUMStateManager( + private val shouldAlwaysShowSearchBar: Provider, + private val currentVisibleIds: Provider>, + private val preselectedSortType: Provider, + private val onLoadMoreUiItems: () -> Unit, + private val visibleItemsChanged: (itemsKeys: List) -> Unit, + private val onRetryButtonClicked: () -> Unit, + private val onTokenClick: (MarketsListItemUM) -> Unit, + private val onShowTokensUnder100kClicked: () -> Unit, +) { + + val state = MutableStateFlow(state()) + + private var isSortByBottomSheetShown + get() = state.value.sortByBottomSheet.isShown + set(value) = state.update { it.copy(sortByBottomSheet = it.sortByBottomSheet.copy(isShown = value)) } + + var searchQuery + get() = state.value.searchBar.query + private set(value) = state.update { marketsListUM -> + marketsListUM.copy( + searchBar = marketsListUM.searchBar.copy( + query = value, + isActive = value.isNotEmpty(), + ), + ) + } + + var isInSearchState + get() = state.value.searchBar.isActive + private set(value) = state.update { it.copy(searchBar = it.searchBar.copy(isActive = value)) } + + var selectedSortByType + get() = state.value.selectedSortBy + set(value) = state.update { marketsListUM -> + marketsListUM.copy( + selectedSortBy = value, + sortByBottomSheet = marketsListUM.sortByBottomSheet.copy( + content = (marketsListUM.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( + selectedOption = value, + ), + ), + list = if (marketsListUM.list is ListUM.Content && marketsListUM.selectedSortBy != value) { + marketsListUM.list.copy( + triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }, + ) + } else { + marketsListUM.list + }, + ) + } + + var selectedInterval + get() = state.value.selectedInterval + set(value) = state.update { marketsListUM -> + marketsListUM.copy( + selectedInterval = value, + list = if (marketsListUM.list is ListUM.Content && + marketsListUM.selectedSortBy != SortByTypeUM.Rating && + marketsListUM.selectedInterval != value + ) { + marketsListUM.list.copy( + triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }, + ) + } else { + marketsListUM.list + }, + ) + } + + val isInSearchStateFlow = state.map { it.isInSearchMode }.distinctUntilChanged() + val searchQueryFlow = state.map { it.searchBar.query }.distinctUntilChanged() + + fun onUiItemsChanged( + isInErrorState: Boolean, + isSearchNotFound: Boolean, + uiItems: ImmutableList, + marketsNotificationUM: MarketsNotificationUM?, + ) { + state.update { marketsListUM -> + when { + isInErrorState -> { + marketsListUM.copy( + list = ListUM.LoadingError(onRetryClicked = onRetryButtonClicked), + ) + } + isSearchNotFound -> { + marketsListUM.copy(list = ListUM.SearchNothingFound) + } + uiItems.isEmpty() -> { + marketsListUM.copy(list = ListUM.Loading) + } + else -> { + marketsListUM.updateItems( + newItems = uiItems, + marketsNotificationUM = marketsNotificationUM, + ) + } + } + } + } + + private fun MarketsListUM.updateItems( + newItems: ImmutableList, + marketsNotificationUM: MarketsNotificationUM?, + ): MarketsListUM { + val currentState = this + + if (isInSearchMode.not() || currentState.showUnder100kButtonAlreadyPressed()) { + val itemsWithFilteredPriceChange = newItems.filterPriceChangeByVisibility() + + return currentState.copy( + list = generalContentState(itemsWithFilteredPriceChange) + .copy( + shouldShowUnder100kTokensNotificationWasHidden = currentState + .showUnder100kButtonAlreadyPressed(), + ), + marketsNotificationUM = marketsNotificationUM, + ) + } + + // Search state cases + + val filtered = newItems.filter { item -> item.isUnder100kMarketCap.not() } + .toImmutableList() + .filterPriceChangeByVisibility() + + if (filtered.size != newItems.size) { + val searchUiItemsCached = newItems.filterPriceChangeByVisibility() + + return currentState.copy( + list = generalContentState(filtered).copy( + shouldShowUnder100kTokensNotificationWasHidden = false, + shouldShowUnder100kTokensNotification = true, + onShowTokensUnder100kClicked = { + onShowTokensUnder100kClicked() + state.update { s -> + (s.list as? ListUM.Content)?.let { + s.copy( + list = s.list.copy( + items = searchUiItemsCached, + shouldShowUnder100kTokensNotification = false, + shouldShowUnder100kTokensNotificationWasHidden = true, + ), + ) + } ?: s + } + }, + ), + ) + } else { + return currentState.copy( + list = generalContentState(newItems.filterPriceChangeByVisibility()), + ) + } + } + + private fun MarketsListUM.showUnder100kButtonAlreadyPressed(): Boolean { + return this.list is ListUM.Content && + this.isInSearchMode && + this.list.shouldShowUnder100kTokensNotificationWasHidden + } + + // Show price change animation for visible items only + private fun ImmutableList.filterPriceChangeByVisibility(): ImmutableList { + val visibleItemIds = currentVisibleIds() + return map { marketsListItemUM -> + marketsListItemUM.copy( + price = marketsListItemUM.price.copy( + changeType = if (visibleItemIds.contains(marketsListItemUM.id)) { + marketsListItemUM.price.changeType + } else { + null + }, + ), + ) + }.toImmutableList() + } + + private fun generalContentState(newItems: ImmutableList): ListUM.Content { + return ListUM.Content( + items = newItems, + loadMore = onLoadMoreUiItems, + visibleIdsChanged = visibleItemsChanged, + shouldShowUnder100kTokensNotification = false, + onShowTokensUnder100kClicked = {}, + triggerScrollReset = consumedEvent(), + onItemClick = onTokenClick, + shouldShowUnder100kTokensNotificationWasHidden = false, + ) + } + + private fun state(): MarketsListUM = MarketsListUM( + list = ListUM.Loading, + searchBar = SearchBarUM( + placeholderText = resourceReference(R.string.markets_search_header_title), + query = "", + onQueryChange = { searchQuery = it }, + isActive = false, + onActiveChange = { }, + ), + selectedSortBy = preselectedSortType(), + selectedInterval = MarketsListUM.TrendInterval.H24, + onIntervalClick = { selectedInterval = it }, + onSortByButtonClick = { isSortByBottomSheetShown = true }, + sortByBottomSheet = TangemBottomSheetConfig( + isShown = false, + onDismissRequest = { isSortByBottomSheetShown = false }, + content = SortByBottomSheetContentUM( + selectedOption = preselectedSortType(), + onOptionClicked = ::onBottomSheetOptionClicked, + ), + ), + marketsNotificationUM = null, + shouldAlwaysShowSearchBar = shouldAlwaysShowSearchBar(), + ) + + private fun onBottomSheetOptionClicked(sortByTypeUM: SortByTypeUM) { + state.update { marketsListUM -> + marketsListUM.copy( + selectedSortBy = sortByTypeUM, + sortByBottomSheet = marketsListUM.sortByBottomSheet.copy( + isShown = false, + content = (marketsListUM.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( + selectedOption = sortByTypeUM, + ), + ), + ) + } + } + + private fun consumeTriggerResetScrollEvent() { + state.update { marketsListUM -> + marketsListUM.copy( + list = if (marketsListUM.list is ListUM.Content) { + marketsListUM.list.copy( + triggerScrollReset = consumedEvent(), + ) + } else { + marketsListUM.list + }, + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/utils/LoggingUtils.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/utils/LoggingUtils.kt new file mode 100644 index 0000000000..03579058d8 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/utils/LoggingUtils.kt @@ -0,0 +1,58 @@ +package com.tangem.features.feed.model.market.list.utils + +import com.tangem.domain.markets.TokenMarket +import com.tangem.domain.markets.TokenMarketListConfig +import com.tangem.domain.markets.TokenMarketUpdateRequest +import com.tangem.pagination.BatchAction +import com.tangem.pagination.BatchUpdateResult +import com.tangem.pagination.PaginationStatus +import timber.log.Timber + +internal fun logStatus(tag: String, status: PaginationStatus>) { + Timber.tag(tag).d( + """ + Status + $status + """.trimIndent(), + ) +} + +internal fun logAction(tag: String, action: BatchAction) { + when (action) { + is BatchAction.Reload -> Timber.tag(tag).d( + """ + Reload = ${action.requestParams} + """.trimIndent(), + ) + is BatchAction.UpdateBatches -> Timber.tag(tag).d( + """ + To update: + keys: ${action.keys.toList()} + updateType: ${action.updateRequest.javaClass.simpleName} + """.trimIndent(), + ) + else -> Timber.tag(tag).d( + """ + $action + """.trimIndent(), + ) + } +} + +internal fun logUpdateResults( + tag: String, + updateResult: Pair>>, +) { + val sec = when (val s = updateResult.second) { + is BatchUpdateResult.Success -> "Success" + is BatchUpdateResult.Error -> s.throwable.toString() + } + + Timber.tag(tag).d( + """ + updateResults + request: ${updateResult.first} + result: $sec + """.trimIndent(), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryBottomSheetContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryBottomSheetContent.kt index cf87b0cefd..a94ee42c3c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryBottomSheetContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryBottomSheetContent.kt @@ -1,6 +1,9 @@ package com.tangem.features.feed.ui import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold @@ -12,6 +15,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.arkivanov.decompose.router.stack.ChildStack import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.features.feed.components.FeedEntryChildFactory @Composable @@ -20,8 +24,10 @@ internal fun EntryBottomSheetContent( onHeaderSizeChange: (Dp) -> Unit, ) { val density = LocalDensity.current + val background = LocalMainBottomSheetColor.current.value Scaffold( + containerColor = background, contentWindowInsets = WindowInsets(0.dp), topBar = { AnimatedContent( @@ -33,13 +39,15 @@ internal fun EntryBottomSheetContent( } } }, + transitionSpec = { fadeIn() togetherWith fadeOut() }, ) { currentState -> currentState.Title() } }, content = { contentPadding -> AnimatedContent( - stackState.active.instance, + targetState = stackState.active.instance, + transitionSpec = { fadeIn() togetherWith fadeOut() }, ) { currentState -> currentState.Content(modifier = Modifier.padding(contentPadding)) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index c35da92100..49bd19e7af 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -52,7 +52,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState import com.tangem.features.feed.ui.feed.state.* -import com.tangem.features.feed.ui.market.state.SortByTypeUM +import com.tangem.features.feed.ui.market.list.state.SortByTypeUM @Composable internal fun FeedListHeader(searchBarUM: SearchBarUM, modifier: Modifier = Modifier) { @@ -269,6 +269,7 @@ private fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendi } } +@Suppress("LongMethod") @Composable private fun NewsContentBlock( feedListCallbacks: FeedListCallbacks, @@ -315,21 +316,23 @@ private fun NewsContentBlock( ) SpacerH(12.dp) - trendingArticle?.let { article -> - ArticleCard( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - articleConfigUM = article, - onArticleClick = { feedListCallbacks.onArticleClick(article.id) }, - colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), - ) - SpacerH(12.dp) + if (trendingArticle != null) { + Column { + ArticleCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + articleConfigUM = trendingArticle, + onArticleClick = { feedListCallbacks.onArticleClick(trendingArticle.id) }, + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), + ) + SpacerH(12.dp) + } } LazyRow( verticalAlignment = Alignment.CenterVertically, - contentPadding = PaddingValues(16.dp), + contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), state = rememberLazyListState(), ) { @@ -347,6 +350,7 @@ private fun NewsContentBlock( ) } } + SpacerH(32.dp) } } @@ -388,10 +392,16 @@ private fun Charts( } } is MarketChartUM.LoadingError -> { - UnableToLoadData( - onRetryClick = marketChart.onRetryClicked, - modifier = Modifier.fillMaxWidth(), - ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 35.dp, horizontal = 10.dp), + ) { + UnableToLoadData( + onRetryClick = marketChart.onRetryClicked, + modifier = Modifier.fillMaxWidth(), + ) + } } is MarketChartUM.Content -> { marketChart.items.fastForEach { chart -> @@ -468,10 +478,18 @@ private fun NewsErrorBlock(onRetryClick: () -> Unit) { onSeeAllClick = {}, ) SpacerH(12.dp) - UnableToLoadData( - onRetryClick = onRetryClick, - modifier = Modifier.fillMaxWidth(), - ) + + BlockCard( + modifier = Modifier.padding(horizontal = 16.dp), + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), + ) { + UnableToLoadData( + onRetryClick = onRetryClick, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 35.dp, horizontal = 10.dp), + ) + } } } @@ -481,7 +499,7 @@ private const val GRADIENT_END = 0.5f private val LinearGradientFirstPart = Color(0xFF635EEC) private val LinearGradientSecondPart = Color(0xFFE05AED) -@Preview(showBackground = true) +@Preview(showBackground = true, heightDp = 1500) @Composable private fun FeedListPreview() { TangemThemePreview { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt index 9cf0dea267..d0d833fcd1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt @@ -11,7 +11,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.feed.ui.feed.state.* -import com.tangem.features.feed.ui.market.state.SortByTypeUM +import com.tangem.features.feed.ui.market.list.state.SortByTypeUM import kotlinx.collections.immutable.* @Suppress("MagicNumber") diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt index 3b613ccc86..269fcb8d30 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.core.ui.components.fields.entity.SearchBarUM -import com.tangem.features.feed.ui.market.state.SortByTypeUM +import com.tangem.features.feed.ui.market.list.state.SortByTypeUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toPersistentList diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt index b053465040..7d975bc782 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt @@ -5,8 +5,8 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.feed.model.converter.MarketsTokenItemConverter -import com.tangem.features.feed.ui.market.state.MarketsListUM -import com.tangem.features.feed.ui.market.state.SortByTypeUM +import com.tangem.features.feed.ui.market.list.state.MarketsListUM +import com.tangem.features.feed.ui.market.list.state.SortByTypeUM import com.tangem.pagination.Batch import com.tangem.pagination.BatchAction import com.tangem.pagination.BatchFetchResult diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt new file mode 100644 index 0000000000..1c86bc2b22 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt @@ -0,0 +1,350 @@ +package com.tangem.features.feed.ui.market.list + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.markets.preview.MarketChartListItemPreviewDataProvider +import com.tangem.core.ui.components.Keyboard +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.components.fields.SearchBar +import com.tangem.core.ui.components.fields.TangemSearchBarDefaults +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.components.keyboardAsState +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.list.components.MarketsListLazyColumn +import com.tangem.features.feed.ui.market.list.components.MarketsListSortByBottomSheet +import com.tangem.features.feed.ui.market.list.components.YieldSupplyInMarketsPromoNotification +import com.tangem.features.feed.ui.market.list.state.* +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +private const val SHOW_MORE_KEY = "privacyPolicy" + +@Composable +internal fun TopBarWithSearch(searchBarUM: SearchBarUM) { + val background = LocalMainBottomSheetColor.current.value + SearchBar( + modifier = Modifier + .drawBehind { drawRect(background) } + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp), + state = searchBarUM, + colors = TangemSearchBarDefaults.defaultTextFieldColors.copy( + focusedContainerColor = TangemTheme.colors.field.focused, + unfocusedContainerColor = TangemTheme.colors.field.focused, + ), + ) +} + +@Composable +internal fun MarketsList(state: MarketsListUM, modifier: Modifier = Modifier) { + val background = LocalMainBottomSheetColor.current.value + Column( + modifier = modifier + .fillMaxSize() + .imePadding() + .drawBehind { drawRect(background) }, + ) { + Content(state = state) + } + MarketsListSortByBottomSheet(config = state.sortByBottomSheet) + KeyboardEvents(isSortByBottomSheetShown = state.sortByBottomSheet.isShown) +} + +@Suppress("LongMethod") +@Composable +private fun ColumnScope.Content(state: MarketsListUM, modifier: Modifier = Modifier) { + val strokeColor = TangemTheme.colors.stroke.primary + val scrolledState = remember { mutableStateOf(false) } + + Column(modifier.padding(horizontal = TangemTheme.dimens.size16)) { + AnimatedVisibility( + visible = scrolledState.value.not(), + ) { + Column { + SpacerH8() + Title(isInSearchMode = state.isInSearchMode) + SpacerH12() + } + } + Column { + AnimatedVisibility(state.isInSearchMode.not()) { + Options( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + sortByTypeUM = state.selectedSortBy, + trendInterval = state.selectedInterval, + onIntervalClick = state.onIntervalClick, + onSortByClick = state.onSortByButtonClick, + ) + } + + val marketsNotification = state.marketsNotificationUM + AnimatedVisibility( + state.isInSearchMode.not() && + state.selectedSortBy != SortByTypeUM.YieldSupply, + ) { + val showMore = stringResourceSafe(R.string.common_show_more) + + when (marketsNotification) { + is MarketsNotificationUM.YieldSupplyPromo -> { + val description = stringResourceSafe( + R.string.markets_yield_supply_banner_description, + showMore, + ) + + val clickableDescription = annotatedReference { + append(description.substringBefore(showMore)) + + pushStringAnnotation(SHOW_MORE_KEY, "") + appendColored(showMore, TangemTheme.colors.text.accent) + pop() + } + + YieldSupplyInMarketsPromoNotification( + config = marketsNotification.config.copy( + subtitle = clickableDescription, + ), + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + ) + } + else -> { /* no-op */ + } + } + } + } + } + val strokeWidth = TangemTheme.dimens.size0_5 + Box( + Modifier + .fillMaxWidth() + .height(strokeWidth) + .drawBehind { + // draw horizontal line + if (scrolledState.value) { + drawLine( + color = strokeColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = strokeWidth.toPx(), + ) + } + }, + ) + ItemsList( + scrolledState = scrolledState, + isInSearchMode = state.isInSearchMode, + state = state.list, + ) +} + +@Composable +private fun Title(isInSearchMode: Boolean, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = if (isInSearchMode) { + stringResourceSafe(id = R.string.markets_search_result_title) + } else { + stringResourceSafe(id = R.string.markets_common_title) + }, + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) +} + +@Composable +private fun Options( + sortByTypeUM: SortByTypeUM, + trendInterval: MarketsListUM.TrendInterval, + onSortByClick: () -> Unit, + onIntervalClick: (MarketsListUM.TrendInterval) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .height(IntrinsicSize.Max) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + SecondarySmallButton( + config = SmallButtonConfig( + text = sortByTypeUM.text, + onClick = onSortByClick, + icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24), + ), + ) + SegmentedButtons( + config = persistentListOf( + MarketsListUM.TrendInterval.H24, + MarketsListUM.TrendInterval.D7, + MarketsListUM.TrendInterval.M1, + ), + color = TangemTheme.colors.button.secondary, + initialSelectedItem = trendInterval, + onClick = onIntervalClick, + modifier = Modifier + .width(160.dp) + .fillMaxHeight(), + ) { + Box( + Modifier + .fillMaxSize() + .align(Alignment.Center) + .padding( + vertical = TangemTheme.dimens.spacing4, + ), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = it.text.resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + } + } + } +} + +@Composable +private fun ItemsList( + scrolledState: MutableState, + isInSearchMode: Boolean, + state: ListUM, + modifier: Modifier = Modifier, +) { + val searchLazyListState = rememberLazyListState() + val mainLazyListState = rememberLazyListState() + + val isMainScrolled by remember { + derivedStateOf { + mainLazyListState.firstVisibleItemScrollOffset > 0 + } + } + + val isSearchScrolled by remember { + derivedStateOf { + searchLazyListState.firstVisibleItemScrollOffset > 0 + } + } + + LaunchedEffect(isMainScrolled, isInSearchMode, isSearchScrolled) { + scrolledState.value = if (isInSearchMode) { + isSearchScrolled + } else { + isMainScrolled + } + } + + MarketsListLazyColumn( + modifier = modifier, + state = state, + isInSearchMode = isInSearchMode, + lazyListState = if (isInSearchMode) { + searchLazyListState + } else { + mainLazyListState + }, + ) +} + +@Composable +private fun KeyboardEvents(isSortByBottomSheetShown: Boolean) { + val keyboardController = LocalSoftwareKeyboardController.current + val keyboard by keyboardAsState() + val focusManager = LocalFocusManager.current + + BackHandler(enabled = keyboard is Keyboard.Opened) { + keyboardController?.hide() + } + + LaunchedEffect(keyboard) { + if (keyboard is Keyboard.Closed) { + focusManager.clearFocus() + } + } + + LaunchedEffect(isSortByBottomSheetShown) { + keyboardController?.hide() + } +} + +//region: Preview + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview(alwaysShowBottomSheets = false) { + val primaryBackground = TangemTheme.colors.background.primary + + CompositionLocalProvider( + LocalMainBottomSheetColor provides remember { mutableStateOf(primaryBackground) }, + ) { + MarketsList( + state = MarketsListUM( + list = ListUM.Content( + items = MarketChartListItemPreviewDataProvider().values + .flatMap { item -> List(size = 10) { item } } + .mapIndexed { index, item -> + item.copy(id = CryptoCurrency.RawID(index.toString())) + } + .toImmutableList(), + shouldShowUnder100kTokensNotification = false, + shouldShowUnder100kTokensNotificationWasHidden = false, + loadMore = {}, + visibleIdsChanged = {}, + onShowTokensUnder100kClicked = {}, + triggerScrollReset = consumedEvent(), + onItemClick = {}, + ), + searchBar = SearchBarUM( + placeholderText = resourceReference(R.string.markets_search_header_title), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = { }, + ), + selectedSortBy = SortByTypeUM.Rating, + selectedInterval = MarketsListUM.TrendInterval.H24, + onIntervalClick = {}, + onSortByButtonClick = {}, + sortByBottomSheet = TangemBottomSheetConfig( + isShown = false, + onDismissRequest = {}, + content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {}, + ), + marketsNotificationUM = MarketsNotificationUM.YieldSupplyPromo( + onClick = {}, + onCloseClick = {}, + ), + shouldAlwaysShowSearchBar = true, + ), + ) + } + } +} + +//endregion: Preview \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt new file mode 100644 index 0000000000..495d901693 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt @@ -0,0 +1,214 @@ +package com.tangem.features.feed.ui.market.list.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import com.tangem.common.ui.markets.MarketsListItem +import com.tangem.common.ui.markets.MarketsListItemPlaceholder +import com.tangem.common.ui.markets.models.MarketsListItemUM.Companion.TOKEN_LAZY_LIST_ID_SEPARATOR +import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.list.InfiniteListHandler +import com.tangem.core.ui.event.EventEffect +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.MarketsTestTags +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.list.state.ListUM +import kotlinx.coroutines.launch + +private const val LOAD_NEXT_PAGE_ON_END_INDEX = 50 +private const val LOAD_NEXT_PAGE_ON_END_INDEX_SEARCH = 25 + +@Composable +@Suppress("LongMethod") +internal fun MarketsListLazyColumn( + state: ListUM, + isInSearchMode: Boolean, + lazyListState: LazyListState, + modifier: Modifier = Modifier, +) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + val coroutineScope = rememberCoroutineScope() + + SideEffect { + if (state is ListUM.Loading) { + coroutineScope.launch { + lazyListState.scrollToItem(0) + } + } + } + + if (state is ListUM.Content) { + EventEffect(state.triggerScrollReset) { + lazyListState.scrollToItem(0) + } + } + + if (state is ListUM.Loading) { + LazyColumn( + modifier = modifier, + state = rememberLazyListState(), + contentPadding = PaddingValues(bottom = bottomBarHeight), + userScrollEnabled = false, + ) { + items(count = 100, key = { it }) { + MarketsListItemPlaceholder() + } + } + } else { + LazyColumn( + modifier = modifier.testTag(MarketsTestTags.TOKENS_LIST), + state = lazyListState, + contentPadding = PaddingValues(bottom = bottomBarHeight), + userScrollEnabled = true, + ) { + // ATTENTION! There should be no elements with a string key value except MarketsListItem! + when (state) { + is ListUM.LoadingError -> { + item(key = "loading error".hashCode()) { + LoadingErrorItem( + modifier = Modifier.fillParentMaxSize(), + onTryAgain = state.onRetryClicked, + ) + } + } + ListUM.SearchNothingFound -> { + item(key = "not found text".hashCode()) { + SearchNothingFoundText( + modifier = Modifier.fillParentMaxSize(), + ) + } + } + is ListUM.Content -> { + items( + items = state.items, + key = { it.getComposeKey() }, + ) { item -> + MarketsListItem( + model = item, + onClick = { state.onItemClick(item) }, + ) + } + + if (isInSearchMode && state.shouldShowUnder100kTokensNotification) { + item(key = "show tokens under 100k".hashCode()) { + ShowTokensUnder100kItem( + onShowTokensClick = state.onShowTokensUnder100kClicked, + ) + } + } + } + else -> {} + } + } + } + + VisibleItemsTracker(lazyListState, state) + + InfiniteListHandler( + listState = lazyListState, + buffer = if (isInSearchMode) { + LOAD_NEXT_PAGE_ON_END_INDEX_SEARCH + } else { + LOAD_NEXT_PAGE_ON_END_INDEX + }, + triggerLoadMoreCheckOnItemsCountChange = true, + onLoadMore = remember(state) { + { + if (state is ListUM.Content && state.shouldShowUnder100kTokensNotification.not()) { + state.loadMore() + true + } else { + false + } + } + }, + ) +} + +@Composable +private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData(onRetryClick = onTryAgain) + } +} + +@Composable +private fun ShowTokensUnder100kItem(onShowTokensClick: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResourceSafe(R.string.markets_search_see_tokens_under_100k), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) + SecondarySmallButton( + config = SmallButtonConfig( + text = resourceReference(R.string.markets_search_show_tokens), + onClick = onShowTokensClick, + ), + ) + } +} + +@Composable +private fun SearchNothingFoundText(modifier: Modifier = Modifier) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResourceSafe(R.string.markets_search_token_no_result_title), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) + } +} + +@Composable +private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) { + val visibleItems by remember { + derivedStateOf { + listState.layoutInfo.visibleItemsInfo.mapNotNull { lazyListItemInfo -> + (lazyListItemInfo.key as? String) + ?.split(TOKEN_LAZY_LIST_ID_SEPARATOR) + ?.first() + ?.let { rawId -> CryptoCurrency.RawID(rawId) } + } + } + } + + LaunchedEffect(listState.isScrollInProgress, visibleItems) { + if (state is ListUM.Content && listState.isScrollInProgress.not()) { + state.visibleIdsChanged(visibleItems) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListSortByBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListSortByBottomSheet.kt new file mode 100644 index 0000000000..52f19e96f5 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListSortByBottomSheet.kt @@ -0,0 +1,85 @@ +package com.tangem.features.feed.ui.market.list.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.inputrow.InputRowChecked +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.list.state.SortByBottomSheetContentUM +import com.tangem.features.feed.ui.market.list.state.SortByTypeUM + +@Composable +fun MarketsListSortByBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + titleText = resourceReference(R.string.markets_sort_by_title), + containerColor = TangemTheme.colors.background.tertiary, + content = { Content(it) }, + ) +} + +@Composable +private fun Content(content: SortByBottomSheetContentUM) { + Column( + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) { + SortByTypeUM.entries.forEachIndexed { index, type -> + DividerContainer( + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = SortByTypeUM.entries.lastIndex, + addDefaultPadding = false, + ) + .background(TangemTheme.colors.background.action) + .clickable { content.onOptionClicked(type) }, + showDivider = index != SortByTypeUM.entries.lastIndex, + ) { + InputRowChecked( + text = type.text, + checked = type == content.selectedOption, + ) + } + } + } +} + +@Preview(widthDp = 360, heightDp = 640) +@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview( + alwaysShowBottomSheets = true, + ) { + Box(Modifier.background(TangemTheme.colors.background.secondary)) { + MarketsListSortByBottomSheet( + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = SortByBottomSheetContentUM( + selectedOption = SortByTypeUM.Trending, + onOptionClicked = {}, + ), + ), + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/StakingInMarketsPromoNotification.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/StakingInMarketsPromoNotification.kt new file mode 100644 index 0000000000..2313a8dd52 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/StakingInMarketsPromoNotification.kt @@ -0,0 +1,157 @@ +package com.tangem.features.feed.ui.market.list.components + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.notifications.CloseableIconButton +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +private val bgColor = Color(0x1F8CD9FF) +private val borderColor = Color(0x3D8CD9FF) + +@Composable +fun StakingInMarketsPromoNotification(config: NotificationConfig, modifier: Modifier = Modifier) { + var textHeightDp by remember { mutableStateOf(0.dp) } + + Box( + modifier = modifier + .fillMaxWidth() + .border( + width = 1.dp, + shape = TangemTheme.shapes.roundedCornersXMedium, + color = borderColor, + ) + .clip(shape = TangemTheme.shapes.roundedCornersXMedium) + .background(bgColor) + .clickable { config.onClick?.invoke() }, + ) { + PromoImage( + iconRes = config.iconResId, + modifier = Modifier.height(textHeightDp), + ) + PromoText( + title = config.title, + subtitle = config.subtitle, + onSizeChange = { textHeightDp = it }, + ) + CloseableIconButton( + onClick = config.onCloseClick, + modifier = Modifier.align(alignment = Alignment.TopEnd), + iconTint = TangemTheme.colors.icon.secondary, + ) + } +} + +@Composable +private fun PromoImage(@DrawableRes iconRes: Int, modifier: Modifier = Modifier) { + Box( + modifier = modifier.padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = iconRes), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .requiredWidth(56.dp) + .wrapContentHeight(Alignment.Top, unbounded = true), + ) + } +} + +@Composable +private fun PromoText(title: TextReference?, subtitle: TextReference, onSizeChange: (Dp) -> Unit) { + val density = LocalDensity.current + + Box( + modifier = Modifier.onSizeChanged { + with(density) { onSizeChange(it.height.toDp()) } + }, + ) { + TextsBlock( + title = title, + subtitle = subtitle, + modifier = Modifier + .wrapContentHeight() + .align(Alignment.CenterStart) + .padding(start = 76.dp, top = 12.dp, end = 12.dp, bottom = 12.dp), + ) + } +} + +@Composable +private fun TextsBlock(title: TextReference?, subtitle: TextReference, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + val titleText = title?.resolveReference() + + if (titleText != null) { + Text( + text = titleText, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.button, + ) + + SpacerH(height = TangemTheme.dimens.spacing2) + } + + Text( + text = subtitle.resolveAnnotatedReference(), + color = TangemTheme.colors.text.secondary, + style = TangemTheme.typography.caption2, + ) + } +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun RingPromoNotification_Preview() { + TangemThemePreview { + StakingInMarketsPromoNotification( + config = NotificationConfig( + title = stringReference("Earn up to 14% APY"), + subtitle = stringReference("Staking is the easiest way to earn rewards on your crypto. Show more"), + iconResId = R.drawable.img_staking_in_market_notification, + onCloseClick = { }, + ), + ) + } +} + +// endregion \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/YieldSupplyInMarketsPromoNotification.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/YieldSupplyInMarketsPromoNotification.kt new file mode 100644 index 0000000000..38730f216c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/YieldSupplyInMarketsPromoNotification.kt @@ -0,0 +1,157 @@ +package com.tangem.features.feed.ui.market.list.components + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.notifications.CloseableIconButton +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +private val bgColor = Color(0x2684B0D7) +private val borderColor = Color(0x2684B0D7) + +@Composable +fun YieldSupplyInMarketsPromoNotification(config: NotificationConfig, modifier: Modifier = Modifier) { + var textHeightDp by remember { mutableStateOf(0.dp) } + + Box( + modifier = modifier + .fillMaxWidth() + .border( + width = 1.dp, + shape = TangemTheme.shapes.roundedCornersXMedium, + color = borderColor, + ) + .clip(shape = TangemTheme.shapes.roundedCornersXMedium) + .background(bgColor) + .clickable { config.onClick?.invoke() }, + ) { + PromoImage( + iconRes = config.iconResId, + modifier = Modifier.height(textHeightDp), + ) + PromoText( + title = config.title, + subtitle = config.subtitle, + onSizeChange = { textHeightDp = it }, + ) + CloseableIconButton( + onClick = config.onCloseClick, + modifier = Modifier.align(alignment = Alignment.TopEnd), + iconTint = TangemTheme.colors.icon.secondary, + ) + } +} + +@Composable +private fun PromoImage(@DrawableRes iconRes: Int, modifier: Modifier = Modifier) { + Box( + modifier = modifier.padding(vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = iconRes), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .requiredWidth(80.dp) + .wrapContentHeight(Alignment.CenterVertically, unbounded = true), + ) + } +} + +@Composable +private fun PromoText(title: TextReference?, subtitle: TextReference, onSizeChange: (Dp) -> Unit) { + val density = LocalDensity.current + + Box( + modifier = Modifier.onSizeChanged { + with(density) { onSizeChange(it.height.toDp()) } + }, + ) { + TextsBlock( + title = title, + subtitle = subtitle, + modifier = Modifier + .wrapContentHeight() + .align(Alignment.CenterStart) + .padding(start = 80.dp, top = 12.dp, end = 12.dp, bottom = 12.dp), + ) + } +} + +@Composable +private fun TextsBlock(title: TextReference?, subtitle: TextReference, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + val titleText = title?.resolveReference() + + if (titleText != null) { + Text( + text = titleText, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.button, + ) + + SpacerH(height = TangemTheme.dimens.spacing2) + } + + Text( + text = subtitle.resolveAnnotatedReference(), + color = TangemTheme.colors.text.secondary, + style = TangemTheme.typography.caption2, + ) + } +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun RingPromoNotification_Preview() { + TangemThemePreview { + YieldSupplyInMarketsPromoNotification( + config = NotificationConfig( + title = stringReference("Activate Yield Mode"), + subtitle = stringReference("Power up your assets while supplying them with instant access. Show more"), + iconResId = R.drawable.img_yield_supply_in_market_notification, + onCloseClick = { }, + ), + ) + } +} + +// endregion \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsListUM.kt similarity index 91% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListUM.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsListUM.kt index 88dc1834e7..17fc25d214 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsListUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.ui.market.state +package com.tangem.features.feed.ui.market.list.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.markets.models.MarketsListItemUM @@ -10,7 +10,6 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.currency.CryptoCurrency import kotlinx.collections.immutable.ImmutableList -import java.math.BigDecimal internal data class MarketsListUM( val list: ListUM, @@ -18,11 +17,10 @@ internal data class MarketsListUM( val selectedSortBy: SortByTypeUM, val sortByBottomSheet: TangemBottomSheetConfig, val selectedInterval: TrendInterval, + val shouldAlwaysShowSearchBar: Boolean, val onIntervalClick: (TrendInterval) -> Unit, val onSortByButtonClick: () -> Unit, - val stakingNotificationMaxApy: BigDecimal?, - val onStakingNotificationClick: () -> Unit, - val onStakingNotificationCloseClick: () -> Unit, + val marketsNotificationUM: MarketsNotificationUM?, ) { val isInSearchMode get() = searchBar.isActive diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsNotificationUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsNotificationUM.kt new file mode 100644 index 0000000000..424060c994 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsNotificationUM.kt @@ -0,0 +1,22 @@ +package com.tangem.features.feed.ui.market.list.state + +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.feed.impl.R + +internal sealed class MarketsNotificationUM(val config: NotificationConfig) { + + data class YieldSupplyPromo( + val onClick: () -> Unit, + val onCloseClick: () -> Unit, + ) : MarketsNotificationUM( + config = NotificationConfig( + iconResId = R.drawable.img_yield_supply_in_market_notification, + title = resourceReference(R.string.markets_yield_supply_banner_title), + subtitle = TextReference.EMPTY, + onClick = onClick, + onCloseClick = onCloseClick, + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/SortByBottomSheetContentUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/SortByBottomSheetContentUM.kt new file mode 100644 index 0000000000..75e79d5844 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/SortByBottomSheetContentUM.kt @@ -0,0 +1,8 @@ +package com.tangem.features.feed.ui.market.list.state + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent + +data class SortByBottomSheetContentUM( + val selectedOption: SortByTypeUM, + val onOptionClicked: (SortByTypeUM) -> Unit, +) : TangemBottomSheetConfigContent \ No newline at end of file From fe6c26624a78e5b53a985b0ca455338dcbf76359 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 18 Dec 2025 16:54:01 +0400 Subject: [PATCH 006/172] Updated on 2026-08-14 --- .../cardsettings/model/CardSettingsModel.kt | 24 +++--- .../details/ui/resetcard/ResetCardScreen.kt | 36 ++++----- .../ui/resetcard/ResetCardScreenState.kt | 19 +++-- .../ui/resetcard/api/ResetCardComponent.kt | 1 + .../ui/resetcard/model/ResetCardModel.kt | 73 +++++++++++-------- .../tangem/tap/routing/utils/ChildFactory.kt | 1 + .../com/tangem/common/routing/AppRoute.kt | 1 + core/res/src/main/res/values-ru/strings.xml | 2 +- core/res/src/main/res/values/strings.xml | 3 + 9 files changed, 89 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt index 08aa5ef0b0..797ef6c056 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt @@ -18,6 +18,7 @@ import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.requireColdWallet +import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.builder.UserWalletIdBuilder import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -54,6 +55,7 @@ internal class CardSettingsModel @Inject constructor( private val getUserWalletUseCase: GetUserWalletUseCase, private val cardSdkConfigRepository: CardSdkConfigRepository, private val settingsRepository: SettingsRepository, + private val onboardingRepository: OnboardingRepository, ) : Model() { private val params = paramsContainer.require() @@ -218,15 +220,19 @@ internal class CardSettingsModel @Inject constructor( } else { val card = scanResponse.card - store.dispatchNavigationAction { - push( - route = AppRoute.ResetToFactory( - userWalletId = userWalletId, - cardId = card.cardId, - isActiveBackupStatus = card.backupStatus?.isActive == true, - backupCardsCount = scanResponse.getBackupCardsCount() ?: 0, - ), - ) + modelScope.launch { + val hasTangemPay = onboardingRepository.checkCustomerWallet(userWalletId).getOrNull() == true + store.dispatchNavigationAction { + push( + route = AppRoute.ResetToFactory( + userWalletId = userWalletId, + cardId = card.cardId, + isActiveBackupStatus = card.backupStatus?.isActive == true, + backupCardsCount = scanResponse.getBackupCardsCount() ?: 0, + hasTangemPay = hasTangemPay, + ), + ) + } } } } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt index 3e04e9381b..818620b38f 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt @@ -22,6 +22,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.tap.features.details.ui.common.DetailsMainButton import com.tangem.tap.features.details.ui.common.SettingsScreensScaffold import com.tangem.wallet.R +import kotlinx.collections.immutable.persistentListOf import com.tangem.tap.features.details.ui.resetcard.ResetCardScreenState.Dialog as ResetCardDialog @Composable @@ -114,22 +115,11 @@ private fun Description(text: TextReference) { @Composable private fun Conditions(state: ResetCardScreenState) { state.warningsToShow.forEach { warning -> - when (warning) { - ResetCardScreenState.WarningsToReset.LOST_WALLET_ACCESS -> { - ConditionCheckBox( - checkedState = state.isAcceptCondition1Checked, - onCheckedChange = state.onAcceptCondition1ToggleClick, - description = TextReference.Res(R.string.reset_card_to_factory_condition_1), - ) - } - ResetCardScreenState.WarningsToReset.LOST_PASSWORD_RESTORE -> { - ConditionCheckBox( - checkedState = state.isAcceptCondition2Checked, - onCheckedChange = state.onAcceptCondition2ToggleClick, - description = TextReference.Res(R.string.reset_card_to_factory_condition_2), - ) - } - } + ConditionCheckBox( + checkedState = warning.isChecked, + onCheckedChange = { state.onToggleWarning(warning.type) }, + description = warning.description, + ) } } @@ -239,14 +229,16 @@ private fun ResetCardScreenSample(modifier: Modifier = Modifier) { ResetCardScreen( state = ResetCardScreenState( isResetButtonEnabled = true, - isResetPasswordButtonShown = false, - warningsToShow = listOf(ResetCardScreenState.WarningsToReset.LOST_WALLET_ACCESS), + warningsToShow = persistentListOf( + ResetCardScreenState.WarningUM( + isChecked = false, + type = ResetCardScreenState.WarningType.LOST_WALLET_ACCESS, + description = TextReference.Res(id = R.string.reset_card_to_factory_condition_1), + ), + ), descriptionText = TextReference.Res(R.string.reset_card_with_backup_to_factory_message), - isAcceptCondition1Checked = false, - isAcceptCondition2Checked = false, - onAcceptCondition1ToggleClick = {}, - onAcceptCondition2ToggleClick = {}, onResetButtonClick = {}, + onToggleWarning = {}, dialog = null, ), onBackClick = {}, diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreenState.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreenState.kt index 68b8585c72..0011e5dcd6 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreenState.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreenState.kt @@ -3,16 +3,13 @@ package com.tangem.tap.features.details.ui.resetcard import androidx.annotation.StringRes import com.tangem.core.ui.extensions.TextReference import com.tangem.wallet.R +import kotlinx.collections.immutable.ImmutableList internal data class ResetCardScreenState( val isResetButtonEnabled: Boolean, val descriptionText: TextReference, - val warningsToShow: List, - val isResetPasswordButtonShown: Boolean, - val isAcceptCondition1Checked: Boolean, - val isAcceptCondition2Checked: Boolean, - val onAcceptCondition1ToggleClick: (Boolean) -> Unit, - val onAcceptCondition2ToggleClick: (Boolean) -> Unit, + val warningsToShow: ImmutableList, + val onToggleWarning: (WarningType) -> Unit, val onResetButtonClick: () -> Unit, val dialog: Dialog?, ) { @@ -58,7 +55,13 @@ internal data class ResetCardScreenState( } } - internal enum class WarningsToReset { - LOST_WALLET_ACCESS, LOST_PASSWORD_RESTORE + internal data class WarningUM( + val isChecked: Boolean, + val type: WarningType, + val description: TextReference, + ) + + internal enum class WarningType { + LOST_WALLET_ACCESS, LOST_PASSWORD_RESTORE, LOST_TANGEM_PAY } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/api/ResetCardComponent.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/api/ResetCardComponent.kt index 64dc8b1c5d..9f24f61f9b 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/api/ResetCardComponent.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/api/ResetCardComponent.kt @@ -11,6 +11,7 @@ interface ResetCardComponent : ComposableContentComponent { val cardId: String, val isActiveBackupStatus: Boolean, val backupCardsCount: Int, + val hasTangemPay: Boolean, ) interface Factory : ComponentFactory diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt index eaa4ce0a9f..c53ead9529 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt @@ -8,6 +8,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler 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.TextReference import com.tangem.domain.card.DeleteSavedAccessCodesUseCase import com.tangem.domain.card.ResetCardUseCase import com.tangem.domain.card.ResetCardUserCodeParams @@ -30,6 +31,9 @@ import com.tangem.tap.features.details.ui.resetcard.api.ResetCardComponent import com.tangem.tap.store import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE +import com.tangem.wallet.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -82,33 +86,22 @@ internal class ResetCardModel @Inject constructor( // TODO: move logic to separate domain entity private var resetBackupCardCount = 0 + private var warningsMap = emptyMap() + val screenState: MutableStateFlow = MutableStateFlow( value = getInitialState(), ) private fun getInitialState(): ResetCardScreenState { - val shouldShowResetPasswordButton = shouldShowResetPasswordButton() - val warningsToShow = buildList { - add(ResetCardScreenState.WarningsToReset.LOST_WALLET_ACCESS) - - if (shouldShowResetPasswordButton) { - add(ResetCardScreenState.WarningsToReset.LOST_PASSWORD_RESTORE) - } - } - return ResetCardScreenState( isResetButtonEnabled = false, descriptionText = getResetToFactoryDescription( isActiveBackupStatus = isActiveBackupPrimaryCard, typesResolver = currentCardTypesResolver, ), - warningsToShow = warningsToShow, - isResetPasswordButtonShown = shouldShowResetPasswordButton, - isAcceptCondition1Checked = false, - isAcceptCondition2Checked = false, - onAcceptCondition1ToggleClick = ::toggleFirstCondition, - onAcceptCondition2ToggleClick = ::toggleSecondCondition, + warningsToShow = buildInitialItems(), onResetButtonClick = { showDialog(ResetCardDialog.StartResetDialog) }, + onToggleWarning = ::toggleCondition, dialog = null, ) } @@ -119,28 +112,46 @@ internal class ResetCardModel @Inject constructor( return isTangemWallet && isActiveBackupPrimaryCard } - private fun toggleFirstCondition(isAccepted: Boolean) { - screenState.update { prevState -> - val isResetButtonEnabled = if (prevState.isResetPasswordButtonShown) { - isAccepted && prevState.isAcceptCondition2Checked - } else { - isAccepted - } + private fun buildInitialItems(): ImmutableList { + val shouldShowResetPasswordButton = shouldShowResetPasswordButton() + val shouldShowResetTangemPayButton = params.hasTangemPay - prevState.copy( - isAcceptCondition1Checked = isAccepted, - isResetButtonEnabled = isResetButtonEnabled, - ) + val lostWalletUM = ResetCardScreenState.WarningUM( + isChecked = false, + type = ResetCardScreenState.WarningType.LOST_WALLET_ACCESS, + description = TextReference.Res(id = R.string.reset_card_to_factory_condition_1), + ) + val lostPasswordUM = ResetCardScreenState.WarningUM( + isChecked = false, + type = ResetCardScreenState.WarningType.LOST_PASSWORD_RESTORE, + description = TextReference.Res(R.string.reset_card_to_factory_condition_2), + ) + val lostTangemPayUM = ResetCardScreenState.WarningUM( + isChecked = false, + type = ResetCardScreenState.WarningType.LOST_TANGEM_PAY, + description = TextReference.Res(R.string.reset_card_to_factory_condition_3), + ) + + warningsMap = buildMap { + put(ResetCardScreenState.WarningType.LOST_WALLET_ACCESS, lostWalletUM) + if (shouldShowResetPasswordButton) { + put(ResetCardScreenState.WarningType.LOST_PASSWORD_RESTORE, lostPasswordUM) + } + if (shouldShowResetTangemPayButton) { + put(ResetCardScreenState.WarningType.LOST_TANGEM_PAY, lostTangemPayUM) + } } + return warningsMap.values.toImmutableList() } - private fun toggleSecondCondition(isAccepted: Boolean) { + private fun toggleCondition(type: ResetCardScreenState.WarningType) { screenState.update { prevState -> - val isResetButtonEnabled = prevState.isAcceptCondition1Checked && isAccepted - + warningsMap[type]?.let { current -> + warningsMap = warningsMap + (type to current.copy(isChecked = !current.isChecked)) + } prevState.copy( - isAcceptCondition2Checked = isAccepted, - isResetButtonEnabled = isResetButtonEnabled, + warningsToShow = warningsMap.values.toImmutableList(), + isResetButtonEnabled = warningsMap.values.all { it.isChecked }, ) } } diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 99c8e414b9..2462507a7b 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -424,6 +424,7 @@ internal class ChildFactory @Inject constructor( cardId = route.cardId, isActiveBackupStatus = route.isActiveBackupStatus, backupCardsCount = route.backupCardsCount, + hasTangemPay = route.hasTangemPay, ), componentFactory = resetCardComponentFactory, ) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 0c771f5609..dbb07560d9 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -113,6 +113,7 @@ sealed class AppRoute(val path: String) : Route { val cardId: String, val isActiveBackupStatus: Boolean, val backupCardsCount: Int, + val hasTangemPay: Boolean, ) : AppRoute( path = "/reset_to_factory" + "/${userWalletId.stringValue}" + diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 0d5eaa197f..03ce372d0f 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -2017,7 +2017,7 @@ %1$s выведено из Aave Режим доходности инициализирован Режим доходности реактивирован - Перевод средств в Aave + Перевод в Aave %1$s отправлено в Aave Вывод из Aave Автоматически diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 524e2ec97f..c6ef91ce1f 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1083,6 +1083,7 @@ Reset the card I understand that after performing this action, I will no longer have access to the current wallet I realize that I can\'t use this card to recover my access code on the other cards of the current wallet + I understand that I will completely lose access to my Tangem Pay Card and all funds on it without the possibility of recovery Factory Reset will completely delete the wallet from the selected card or ring. You will not be able to restore the current wallet or use the card or ring to recover the access code. Factory Reset will completely delete the wallet from the selected card or ring and remove it from the app. You will not be able to restore the current wallet. All Tangem devices have been reset. @@ -1091,6 +1092,8 @@ Please reset the next device to continue Ring owners get 3 commission-free swaps on Changelly until 15.11! Swap With 0% Fees Now! + Devices with root access are considered less secure. Your data may be exposed to additional risks. + Root access detected Log into the app and check your balance without scanning the card or ring Access the app Allow to use biometrics From 5060b5c0281b959b0fd6b09bde2ece5a77315c39 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 18 Dec 2025 16:25:57 +0200 Subject: [PATCH 007/172] Updated on 2026-08-14 --- .../response/P2PEthPoolVaultsResponse.kt | 11 +++-- .../staking/DefaultP2PEthPoolRepository.kt | 2 +- .../P2PEthPoolStakingBalanceConverter.kt | 26 +++++++--- .../ethpool/P2PEthPoolVaultConverter.kt | 15 ++++-- .../DefaultMultiStakingBalanceFetcher.kt | 4 +- .../store/DefaultP2PEthPoolBalancesStore.kt | 12 +++++ .../staking/store/P2PEthPoolBalancesStore.kt | 2 + .../tangem/data/staking/StakingBalanceExt.kt | 2 +- .../staking/model/common/RewardClaiming.kt | 10 ++++ .../staking/model/common/RewardSchedule.kt | 15 ++++++ .../staking/model/common/StakingActionArgs.kt | 17 +++++++ .../model/common/StakingAmountRequirement.kt | 19 ++++++++ .../staking/model/P2PEthPoolIntegration.kt | 27 +++++++++-- .../staking/model/StakeKitIntegration.kt | 47 +++++++++++++++++-- .../staking/model/StakingIntegration.kt | 13 ++--- .../SetInitialDataStateTransformer.kt | 10 ++-- .../AmountRequirementStateTransformer.kt | 11 ++--- .../StakingInfoNotificationsFactory.kt | 5 +- .../state/utils/StakingRewardsUtils.kt | 28 +++++------ 19 files changed, 212 insertions(+), 64 deletions(-) create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardClaiming.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardSchedule.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/StakingActionArgs.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/StakingAmountRequirement.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt index 1e3bde7a5a..8a8eaf6167 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt @@ -2,6 +2,7 @@ package com.tangem.datasource.api.ethpool.models.response import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import java.math.BigDecimal /** * Response for GET /api/v1/staking/pool/{network}/vaults @@ -33,15 +34,15 @@ data class P2PEthPoolVaultDTO( @Json(name = "displayName") val displayName: String, @Json(name = "apy") - val apy: Double, + val apy: BigDecimal, @Json(name = "baseApy") - val baseApy: Double, + val baseApy: BigDecimal, @Json(name = "capacity") - val capacity: Double, + val capacity: BigDecimal, @Json(name = "totalAssets") - val totalAssets: Double, + val totalAssets: BigDecimal, @Json(name = "feePercent") - val feePercent: Double, + val feePercent: BigDecimal, @Json(name = "isPrivate") val isPrivate: Boolean, @Json(name = "isGenesis") diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt index 33ea700fb0..54d82d69e9 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -44,7 +44,7 @@ internal class DefaultP2PEthPoolRepository( Timber.e("Error fetching P2PEthPool vaults: $error") emptyList() } - p2pEthPoolVaultsStore.store(vaults) + p2pEthPoolVaultsStore.store(vaults.filter { !it.isPrivate }) // TODO eth isSmoothingPool? } override suspend fun getVaults(network: P2PEthPoolNetwork): Either> = either { diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingBalanceConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingBalanceConverter.kt index 61962eae94..9940c0d306 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingBalanceConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingBalanceConverter.kt @@ -8,11 +8,12 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.staking.* import com.tangem.domain.staking.model.StakingIntegrationID import kotlinx.datetime.Instant +import java.math.BigDecimal -/** Converts P2PEthPool API response to [StakingBalance.Data.P2PEthPool] */ +/** Converts P2PEthPool API response to [StakingBalance] */ internal object P2PEthPoolStakingBalanceConverter { - fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance.Data.P2PEthPool { + fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance { val stakingId = StakingID( integrationId = StakingIntegrationID.P2PEthPool.value, address = response.delegatorAddress, @@ -27,11 +28,22 @@ internal object P2PEthPoolStakingBalanceConverter { exitQueue = convertExitQueue(response.exitQueue), ) - return StakingBalance.Data.P2PEthPool( - stakingId = stakingId, - source = source, - account = account, - ) + val hasActivePosition = account.stake.assets > BigDecimal.ZERO || + account.exitQueue.total > BigDecimal.ZERO || + account.availableToWithdraw > BigDecimal.ZERO + + return if (hasActivePosition) { + StakingBalance.Data.P2PEthPool( + stakingId = stakingId, + source = source, + account = account, + ) + } else { + StakingBalance.Empty( + stakingId = stakingId, + source = source, + ) + } } private fun convertStake(dto: P2PEthPoolStakeDTO): P2PEthPoolStake { diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt index 75f9a6a470..1925cd298e 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt @@ -3,21 +3,26 @@ package com.tangem.data.staking.converters.ethpool import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultDTO import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import com.tangem.utils.converter.Converter +import java.math.BigDecimal +import java.math.RoundingMode /** * Converter from P2PEthPool Vault DTO to Domain model */ internal object P2PEthPoolVaultConverter : Converter { + private val HUNDRED = BigDecimal(100) + private const val DIVIDE_SCALE = 8 + override fun convert(value: P2PEthPoolVaultDTO): P2PEthPoolVault { return P2PEthPoolVault( vaultAddress = value.vaultAddress, displayName = value.displayName, - apy = value.apy.toBigDecimal(), - baseApy = value.baseApy.toBigDecimal(), - capacity = value.capacity.toBigDecimal(), - totalAssets = value.totalAssets.toBigDecimal(), - feePercent = value.feePercent.toBigDecimal(), + apy = value.apy.divide(HUNDRED, DIVIDE_SCALE, RoundingMode.HALF_UP), + baseApy = value.baseApy.divide(HUNDRED, DIVIDE_SCALE, RoundingMode.HALF_UP), + capacity = value.capacity, + totalAssets = value.totalAssets, + feePercent = value.feePercent, isPrivate = value.isPrivate, isGenesis = value.isGenesis, isSmoothingPool = value.isSmoothingPool, 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 60c12b3b12..2ff4700bb3 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 @@ -136,8 +136,8 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( val vaults = runSuspendCatching { p2pEthPoolVaultsStore.getSync() }.getOrNull().orEmpty() if (vaults.isEmpty()) { - Timber.w("No P2PEthPool vaults available for $userWalletId") - p2PEthPoolBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) + Timber.w("No P2PEthPool vaults available for $userWalletId, storing empty balances") + p2PEthPoolBalancesStore.storeEmpty(userWalletId = userWalletId, stakingIds = stakingIds) return } diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt index 2be492ef3a..35f65f95da 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt @@ -90,6 +90,15 @@ internal class DefaultP2PEthPoolBalancesStore( } } + override suspend fun storeEmpty(userWalletId: UserWalletId, stakingIds: Set) { + updateInRuntime( + userWalletId = userWalletId, + stakingIds = stakingIds, + ifNotFound = ::createEmptyStakingBalance, + update = { it.copySealed(source = StatusSource.ACTUAL) }, + ) + } + override suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set) { updateInRuntime( userWalletId = userWalletId, @@ -187,5 +196,8 @@ internal class DefaultP2PEthPoolBalancesStore( } } + private fun createEmptyStakingBalance(id: StakingID): StakingBalance = + StakingBalance.Empty(stakingId = id, source = StatusSource.ACTUAL) + private fun createErrorStakingBalance(id: StakingID): StakingBalance = StakingBalance.Error(stakingId = id) } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt index 72891d6a1b..06d300af6e 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt @@ -23,6 +23,8 @@ interface P2PEthPoolBalancesStore { suspend fun storeActual(userWalletId: UserWalletId, values: Set) + suspend fun storeEmpty(userWalletId: UserWalletId, stakingIds: Set) + suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set) suspend fun clear(userWalletId: UserWalletId, stakingIds: Set) diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt index ee45a232fe..aec8fadd1f 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt @@ -13,7 +13,7 @@ internal fun YieldBalanceWrapperDTO.toDomain(source: StatusSource = StatusSource internal fun P2PEthPoolAccountResponse.toDomain( source: StatusSource = StatusSource.CACHE, -): StakingBalance.Data.P2PEthPool { +): StakingBalance { return P2PEthPoolStakingBalanceConverter.convert( response = this, source = source, diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardClaiming.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardClaiming.kt new file mode 100644 index 0000000000..47ea02a99a --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardClaiming.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.staking.model.common + +import kotlinx.serialization.Serializable + +@Serializable +enum class RewardClaiming { + AUTO, + MANUAL, + UNKNOWN, +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardSchedule.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardSchedule.kt new file mode 100644 index 0000000000..8dffddf1e6 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/RewardSchedule.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.staking.model.common + +import kotlinx.serialization.Serializable + +@Serializable +enum class RewardSchedule { + BLOCK, + HOUR, + DAY, + WEEK, + MONTH, + ERA, + EPOCH, + UNKNOWN, +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/StakingActionArgs.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/StakingActionArgs.kt new file mode 100644 index 0000000000..bdf0f5a05b --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/StakingActionArgs.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.staking.model.common + +import kotlinx.serialization.Serializable + +/** + * Provider-agnostic representation of staking action arguments. + * Contains amount requirements and constraints for enter/exit operations. + * + * Maps from: + * - StakeKit: Yield.Args.Enter + * - P2PEthPool: P2PEthPoolStaking.Metadata + */ +@Serializable +data class StakingActionArgs( + val amountRequirement: StakingAmountRequirement?, + val isPartialAmountDisabled: Boolean, +) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/StakingAmountRequirement.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/StakingAmountRequirement.kt new file mode 100644 index 0000000000..87b4eb083d --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/common/StakingAmountRequirement.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.staking.model.common + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import kotlinx.serialization.Serializable + +/** + * Provider-agnostic representation of staking amount requirements. + * Contains validation constraints for stake/unstake amounts. + * + * Maps from: + * - StakeKit: AddressArgument with ArgType.AMOUNT + * - P2PEthPool: P2PEthPoolStaking.Metadata minimumStake/maximumStake + */ +@Serializable +data class StakingAmountRequirement( + val isRequired: Boolean, + val minimum: SerializedBigDecimal? = null, + val maximum: SerializedBigDecimal? = null, +) \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt index 97fc1c11cf..052cb4509b 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt @@ -2,8 +2,11 @@ package com.tangem.domain.staking.model import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.staking.YieldToken +import com.tangem.domain.staking.model.common.RewardClaiming +import com.tangem.domain.staking.model.common.RewardSchedule +import com.tangem.domain.staking.model.common.StakingActionArgs +import com.tangem.domain.staking.model.common.StakingAmountRequirement import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault -import com.tangem.domain.staking.model.stakekit.Yield import java.math.BigDecimal /** @@ -39,9 +42,23 @@ class P2PEthPoolIntegration( override val exitMinimumAmount: BigDecimal? = null - override val enterArgs: Yield.Args.Enter? = null + override val enterArgs: StakingActionArgs = StakingActionArgs( + amountRequirement = StakingAmountRequirement( + isRequired = true, + minimum = DEFAULT_MINIMUM_STAKE, + maximum = null, + ), + isPartialAmountDisabled = false, + ) - override val exitArgs: Yield.Args.Enter? = null + override val exitArgs: StakingActionArgs = StakingActionArgs( + amountRequirement = StakingAmountRequirement( + isRequired = true, + minimum = null, + maximum = null, + ), + isPartialAmountDisabled = false, + ) // Metadata @@ -49,9 +66,9 @@ class P2PEthPoolIntegration( override val cooldownPeriodDays: Int = DEFAULT_COOLDOWN_DAYS - override val rewardSchedule: Yield.Metadata.RewardSchedule = Yield.Metadata.RewardSchedule.DAY + override val rewardSchedule: RewardSchedule = RewardSchedule.DAY - override val rewardClaiming: Yield.Metadata.RewardClaiming = Yield.Metadata.RewardClaiming.AUTO + override val rewardClaiming: RewardClaiming = RewardClaiming.AUTO override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = token diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt index d6b3ad49e5..fa2d4542a7 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt @@ -2,6 +2,10 @@ package com.tangem.domain.staking.model import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.staking.YieldToken +import com.tangem.domain.staking.model.common.RewardClaiming +import com.tangem.domain.staking.model.common.RewardSchedule +import com.tangem.domain.staking.model.common.StakingActionArgs +import com.tangem.domain.staking.model.common.StakingAmountRequirement import com.tangem.domain.staking.model.stakekit.Yield import java.math.BigDecimal @@ -39,9 +43,9 @@ class StakeKitIntegration( yield.args.exit?.args ?.get(Yield.Args.ArgType.AMOUNT)?.minimum - override val enterArgs: Yield.Args.Enter = yield.args.enter + override val enterArgs: StakingActionArgs = yield.args.enter.toStakingActionArgs() - override val exitArgs: Yield.Args.Enter? = yield.args.exit + override val exitArgs: StakingActionArgs? = yield.args.exit?.toStakingActionArgs() // Metadata @@ -49,12 +53,47 @@ class StakeKitIntegration( override val cooldownPeriodDays: Int? = yield.metadata.cooldownPeriod?.days - override val rewardSchedule: Yield.Metadata.RewardSchedule = yield.metadata.rewardSchedule + override val rewardSchedule: RewardSchedule = yield.metadata.rewardSchedule.toRewardSchedule() - override val rewardClaiming: Yield.Metadata.RewardClaiming = yield.metadata.rewardClaiming + override val rewardClaiming: RewardClaiming = yield.metadata.rewardClaiming.toRewardClaiming() // Basic override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = tokens.firstOrNull { rawCurrencyId?.value == it.coinGeckoId } ?: token + + private fun Yield.Args.Enter.toStakingActionArgs(): StakingActionArgs { + val amountArg = args[Yield.Args.ArgType.AMOUNT] + return StakingActionArgs( + amountRequirement = amountArg?.let { arg -> + StakingAmountRequirement( + isRequired = arg.required, + minimum = arg.minimum, + maximum = arg.maximum, + ) + }, + isPartialAmountDisabled = isPartialAmountDisabled, + ) + } + + private fun Yield.Metadata.RewardSchedule.toRewardSchedule(): RewardSchedule { + return when (this) { + Yield.Metadata.RewardSchedule.BLOCK -> RewardSchedule.BLOCK + Yield.Metadata.RewardSchedule.HOUR -> RewardSchedule.HOUR + Yield.Metadata.RewardSchedule.DAY -> RewardSchedule.DAY + Yield.Metadata.RewardSchedule.WEEK -> RewardSchedule.WEEK + Yield.Metadata.RewardSchedule.MONTH -> RewardSchedule.MONTH + Yield.Metadata.RewardSchedule.ERA -> RewardSchedule.ERA + Yield.Metadata.RewardSchedule.EPOCH -> RewardSchedule.EPOCH + Yield.Metadata.RewardSchedule.UNKNOWN -> RewardSchedule.UNKNOWN + } + } + + private fun Yield.Metadata.RewardClaiming.toRewardClaiming(): RewardClaiming { + return when (this) { + Yield.Metadata.RewardClaiming.AUTO -> RewardClaiming.AUTO + Yield.Metadata.RewardClaiming.MANUAL -> RewardClaiming.MANUAL + Yield.Metadata.RewardClaiming.UNKNOWN -> RewardClaiming.UNKNOWN + } + } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt index a20e1d6ebb..37f76ede7f 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt @@ -2,14 +2,15 @@ package com.tangem.domain.staking.model import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.staking.YieldToken -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.common.RewardClaiming +import com.tangem.domain.staking.model.common.RewardSchedule +import com.tangem.domain.staking.model.common.StakingActionArgs import java.math.BigDecimal /** * Strategy interface for staking integrations. * Abstracts over StakeKit and P2PEthPool staking providers. */ -// TODO p2p get rid of stakekit-specific models in StakingIntegration and implementors interface StakingIntegration { // Basic @@ -36,9 +37,9 @@ interface StakingIntegration { val exitMinimumAmount: BigDecimal? - val enterArgs: Yield.Args.Enter? + val enterArgs: StakingActionArgs? - val exitArgs: Yield.Args.Enter? + val exitArgs: StakingActionArgs? // Metadata @@ -46,9 +47,9 @@ interface StakingIntegration { val cooldownPeriodDays: Int? - val rewardSchedule: Yield.Metadata.RewardSchedule + val rewardSchedule: RewardSchedule - val rewardClaiming: Yield.Metadata.RewardClaiming + val rewardClaiming: RewardClaiming // Basic diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index 86fdcfd533..f1404c4a63 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -19,10 +19,10 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.staking.BalanceItem import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.staking.model.common.RewardType import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.StakingTarget -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.common.RewardClaiming +import com.tangem.domain.staking.model.common.RewardType import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.model.StakingClickIntents import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState @@ -293,9 +293,9 @@ internal class SetInitialDataStateTransformer( val EQUALITY_THRESHOLD = BigDecimal(1E-10) val rewardClaimingResources = mapOf( - Yield.Metadata.RewardClaiming.MANUAL to R.string.staking_reward_claiming_manual, - Yield.Metadata.RewardClaiming.AUTO to R.string.staking_reward_claiming_auto, - Yield.Metadata.RewardSchedule.UNKNOWN to null, + RewardClaiming.MANUAL to R.string.staking_reward_claiming_manual, + RewardClaiming.AUTO to R.string.staking_reward_claiming_auto, + RewardClaiming.UNKNOWN to null, ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt index 8af103c0f1..5421794d27 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt @@ -13,8 +13,7 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.staking.model.StakingIntegration -import com.tangem.domain.staking.model.stakekit.AddressArgument -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.common.StakingAmountRequirement import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.R import com.tangem.lib.crypto.BlockchainUtils.isTron @@ -97,11 +96,11 @@ internal class AmountRequirementStateTransformer( return when (actionType) { is StakingActionCommonType.Enter -> { - val enterRequirements = integration.enterArgs?.args?.get(Yield.Args.ArgType.AMOUNT) + val enterRequirements = integration.enterArgs?.amountRequirement enterRequirements?.getError(amountDecimal, R.string.staking_amount_requirement_error) } is StakingActionCommonType.Exit -> { - val exitRequirements = integration.exitArgs?.args?.get(Yield.Args.ArgType.AMOUNT) + val exitRequirements = integration.exitArgs?.amountRequirement exitRequirements?.getError(amountDecimal, R.string.staking_unstake_amount_requirement_error) } else -> null @@ -119,7 +118,7 @@ internal class AmountRequirementStateTransformer( return isEnterOrExit && isTron && !isIntegerOnly } - private fun AddressArgument.getError(amount: BigDecimal, @StringRes errorTextRes: Int): TextReference? { + private fun StakingAmountRequirement.getError(amount: BigDecimal, @StringRes errorTextRes: Int): TextReference? { val isExceedsMinRequirement = minimum?.compareTo(amount) == 1 val isExceedsMaxRequirement = if (maximum?.isPositive() == true) { maximum?.compareTo(amount) == -1 @@ -143,7 +142,7 @@ internal class AmountRequirementStateTransformer( return resourceReference( errorTextRes, wrappedList(errorText), - ).takeIf { required && (isExceedsMinRequirement || isExceedsMaxRequirement) } + ).takeIf { isRequired && (isExceedsMinRequirement || isExceedsMaxRequirement) } } data class Data( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt index 7b6e21df7d..905ca35ddb 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt @@ -9,7 +9,6 @@ import com.tangem.domain.models.staking.BalanceType import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.action.StakingActionType import com.tangem.domain.staking.model.StakingIntegration -import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState @@ -214,12 +213,12 @@ internal class StakingInfoNotificationsFactory( if (prevState.actionType !is StakingActionCommonType.Exit) return val maxAmount = prevState.balanceState?.cryptoAmount ?: return - val exitRequirements = integration.exitArgs?.args?.get(Yield.Args.ArgType.AMOUNT) ?: return + val exitRequirements = integration.exitArgs?.amountRequirement ?: return val amountLeft = maxAmount - actionAmount val isNotEnoughLeft = !amountLeft.isZero() && amountLeft < exitRequirements.minimum.orZero() - if (exitRequirements.required && isNotEnoughLeft) { + if (exitRequirements.isRequired && isNotEnoughLeft) { add(StakingNotification.Warning.LowStakedBalance) } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt index ba5cc07eee..7abfa03318 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt @@ -1,12 +1,12 @@ package com.tangem.features.staking.impl.presentation.state.utils import com.tangem.core.ui.extensions.* +import com.tangem.domain.staking.model.common.RewardSchedule import com.tangem.domain.staking.model.common.RewardType -import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.features.staking.impl.R -import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.COSMOS_SCHEDULE -import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.SOLANA_SCHEDULE -import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.TON_SCHEDULE +import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardScheduleConstants.COSMOS_SCHEDULE +import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardScheduleConstants.SOLANA_SCHEDULE +import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardScheduleConstants.TON_SCHEDULE import com.tangem.lib.crypto.BlockchainUtils.isCosmos import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.lib.crypto.BlockchainUtils.isTon @@ -14,42 +14,42 @@ import com.tangem.lib.crypto.BlockchainUtils.isTron import com.tangem.utils.StringsSigns.MINUS import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE -private data object StakingRewardSchedule { +private data object StakingRewardScheduleConstants { val COSMOS_SCHEDULE = 5 to 12 val SOLANA_SCHEDULE = 2 to 3 val TON_SCHEDULE = 1 to 2 } internal fun getRewardScheduleText( - rewardSchedule: Yield.Metadata.RewardSchedule, + rewardSchedule: RewardSchedule, networkId: String, decapitalize: Boolean, ): TextReference? { return when (rewardSchedule) { - Yield.Metadata.RewardSchedule.WEEK -> resourceReference( + RewardSchedule.WEEK -> resourceReference( id = R.string.staking_reward_schedule_week, decapitalize = decapitalize, ) - Yield.Metadata.RewardSchedule.HOUR -> resourceReference( + RewardSchedule.HOUR -> resourceReference( id = R.string.staking_reward_schedule_hour, decapitalize = decapitalize, ) - Yield.Metadata.RewardSchedule.DAY -> resourceReference( + RewardSchedule.DAY -> resourceReference( id = R.string.staking_reward_schedule_day, decapitalize = decapitalize, ) - Yield.Metadata.RewardSchedule.MONTH -> resourceReference( + RewardSchedule.MONTH -> resourceReference( id = R.string.staking_reward_schedule_month, decapitalize = decapitalize, ) - Yield.Metadata.RewardSchedule.BLOCK, - Yield.Metadata.RewardSchedule.EPOCH, - Yield.Metadata.RewardSchedule.ERA, + RewardSchedule.BLOCK, + RewardSchedule.EPOCH, + RewardSchedule.ERA, -> getCustomRewardSchedule( networkId = networkId, decapitalize = decapitalize, ) - else -> null + RewardSchedule.UNKNOWN -> null } } From c68a64c9397016e0a2ee02f977706d514d621cd8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 18 Dec 2025 12:24:26 +0700 Subject: [PATCH 008/172] Updated on 2026-08-14 --- .../account/archived/ArchivedAccountListModel.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt index 13a3acdaf9..2b6963e2b6 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt @@ -13,7 +13,6 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.ToastMessage -import com.tangem.core.ui.utils.showErrorDialog import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.status.usecase.RecoverCryptoPortfolioUseCase import com.tangem.domain.account.usecase.GetArchivedAccountsUseCase @@ -139,7 +138,19 @@ internal class ArchivedAccountListModel @Inject constructor( val featureError = AccountFeatureError.ArchivedAccountList.FailedToRecoverAccount(cause = error) logError(error = featureError) - messageSender.showErrorDialog(universalError = featureError, onDismiss = router::pop) + val messageText = resourceReference( + id = R.string.universal_error, + formatArgs = wrappedList(featureError.errorCode), + ) + val message = DialogMessage( + title = resourceReference(R.string.common_something_went_wrong), + message = messageText, + firstAction = EventMessageAction( + title = resourceReference(R.string.common_ok), + onClick = {}, + ), + ) + messageSender.send(message) } private fun logError(error: AccountFeatureError, params: Map = emptyMap()) { From 039023e293812f68624fa1bbc100ad44961b3cb5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 18 Dec 2025 15:06:50 +0700 Subject: [PATCH 009/172] Updated on 2026-08-14 --- .../AccountCryptoPortfolioItemStateConverter.kt | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt index c24dd49e89..376d0c8d8e 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt @@ -19,7 +19,6 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.quote.PriceChange import com.tangem.utils.converter.Converter -import com.tangem.utils.extensions.isZero import java.math.BigDecimal class AccountCryptoPortfolioItemStateConverter( @@ -41,14 +40,11 @@ class AccountCryptoPortfolioItemStateConverter( private fun Account.CryptoPortfolio.mapToContentState( fiatBalance: TotalFiatBalance.Loaded, ): TokenItemState.Content { - val subtitle2State = when (fiatBalance.amount.isZero()) { - true -> null - false -> priceChangeLce?.fold( - ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading }, - ifError = { null }, - ifContent = { priceChange -> priceChange.toSubtitle2State() }, - ) - } + val subtitle2State = priceChangeLce?.fold( + ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading }, + ifError = { null }, + ifContent = { priceChange -> priceChange.toSubtitle2State() }, + ) return TokenItemState.Content( id = account.accountId.toItemId(), iconState = AccountIconItemStateConverter.convert(this), From 2046d070aa0ba91889d03bc54eca338bc1ce2cc0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Dec 2025 12:45:33 +0500 Subject: [PATCH 010/172] Updated on 2026-08-14 --- .../common/ui/amountScreen/ui/AmountBlock.kt | 7 +- .../ui/amountScreen/ui/AmountBlockV2.kt | 8 +- .../core/ui/components/ResizableText.kt | 184 ------------------ .../components/buttons/common/TangemButton.kt | 9 +- .../impl/ui/MarketsTokenDetailsContent.kt | 9 +- .../ui/OnrampSuccessComponentContent.kt | 11 +- .../tangem/feature/swap/ui/TransactionCard.kt | 11 +- .../ui/TokenReceiveAssetsContent.kt | 13 +- .../components/common/WalletActionButtons.kt | 10 +- .../wallet/ui/components/common/WalletCard.kt | 12 +- .../multicurrency/MultiCurrencyAction.kt | 23 +-- 11 files changed, 63 insertions(+), 234 deletions(-) delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/ResizableText.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt index 137328bcf0..fcd6c07b50 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -19,7 +20,6 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.account.AccountTitle import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData -import com.tangem.core.ui.components.ResizableText import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.format.bigdecimal.crypto @@ -66,11 +66,14 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis state = amountState.tokenIconState, iconSize = 40.dp, ) - ResizableText( + Text( text = firstAmount, style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, + autoSize = TextAutoSize.StepBased( + maxFontSize = TangemTheme.typography.h2.fontSize, + ), maxLines = 1, modifier = Modifier .fillMaxWidth() diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt index 3ffd401d66..6adccd58a0 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt @@ -4,6 +4,7 @@ import android.content.res.Configuration 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.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -18,7 +19,6 @@ import com.tangem.common.ui.account.AccountTitle import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData -import com.tangem.core.ui.components.ResizableText import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState @@ -128,21 +128,23 @@ private fun AmountBlockV2( .padding(top = 8.dp) .weight(1f), ) { - ResizableText( + Text( text = firstAmount, style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, maxLines = 1, + autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography.h2.fontSize), modifier = Modifier.testTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT), ) Row( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - ResizableText( + Text( text = secondAmount, style = TangemTheme.typography.body2, color = TangemTheme.colors.text.tertiary, maxLines = 1, + autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography.body2.fontSize), modifier = Modifier.testTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT), ) extraContent() diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/ResizableText.kt b/core/ui/src/main/java/com/tangem/core/ui/components/ResizableText.kt deleted file mode 100644 index 8fc62476fd..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/ResizableText.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.tangem.core.ui.components - -import androidx.annotation.FloatRange -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.sp - -private const val COEFFICIENT = 0.8f - -@Suppress("MagicNumber") -@Composable -fun ResizableText( - text: String, - fontSizeRange: FontSizeRange, - modifier: Modifier = Modifier, - color: Color = Color.Unspecified, - overflow: TextOverflow = TextOverflow.Clip, - maxLines: Int = Int.MAX_VALUE, - style: TextStyle = LocalTextStyle.current, -) { - val fontSizeValue = remember { mutableFloatStateOf(fontSizeRange.max.value) } - val readyToDraw = remember { mutableStateOf(false) } - - val textState = remember { mutableStateOf(text) } - if (textState.value != text) { - readyToDraw.value = false - fontSizeValue.floatValue = fontSizeRange.max.value - textState.value = text - } - - Text( - text = text, - modifier = modifier.drawWithContent { if (readyToDraw.value) drawContent() }, - color = color, - fontSize = fontSizeValue.floatValue.sp, - overflow = overflow, - softWrap = false, - maxLines = maxLines, - onTextLayout = { textLayoutResult -> - if (textLayoutResult.hasVisualOverflow) { - val nextFontSizeValue = fontSizeValue.floatValue - fontSizeRange.step.value - if (nextFontSizeValue <= fontSizeRange.min.value) { - fontSizeValue.floatValue = fontSizeRange.min.value - readyToDraw.value = true - } else { - fontSizeValue.floatValue = nextFontSizeValue * COEFFICIENT - } - } else { - readyToDraw.value = true - } - }, - style = style, - ) -} - -/** - * A Composable function that displays text which can be resized based on its content's overflow. - * - * This function draws text on the screen and checks if it overflows. If the text overflows, - * its font size is reduced recursively until it either fits the available space or reaches a - * specified minimum font size. - */ -@Composable -fun ResizableText( - text: String, - modifier: Modifier = Modifier, - color: Color = Color.Unspecified, - textAlign: TextAlign? = null, - overflow: TextOverflow = TextOverflow.Clip, - softWrap: Boolean = true, - maxLines: Int = Int.MAX_VALUE, - style: TextStyle = LocalTextStyle.current, - minFontSize: TextUnit = TextUnit.Unspecified, - @FloatRange(from = 0.0, to = 1.0, fromInclusive = false, toInclusive = false) - reduceFactor: Double = 0.9, -) { - var fontSize by remember { mutableStateOf(style.fontSize) } - var isReadyToDraw by remember { mutableStateOf(value = false) } - - Text( - modifier = modifier - .drawWithContent { - if (isReadyToDraw) drawContent() - } - .wrapContentHeight(), - text = text, - color = color, - fontSize = fontSize, - textAlign = textAlign, - overflow = overflow, - softWrap = softWrap, - maxLines = maxLines, - style = style, - onTextLayout = { result -> - fun reduceFontSize() { - val reducedFontSize = fontSize * reduceFactor - - if (minFontSize != TextUnit.Unspecified && reducedFontSize <= minFontSize) { - fontSize = minFontSize - isReadyToDraw = true - } else { - fontSize = reducedFontSize - } - } - - if (result.hasVisualOverflow) { - reduceFontSize() - } else { - isReadyToDraw = true - } - }, - ) -} - -@Composable -fun ResizableText( - text: String, - fontSizeValue: TextUnit, - fontSizeRange: FontSizeRange, - onFontSizeChange: (Float) -> Unit, - modifier: Modifier = Modifier, - color: Color = Color.Unspecified, - overflow: TextOverflow = TextOverflow.Clip, - maxLines: Int = Int.MAX_VALUE, - style: TextStyle = LocalTextStyle.current, -) { - val readyToDraw = remember { mutableStateOf(false) } - - val textState = remember { mutableStateOf(text) } - if (textState.value != text) { - readyToDraw.value = false - onFontSizeChange(fontSizeRange.max.value) - textState.value = text - } - - Text( - text = text, - modifier = modifier.drawWithContent { if (readyToDraw.value) drawContent() }, - color = color, - fontSize = fontSizeValue.value.sp, - overflow = overflow, - softWrap = false, - maxLines = maxLines, - onTextLayout = { result -> - if (result.hasVisualOverflow) { - val nextFontSizeValue = fontSizeValue.value - fontSizeRange.step.value - if (nextFontSizeValue <= fontSizeRange.min.value) { - onFontSizeChange(fontSizeRange.min.value) - readyToDraw.value = true - } else { - val newSizeValue = nextFontSizeValue * COEFFICIENT - onFontSizeChange(newSizeValue) - } - } else { - readyToDraw.value = true - } - }, - style = style, - ) -} - -data class FontSizeRange( - val min: TextUnit, - val max: TextUnit, - val step: TextUnit = DEFAULT_TEXT_STEP, -) { - init { - require(min < max) { "min should be less than max, $this" } - require(step.value > 0) { "step should be greater than 0, $this" } - } - - companion object { - private val DEFAULT_TEXT_STEP = 1.sp - } -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButton.kt index aa6f681bce..4750445c40 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButton.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable @@ -23,7 +24,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.tangem.core.ui.components.ResizableText import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.utils.MultipleClickPreventer @@ -75,7 +75,7 @@ fun TangemButton( ) }, text = { - ResizableText( + Text( modifier = Modifier .weight(1f, fill = false) .heightIn(MinButtonContentSize, maxContentSize) @@ -86,7 +86,10 @@ fun TangemButton( textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, - minFontSize = 12.sp, + autoSize = TextAutoSize.StepBased( + minFontSize = 12.sp, + maxFontSize = textStyle.fontSize, + ), ) }, icon = { iconResId -> diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt index fc3737ddd0..cd2f2b68b1 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -23,7 +24,10 @@ 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.* +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SpacerH32 +import com.tangem.core.ui.components.SpacerH4 +import com.tangem.core.ui.components.SpacerW4 import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons @@ -248,10 +252,11 @@ private fun TokenPriceText( color.animateTo(generalColor, tween(durationMillis = 500)) } - ResizableText( + Text( text = price, modifier = modifier, color = color.value, + autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography.head.fontSize), maxLines = 1, style = TangemTheme.typography.head, ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/ui/OnrampSuccessComponentContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/ui/OnrampSuccessComponentContent.kt index 36a1e410e8..f5d9468d52 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/ui/OnrampSuccessComponentContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/ui/OnrampSuccessComponentContent.kt @@ -4,6 +4,7 @@ import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -21,7 +22,10 @@ import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import com.tangem.common.ui.expressStatus.ExpressStatusBlock import com.tangem.common.ui.expressStatus.ExpressStatusNotificationBlock -import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.CircleShimmer +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH24 import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.containers.FooterContainer @@ -131,11 +135,14 @@ private fun AmountBlock(state: OnrampSuccessComponentUM.Content) { error = { }, contentDescription = null, ) - ResizableText( + Text( text = state.fromAmount.resolveReference(), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, + autoSize = TextAutoSize.StepBased( + maxFontSize = TangemTheme.typography.h2.fontSize, + ), maxLines = 1, modifier = Modifier .fillMaxWidth() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index f344f04903..98ad517f01 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text @@ -266,11 +267,15 @@ private fun Content( when (type) { is TransactionCardType.ReadOnly -> { if (textFieldValue != null) { - ResizableText( + Text( text = textFieldValue.text, color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.h2, - fontSizeRange = FontSizeRange(min = 16.sp, max = TangemTheme.typography.h2.fontSize), + autoSize = TextAutoSize.StepBased( + minFontSize = 16.sp, + maxFontSize = TangemTheme.typography.h2.fontSize, + ), + maxLines = 1, modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.RECEIVE_TEXT_FIELD), ) } else { @@ -608,7 +613,7 @@ private fun TransactionCardPreviewWithPriceImpact() { networkIconRes = R.drawable.img_polygon_22, onChangeTokenClick = {}, balance = "123", - textFieldValue = TextFieldValue(), + textFieldValue = TextFieldValue("1000000.0000000000000000000000000"), priceImpact = PriceImpact.Value(0.15F), ) } 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 17820dcc25..c18b1c9440 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 @@ -12,6 +12,7 @@ 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 @@ -510,9 +511,6 @@ private fun LoadingBlock(modifier: Modifier = Modifier) { @Composable private fun ActionButtonWithResizableText(config: ActionButtonConfig, modifier: Modifier = Modifier) { - val fontSizeRange = FontSizeRange(min = 10.sp, max = 14.sp) - var fontSizeValue by remember { mutableFloatStateOf(fontSizeRange.max.value) } - ActionBaseButton( config = config, shape = RoundedCornerShape(size = TangemTheme.dimens.radius24), @@ -520,11 +518,12 @@ private fun ActionButtonWithResizableText(config: ActionButtonConfig, modifier: ActionButtonContent( config = config, text = { color -> - ResizableText( + Text( text = config.text.resolveReference(), - fontSizeValue = fontSizeValue.sp, - fontSizeRange = fontSizeRange, - onFontSizeChange = { fontSizeValue = it }, + autoSize = TextAutoSize.StepBased( + minFontSize = 10.sp, + maxFontSize = TangemTheme.typography.button.fontSize, + ), color = color, overflow = TextOverflow.Ellipsis, maxLines = 1, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletActionButtons.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletActionButtons.kt index caba2bb717..7dbc09ce0b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletActionButtons.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletActionButtons.kt @@ -2,12 +2,10 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.common import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.runtime.* +import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.tangem.core.ui.components.FontSizeRange import com.tangem.core.ui.components.buttons.HorizontalActionChips import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.wallet.presentation.wallet.state.model.WalletManageButton @@ -66,16 +64,10 @@ internal fun LazyListScope.actions( horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8), verticalAlignment = Alignment.CenterVertically, ) { - val fontSizeRange = FontSizeRange(min = 10.sp, max = 14.sp) - var fontSizeValue by remember { mutableFloatStateOf(fontSizeRange.max.value) } - actions.fastForEach { action -> key(action::class.java) { MultiCurrencyAction( config = action.config, - fontSizeValue = fontSizeValue.sp, - fontSizeRange = fontSizeRange, - onFontSizeChange = { fontSizeValue = it }, modifier = Modifier.weight(1f), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt index a3cfa9ee44..dcfaea4d35 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -40,9 +41,7 @@ import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayoutScope import androidx.constraintlayout.compose.Dimension import androidx.constraintlayout.compose.Visibility -import com.tangem.core.ui.components.FontSizeRange import com.tangem.core.ui.components.RectangleShimmer -import com.tangem.core.ui.components.ResizableText import com.tangem.core.ui.components.text.applyBladeBrush import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.orMaskWithStars @@ -279,10 +278,13 @@ private fun Balance(state: WalletCardState, isBalanceHidden: Boolean, modifier: ) { balance -> when (state) { is WalletCardState.Content -> { - ResizableText( - modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32), + Text( text = balance, - fontSizeRange = FontSizeRange(min = 16.sp, max = TangemTheme.typography.h2.fontSize), + modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32), + autoSize = TextAutoSize.StepBased( + minFontSize = 16.sp, + maxFontSize = TangemTheme.typography.h2.fontSize, + ), overflow = TextOverflow.Ellipsis, maxLines = 1, style = TangemTheme.typography.h2 diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAction.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAction.kt index e82c67b4fe..9039594895 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAction.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAction.kt @@ -2,13 +2,13 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrenc import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.FontSizeRange -import com.tangem.core.ui.components.ResizableText +import androidx.compose.ui.unit.sp 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 @@ -19,13 +19,7 @@ import com.tangem.core.ui.res.TangemTheme [REDACTED_AUTHOR] */ @Composable -internal fun MultiCurrencyAction( - config: ActionButtonConfig, - fontSizeValue: TextUnit, - fontSizeRange: FontSizeRange, - onFontSizeChange: (Float) -> Unit, - modifier: Modifier = Modifier, -) { +internal fun MultiCurrencyAction(config: ActionButtonConfig, modifier: Modifier = Modifier) { ActionBaseButton( config = config, shape = RoundedCornerShape(size = TangemTheme.dimens.radius12), @@ -33,11 +27,12 @@ internal fun MultiCurrencyAction( ActionButtonContent( config = config, text = { color -> - ResizableText( + Text( text = config.text.resolveReference(), - fontSizeValue = fontSizeValue, - fontSizeRange = fontSizeRange, - onFontSizeChange = onFontSizeChange, + autoSize = TextAutoSize.StepBased( + minFontSize = 10.sp, + maxFontSize = TangemTheme.typography.button.fontSize, + ), color = color, overflow = TextOverflow.Ellipsis, maxLines = 1, From 27fd5458d13d63752febb59a3e1c0d99c58b3202 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Dec 2025 13:03:44 +0200 Subject: [PATCH 011/172] Updated on 2026-08-14 --- .../data/staking/DefaultP2PEthPoolRepository.kt | 14 ++++++++++---- .../tangem/data/staking/di/StakingDataModule.kt | 2 ++ .../staking/usecase/StakingApyFlowUseCase.kt | 13 ++++++++++--- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt index 54d82d69e9..2a04a06ae3 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -17,6 +17,7 @@ import com.tangem.domain.staking.model.StakingOption import com.tangem.domain.staking.model.ethpool.* import com.tangem.domain.staking.repositories.P2PEthPoolRepository import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged @@ -31,6 +32,7 @@ internal class DefaultP2PEthPoolRepository( private val p2pEthPoolApi: P2PEthPoolApi, private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore, private val dispatchers: CoroutineDispatcherProvider, + private val stakingFeatureToggles: StakingFeatureToggles, ) : P2PEthPoolRepository { private val vaultConverter = P2PEthPoolVaultConverter @@ -40,17 +42,21 @@ internal class DefaultP2PEthPoolRepository( private val errorConverter = P2PEthPoolErrorConverter override suspend fun fetchVaults(network: P2PEthPoolNetwork) { - val vaults = getVaults(network).getOrElse { error -> - Timber.e("Error fetching P2PEthPool vaults: $error") + val vaults = if (stakingFeatureToggles.isEthStakingEnabled) { + getVaults(network).getOrElse { error -> + Timber.e("Error fetching P2PEthPool vaults: $error") + emptyList() + } + } else { emptyList() } + p2pEthPoolVaultsStore.store(vaults.filter { !it.isPrivate }) // TODO eth isSmoothingPool? } override suspend fun getVaults(network: P2PEthPoolNetwork): Either> = either { withContext(dispatchers.io) { - val response = p2pEthPoolApi.getVaults(network.value) - when (response) { + when (val response = p2pEthPoolApi.getVaults(network.value)) { is ApiResponse.Success -> { val data = response.data ensure(data.error == null) { diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt index fd25f57d71..2f4f5b754a 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -78,11 +78,13 @@ internal object StakingDataModule { p2pEthPoolApi: P2PEthPoolApi, p2pEthPoolVaultsStore: P2PEthPoolVaultsStore, dispatchers: CoroutineDispatcherProvider, + stakingFeatureToggles: StakingFeatureToggles, ): P2PEthPoolRepository { return DefaultP2PEthPoolRepository( p2pEthPoolApi = p2pEthPoolApi, p2pEthPoolVaultsStore = p2pEthPoolVaultsStore, dispatchers = dispatchers, + stakingFeatureToggles = stakingFeatureToggles, ) } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/usecase/StakingApyFlowUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/usecase/StakingApyFlowUseCase.kt index 357ec88fa5..6754dab6c3 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/usecase/StakingApyFlowUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/usecase/StakingApyFlowUseCase.kt @@ -29,15 +29,22 @@ class StakingApyFlowUseCase( p2pEthPoolRepository.getVaultsFlow(), ) { yields, p2pVaults -> val stakeKitMap = yields.filterNot { yield -> - val isCardanoYield = yield.token.coinGeckoId == Blockchain.Cardano.toCoinId() - isCardanoYield && !stakingFeatureToggles.isCardanoStakingEnabled + val coinGeckoId = yield.token.coinGeckoId + val isCardanoYield = coinGeckoId == Blockchain.Cardano.toCoinId() + val isEthYield = coinGeckoId == Blockchain.Ethereum.toCoinId() + + when { + isCardanoYield && !stakingFeatureToggles.isCardanoStakingEnabled -> true + isEthYield && !stakingFeatureToggles.isEthStakingEnabled -> true + else -> false + } }.associate { yield -> val key = "${yield.token.coinGeckoId}_${yield.token.symbol}" val targets = yield.validators.map { it.toStakingTarget() } key to targets } - val p2pMap = if (p2pVaults.isNotEmpty()) { + val p2pMap = if (p2pVaults.isNotEmpty() && stakingFeatureToggles.isEthStakingEnabled) { val ethKey = "${Blockchain.Ethereum.toCoinId()}_${Blockchain.Ethereum.currency}" val targets = p2pVaults.map { it.toStakingTarget() } mapOf(ethKey to targets) From 4d099be1c71aa1dffe51a7dc1ef0bf5e147c4d42 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Dec 2025 12:25:24 +0000 Subject: [PATCH 012/172] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 462e99b59c..36965eb1e1 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.32-1329" +tangemBlockchainSdk = "develop-1330" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.32-574" +tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 6b9aacb1ea6a5f590da7c6c1a24f0f55969f5617 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Dec 2025 14:08:10 +0100 Subject: [PATCH 013/172] Updated on 2026-08-14 --- .../com/tangem/common/ui/news/ArticleCard.kt | 38 ++++++++ .../core/ui/components/fields/SearchBar.kt | 20 ++-- .../components/fields/entity/SearchBarUM.kt | 1 + .../res/drawable/ic_show_more_news_48.xml | 18 ++++ .../components/DefaultFeedEntryComponent.kt | 6 +- .../components/feed/DefaultFeedComponent.kt | 2 +- .../list/DefaultMarketsTokenListComponent.kt | 6 +- .../feed/model/feed/FeedComponentModel.kt | 19 +--- .../feed/model/feed/FeedModelClickIntents.kt | 2 +- .../model/market/list/MarketsListModel.kt | 1 + .../statemanager/MarketsListUMStateManager.kt | 47 +++++++--- .../tangem/features/feed/ui/feed/FeedList.kt | 54 ++++++++--- .../preview/FeedListPreviewDataProvider.kt | 8 +- .../features/feed/ui/feed/state/FeedListUM.kt | 9 +- .../ui/feed/state/SearchBarStateFactory.kt | 24 ----- .../feed/ui/market/list/MarketsList.kt | 94 ++++++++++++------- .../ui/market/list/state/MarketsListUM.kt | 16 +++- .../list/state/SortByBottomSheetContentUM.kt | 2 +- 18 files changed, 235 insertions(+), 132 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_show_more_news_48.xml delete mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/SearchBarStateFactory.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt index 91e26bb895..3b3f257ab5 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt @@ -1,6 +1,7 @@ package com.tangem.common.ui.news import android.content.res.Configuration +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -12,6 +13,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -100,6 +103,41 @@ private fun TrendingArticle(articleConfigUM: ArticleConfigUM) { } } +@Composable +fun ShowMoreArticlesCard(modifier: Modifier = Modifier, onClick: () -> Unit) { + BlockCard( + modifier = modifier, + onClick = onClick, + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(vertical = 31.dp, horizontal = 12.dp), + ) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_show_more_news_48), + contentDescription = stringResourceSafe(R.string.common_show_more), + ) + + SpacerH(16.dp) + + Text( + text = stringResourceSafe(R.string.news_all_news), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + + Text( + text = stringResourceSafe(R.string.news_stay_in_the_loop), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } +} + @Composable private fun DefaultArticle(articleConfigUM: ArticleConfigUM) { Column(modifier = Modifier.padding(12.dp)) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt index c0390dfda0..17ddadc6ff 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt @@ -10,9 +10,8 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.remember +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusRequester @@ -51,16 +50,22 @@ fun SearchBar( val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current val interactionSource = remember { MutableInteractionSource() } + var isInitialComposition by rememberSaveable { mutableStateOf(true) } + LaunchedEffect(Unit) { + isInitialComposition = false + } BasicTextField( modifier = modifier .fillMaxWidth() .heightIn(min = TangemTheme.dimens.size48) .onFocusChanged { focusState -> - if (focusState.isFocused) { - state.onActiveChange(true) - } else { - state.onActiveChange(false) + if (!isInitialComposition) { + if (focusState.isFocused) { + state.onActiveChange(true) + } else { + state.onActiveChange(false) + } } } .focusRequester(focusRequester) @@ -163,6 +168,7 @@ private fun ClearButton( focusManager.clearFocus() keyboardController?.hide() state.onActiveChange(false) + state.onClearClick() }, ) { Icon( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt index d2ac8fcc8b..158c768fe8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt @@ -8,4 +8,5 @@ data class SearchBarUM( val onQueryChange: (String) -> Unit, val isActive: Boolean, val onActiveChange: (Boolean) -> Unit, + val onClearClick: () -> Unit = {}, ) \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_show_more_news_48.xml b/core/ui/src/main/res/drawable/ic_show_more_news_48.xml new file mode 100644 index 0000000000..fe0417c568 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_show_more_news_48.xml @@ -0,0 +1,18 @@ + + + + + + + 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 0519eeeaf2..c3f6755841 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 @@ -60,14 +60,14 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( ) } - override fun onMarketOpenClick(sortBy: SortByTypeUM) { + override fun onMarketOpenClick(sortBy: SortByTypeUM?) { innerRouter.push( route = FeedEntryChildFactory.Child.TokenList( params = DefaultMarketsTokenListComponent.Params( onBackClicked = { onChildBack() }, onTokenClick = { token, currency -> onMarketItemClick(token, currency) }, - preselectedSortType = sortBy, - shouldAlwaysShowSearchBar = sortBy == SortByTypeUM.Rating, + preselectedSortType = sortBy ?: SortByTypeUM.Rating, + shouldAlwaysShowSearchBar = sortBy == null, ), ), ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt index 0b6ca24074..9257a465b1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt @@ -23,7 +23,7 @@ internal class DefaultFeedComponent( @Composable override fun Title() { val state by feedComponentModel.state.collectAsStateWithLifecycle() - FeedListHeader(state.searchBar) + FeedListHeader(state.feedListSearchBar) } @Composable 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 a2f2670051..75c9ca0587 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 @@ -26,7 +26,11 @@ internal class DefaultMarketsTokenListComponent( @Composable override fun Title() { val state by model.state.collectAsStateWithLifecycle() - TopBarWithSearch(state.searchBar) + TopBarWithSearch( + onBackClick = params.onBackClicked, + onSearchClick = state.onSearchClicked, + marketsSearchBar = state.marketsSearchBar, + ) } @Composable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 9c7a9c96b1..e61e95a217 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -5,7 +5,6 @@ import arrow.core.getOrElse 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.components.fields.entity.SearchBarUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -31,7 +30,6 @@ import kotlinx.coroutines.launch import org.joda.time.DateTime import org.joda.time.DateTimeZone import javax.inject.Inject -import kotlin.collections.all @Stable @ModelScoped @@ -66,13 +64,6 @@ internal class FeedComponentModel @Inject constructor( internal val state: StateFlow field = MutableStateFlow(initialState()) - private val searchBarStateFactory by lazy(LazyThreadSafetyMode.NONE) { - SearchBarStateFactory( - currentStateProvider = Provider { state.value }, - onStateUpdate = { newState -> state.update { newState } }, - ) - } - private val trendingNewsStateFactory by lazy(LazyThreadSafetyMode.NONE) { TrendingNewsStateFactory( currentStateProvider = Provider { state.value }, @@ -137,14 +128,9 @@ internal class FeedComponentModel @Inject constructor( private fun initialState(): FeedListUM { return FeedListUM( currentDate = getCurrentDate(), - searchBar = SearchBarUM( + feedListSearchBar = FeedListSearchBar( placeholderText = resourceReference(R.string.markets_search_header_title), - query = "", - onQueryChange = {}, - isActive = false, - onActiveChange = { - if (it) params.feedClickIntents.onMarketOpenClick(SortByTypeUM.Rating) - }, + onBarClick = { params.feedClickIntents.onMarketOpenClick(null) }, ), feedListCallbacks = FeedListCallbacks( onSearchClick = {}, @@ -307,7 +293,6 @@ internal class FeedComponentModel @Inject constructor( private fun updateCallbacks() { state.update { feedListUM -> feedListUM.copy( - searchBar = state.value.searchBar.copy(onQueryChange = searchBarStateFactory::onSearchQueryChange), feedListCallbacks = feedListUM.feedListCallbacks.copy( onSortTypeClick = ::onSortTypeClick, onMarketItemClick = { item -> diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt index 4b4c342fe0..97621a1ef9 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt @@ -9,7 +9,7 @@ import com.tangem.features.feed.ui.market.list.state.SortByTypeUM */ internal interface FeedModelClickIntents { fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency) - fun onMarketOpenClick(sortBy: SortByTypeUM) + fun onMarketOpenClick(sortBy: SortByTypeUM?) fun onArticleClick(articleId: Int) fun onOpenAllNews() } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt index ca976fa47b..f7ae1d61d4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt @@ -73,6 +73,7 @@ internal class MarketsListModel @Inject constructor( onShowTokensUnder100kClicked = { analyticsEventHandler.send(MarketsListAnalyticsEvent.ShowTokens()) }, shouldAlwaysShowSearchBar = Provider { params.shouldAlwaysShowSearchBar }, preselectedSortType = Provider { params.preselectedSortType }, + onBackClick = params.onBackClicked, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt index 5ecb6df31a..2e269facb3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt @@ -29,6 +29,7 @@ internal class MarketsListUMStateManager( private val onRetryButtonClicked: () -> Unit, private val onTokenClick: (MarketsListItemUM) -> Unit, private val onShowTokensUnder100kClicked: () -> Unit, + private val onBackClick: () -> Unit, ) { val state = MutableStateFlow(state()) @@ -38,19 +39,17 @@ internal class MarketsListUMStateManager( set(value) = state.update { it.copy(sortByBottomSheet = it.sortByBottomSheet.copy(isShown = value)) } var searchQuery - get() = state.value.searchBar.query + get() = state.value.marketsSearchBar.searchBarUM.query private set(value) = state.update { marketsListUM -> marketsListUM.copy( - searchBar = marketsListUM.searchBar.copy( - query = value, - isActive = value.isNotEmpty(), + marketsSearchBar = marketsListUM.marketsSearchBar.copy( + searchBarUM = marketsListUM.marketsSearchBar.searchBarUM.copy(query = value), ), ) } - var isInSearchState - get() = state.value.searchBar.isActive - private set(value) = state.update { it.copy(searchBar = it.searchBar.copy(isActive = value)) } + val isInSearchState + get() = state.value.marketsSearchBar.searchBarUM.isActive var selectedSortByType get() = state.value.selectedSortBy @@ -91,7 +90,7 @@ internal class MarketsListUMStateManager( } val isInSearchStateFlow = state.map { it.isInSearchMode }.distinctUntilChanged() - val searchQueryFlow = state.map { it.searchBar.query }.distinctUntilChanged() + val searchQueryFlow = state.map { it.marketsSearchBar.searchBarUM.query }.distinctUntilChanged() fun onUiItemsChanged( isInErrorState: Boolean, @@ -214,12 +213,20 @@ internal class MarketsListUMStateManager( private fun state(): MarketsListUM = MarketsListUM( list = ListUM.Loading, - searchBar = SearchBarUM( - placeholderText = resourceReference(R.string.markets_search_header_title), - query = "", - onQueryChange = { searchQuery = it }, - isActive = false, - onActiveChange = { }, + marketsSearchBar = MarketsSearchBar( + searchBarUM = SearchBarUM( + placeholderText = resourceReference(R.string.markets_search_header_title), + query = "", + onQueryChange = { searchQuery = it }, + isActive = false, + onActiveChange = ::changeSearchBarIsActive, + onClearClick = { + if (shouldAlwaysShowSearchBar()) { + onBackClick() + } + }, + ), + shouldAlwaysShowSearchBar = shouldAlwaysShowSearchBar(), ), selectedSortBy = preselectedSortType(), selectedInterval = MarketsListUM.TrendInterval.H24, @@ -234,7 +241,7 @@ internal class MarketsListUMStateManager( ), ), marketsNotificationUM = null, - shouldAlwaysShowSearchBar = shouldAlwaysShowSearchBar(), + onSearchClicked = { changeSearchBarIsActive(true) }, ) private fun onBottomSheetOptionClicked(sortByTypeUM: SortByTypeUM) { @@ -264,4 +271,14 @@ internal class MarketsListUMStateManager( ) } } + + private fun changeSearchBarIsActive(isActive: Boolean) { + state.update { marketsListUM -> + marketsListUM.copy( + marketsSearchBar = marketsListUM.marketsSearchBar.copy( + searchBarUM = marketsListUM.marketsSearchBar.searchBarUM.copy(isActive = isActive), + ), + ) + } + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index 49bd19e7af..4cc64dcbba 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable @@ -33,6 +34,7 @@ import com.tangem.common.ui.markets.MarketsListItemPlaceholder import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.common.ui.news.ArticleCard import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.common.ui.news.ShowMoreArticlesCard import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerW @@ -41,9 +43,6 @@ import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig -import com.tangem.core.ui.components.fields.SearchBar -import com.tangem.core.ui.components.fields.TangemSearchBarDefaults -import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe @@ -55,25 +54,20 @@ import com.tangem.features.feed.ui.feed.state.* import com.tangem.features.feed.ui.market.list.state.SortByTypeUM @Composable -internal fun FeedListHeader(searchBarUM: SearchBarUM, modifier: Modifier = Modifier) { +internal fun FeedListHeader(feedListSearchBar: FeedListSearchBar, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value - SearchBar( + FeedSearchBar( + feedListSearchBar = feedListSearchBar, modifier = modifier .drawBehind { drawRect(background) } .padding(horizontal = 16.dp) .padding(bottom = 12.dp), - state = searchBarUM, - colors = TangemSearchBarDefaults.defaultTextFieldColors.copy( - focusedContainerColor = TangemTheme.colors.field.focused, - unfocusedContainerColor = TangemTheme.colors.field.focused, - ), ) } @Composable internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value - AnimatedContent( modifier = modifier, targetState = state.globalState, @@ -103,6 +97,33 @@ internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) { } } +@Composable +private fun FeedSearchBar(feedListSearchBar: FeedListSearchBar, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(36.dp)) + .background(color = TangemTheme.colors.field.focused) + .clickable(onClick = feedListSearchBar.onBarClick) + .padding(14.dp), + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens.size20), + imageVector = ImageVector.vectorResource(id = R.drawable.ic_search_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + + SpacerW(14.dp) + + Text( + text = feedListSearchBar.placeholderText.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } +} + @Composable private fun FeeListContent(state: FeedListUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value @@ -308,7 +329,7 @@ private fun NewsContentBlock( append(stringResourceSafe(R.string.feed_tangem_ai)) } }, - style = TangemTheme.typography.h3, + style = TangemTheme.typography.subtitle1, ) } }, @@ -345,10 +366,17 @@ private fun NewsContentBlock( onArticleClick = { feedListCallbacks.onArticleClick(article.id) }, modifier = Modifier .height(164.dp) - .widthIn(max = 216.dp), + .width(216.dp), colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), ) } + + item { + ShowMoreArticlesCard( + modifier = Modifier.size(width = 216.dp, height = 164.dp), + onClick = feedListCallbacks.onOpenAllNews, + ) + } } SpacerH(32.dp) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt index d0d833fcd1..50b22f6f46 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt @@ -3,7 +3,6 @@ package com.tangem.features.feed.ui.feed.preview import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.common.ui.news.ArticleConfigUM -import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.components.marketprice.PriceChangeType @@ -22,12 +21,9 @@ internal object FeedListPreviewDataProvider { val marketItems = createSampleMarketItems() return FeedListUM( currentDate = "20 November", - searchBar = SearchBarUM( + feedListSearchBar = FeedListSearchBar( placeholderText = TextReference.Str("Search tokens & news"), - query = "", - onQueryChange = {}, - isActive = false, - onActiveChange = {}, + onBarClick = {}, ), feedListCallbacks = FeedListCallbacks( onSearchClick = {}, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt index 269fcb8d30..3c05e90f30 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt @@ -3,7 +3,7 @@ package com.tangem.features.feed.ui.feed.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.common.ui.news.ArticleConfigUM -import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.extensions.TextReference import com.tangem.features.feed.ui.market.list.state.SortByTypeUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -11,7 +11,7 @@ import kotlinx.collections.immutable.toPersistentList internal data class FeedListUM( val currentDate: String, - val searchBar: SearchBarUM, + val feedListSearchBar: FeedListSearchBar, val feedListCallbacks: FeedListCallbacks, val news: NewsUM, val trendingArticle: ArticleConfigUM?, @@ -28,6 +28,11 @@ internal data class FeedListCallbacks( val onSortTypeClick: (SortByTypeUM) -> Unit, ) +internal data class FeedListSearchBar( + val onBarClick: () -> Unit, + val placeholderText: TextReference, +) + @Immutable internal sealed interface NewsUM { data object Loading : NewsUM diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/SearchBarStateFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/SearchBarStateFactory.kt deleted file mode 100644 index bd33df97cc..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/SearchBarStateFactory.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tangem.features.feed.ui.feed.state - -import com.tangem.utils.Provider - -internal class SearchBarStateFactory( - private val currentStateProvider: Provider, - private val onStateUpdate: (FeedListUM) -> Unit, -) { - - val searchQuery: String - get() = currentStateProvider().searchBar.query - - fun onSearchQueryChange(query: String) { - val currentState = currentStateProvider() - onStateUpdate( - currentState.copy( - searchBar = currentState.searchBar.copy( - query = query, - isActive = query.isNotEmpty(), - ), - ), - ) - } -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt index 1c86bc2b22..b4024d9bce 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt @@ -2,6 +2,7 @@ package com.tangem.features.feed.ui.market.list import android.content.res.Configuration import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState @@ -10,6 +11,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -19,6 +21,7 @@ import com.tangem.common.ui.markets.preview.MarketChartListItemPreviewDataProvid import com.tangem.core.ui.components.Keyboard import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig @@ -41,23 +44,47 @@ import com.tangem.features.feed.ui.market.list.components.YieldSupplyInMarketsPr import com.tangem.features.feed.ui.market.list.state.* import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay private const val SHOW_MORE_KEY = "privacyPolicy" @Composable -internal fun TopBarWithSearch(searchBarUM: SearchBarUM) { +internal fun TopBarWithSearch(onBackClick: () -> Unit, onSearchClick: () -> Unit, marketsSearchBar: MarketsSearchBar) { val background = LocalMainBottomSheetColor.current.value - SearchBar( - modifier = Modifier - .drawBehind { drawRect(background) } - .padding(horizontal = 16.dp) - .padding(bottom = 12.dp), - state = searchBarUM, - colors = TangemSearchBarDefaults.defaultTextFieldColors.copy( - focusedContainerColor = TangemTheme.colors.field.focused, - unfocusedContainerColor = TangemTheme.colors.field.focused, - ), - ) + val focusRequester: FocusRequester = remember { FocusRequester() } + + AnimatedContent( + targetState = !marketsSearchBar.shouldAlwaysShowSearchBar && !marketsSearchBar.searchBarUM.isActive, + ) { showAppBarWithBackIcon -> + if (showAppBarWithBackIcon) { + AppBarWithBackButtonAndIcon( + onBackClick = onBackClick, + text = stringResourceSafe(R.string.markets_common_title), + iconRes = R.drawable.ic_search_24, + onIconClick = onSearchClick, + backgroundColor = background, + ) + } else { + SearchBar( + modifier = Modifier + .drawBehind { drawRect(background) } + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp), + state = marketsSearchBar.searchBarUM, + colors = TangemSearchBarDefaults.defaultTextFieldColors.copy( + focusedContainerColor = TangemTheme.colors.field.focused, + unfocusedContainerColor = TangemTheme.colors.field.focused, + ), + focusRequester = focusRequester, + ) + LaunchedEffect(marketsSearchBar.shouldAlwaysShowSearchBar) { + if (marketsSearchBar.shouldAlwaysShowSearchBar) { + delay(timeMillis = 200) + focusRequester.requestFocus() + } + } + } + } } @Composable @@ -83,16 +110,22 @@ private fun ColumnScope.Content(state: MarketsListUM, modifier: Modifier = Modif Column(modifier.padding(horizontal = TangemTheme.dimens.size16)) { AnimatedVisibility( - visible = scrolledState.value.not(), + visible = scrolledState.value.not() && + state.isInSearchMode && + state.marketsSearchBar.searchBarUM.query.isNotEmpty(), ) { Column { SpacerH8() - Title(isInSearchMode = state.isInSearchMode) + Text( + text = stringResourceSafe(id = R.string.markets_search_result_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) SpacerH12() } } Column { - AnimatedVisibility(state.isInSearchMode.not()) { + AnimatedVisibility(!state.isInSearchMode && !state.marketsSearchBar.shouldAlwaysShowSearchBar) { Options( modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), sortByTypeUM = state.selectedSortBy, @@ -161,20 +194,6 @@ private fun ColumnScope.Content(state: MarketsListUM, modifier: Modifier = Modif ) } -@Composable -private fun Title(isInSearchMode: Boolean, modifier: Modifier = Modifier) { - Text( - modifier = modifier, - text = if (isInSearchMode) { - stringResourceSafe(id = R.string.markets_search_result_title) - } else { - stringResourceSafe(id = R.string.markets_common_title) - }, - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, - ) -} - @Composable private fun Options( sortByTypeUM: SortByTypeUM, @@ -320,12 +339,15 @@ private fun Preview() { triggerScrollReset = consumedEvent(), onItemClick = {}, ), - searchBar = SearchBarUM( - placeholderText = resourceReference(R.string.markets_search_header_title), - query = "", - onQueryChange = {}, - isActive = false, - onActiveChange = { }, + marketsSearchBar = MarketsSearchBar( + searchBarUM = SearchBarUM( + placeholderText = resourceReference(R.string.markets_search_header_title), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = { }, + ), + shouldAlwaysShowSearchBar = true, ), selectedSortBy = SortByTypeUM.Rating, selectedInterval = MarketsListUM.TrendInterval.H24, @@ -340,7 +362,7 @@ private fun Preview() { onClick = {}, onCloseClick = {}, ), - shouldAlwaysShowSearchBar = true, + onSearchClicked = {}, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsListUM.kt index 17fc25d214..9ae4f28516 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsListUM.kt @@ -13,17 +13,18 @@ import kotlinx.collections.immutable.ImmutableList internal data class MarketsListUM( val list: ListUM, - val searchBar: SearchBarUM, + val marketsSearchBar: MarketsSearchBar, val selectedSortBy: SortByTypeUM, val sortByBottomSheet: TangemBottomSheetConfig, val selectedInterval: TrendInterval, - val shouldAlwaysShowSearchBar: Boolean, val onIntervalClick: (TrendInterval) -> Unit, val onSortByButtonClick: () -> Unit, val marketsNotificationUM: MarketsNotificationUM?, + val onSearchClicked: () -> Unit, ) { val isInSearchMode - get() = searchBar.isActive + get() = marketsSearchBar.searchBarUM.isActive && + marketsSearchBar.searchBarUM.query.isNotEmpty() enum class TrendInterval(val text: TextReference) { H24(resourceReference(R.string.markets_selector_interval_24h_title)), @@ -32,7 +33,12 @@ internal data class MarketsListUM( } } -enum class SortByTypeUM(val text: TextReference) { +internal data class MarketsSearchBar( + val searchBarUM: SearchBarUM, + val shouldAlwaysShowSearchBar: Boolean, +) + +internal enum class SortByTypeUM(val text: TextReference) { Rating(resourceReference(R.string.markets_sort_by_rating_title)), Trending(resourceReference(R.string.markets_sort_by_trending_title)), ExperiencedBuyers(resourceReference(R.string.markets_sort_by_experienced_buyers_title)), @@ -43,7 +49,7 @@ enum class SortByTypeUM(val text: TextReference) { } @Immutable -sealed class ListUM { +internal sealed class ListUM { data class Content( val items: ImmutableList, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/SortByBottomSheetContentUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/SortByBottomSheetContentUM.kt index 75e79d5844..947ee23844 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/SortByBottomSheetContentUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/SortByBottomSheetContentUM.kt @@ -2,7 +2,7 @@ package com.tangem.features.feed.ui.market.list.state import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -data class SortByBottomSheetContentUM( +internal data class SortByBottomSheetContentUM( val selectedOption: SortByTypeUM, val onOptionClicked: (SortByTypeUM) -> Unit, ) : TangemBottomSheetConfigContent \ No newline at end of file From cef06187911eda046cd0d029fe806df379ab8126 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Dec 2025 22:42:17 +0300 Subject: [PATCH 014/172] Updated on 2026-08-14 --- .../kotlin/com/tangem/screens/SendAddressPageObject.kt | 5 +---- .../actionButtons/TokenDetailsScreenActionButtonsTest.kt | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt index aa4b7b3647..d51d071203 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt @@ -95,10 +95,8 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider hasAnyDescendant(withText(description, substring = true)) } if (isMyWallet) { - hasAnySibling(withText(getResourceString(CoreUiR.string.send_recipient_wallets_title))) hasAnyDescendant(withText(getResourceString(CoreUiR.string.manage_tokens_network_selector_wallet))) } else { - hasAnySibling(withText(getResourceString(CoreUiR.string.send_recent_transactions))) hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TRANSACTION_ICON)) } } @@ -119,8 +117,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider } val destinationTagTextFieldHint: KNode = child { - hasParent(withTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD)) - useUnmergedTree = true + hasTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD) } val destinationTagBlockText: KNode = child { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt index a531a3cc1a..13a36c3299 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt @@ -84,8 +84,8 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() { step("Assert 'Buy' button is not dimmed") { onTokenDetailsScreen { buyButton().assertContentDescriptionEquals(actionButtonIsNotDimmed) } } - step("Assert 'Send' button is dimmed") { - onTokenDetailsScreen { sendButton().assertContentDescriptionEquals(actionButtonIsDimmed) } + step("Assert 'Send' button is not dimmed") { + onTokenDetailsScreen { sendButton().assertContentDescriptionEquals(actionButtonIsNotDimmed) } } step("Assert 'Swap' button is dimmed") { onTokenDetailsScreen { swapButton().assertContentDescriptionEquals(actionButtonIsDimmed) } From c6fc619530bcb4e911291cedba27de03e5d3f5b6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Dec 2025 09:03:01 +0100 Subject: [PATCH 015/172] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 58 +- .../com/tangem/common/ui/news/ArticleCard.kt | 2 + .../appbar/AppBarWithBackButtonAndIcon.kt | 4 + ...sableModularBottomSheetContentComponent.kt | 33 + .../entry/components/FeedEntryComponent.kt | 8 +- .../feed/entry/components/FeedEntryRoute.kt | 27 + features/feed/impl/build.gradle.kts | 2 + .../components/DefaultFeedEntryComponent.kt | 115 ++- .../feed/components/FeedEntryChildFactory.kt | 10 +- .../components/feed/DefaultFeedComponent.kt | 13 +- .../DefaultMarketsTokenDetailsComponent.kt | 82 ++- .../list/DefaultMarketsTokenListComponent.kt | 24 +- .../details/DefaultNewsDetailsComponent.kt | 16 +- .../news/list/DefaultNewsListComponent.kt | 14 +- .../tangem/features/feed/di/ModelModule.kt | 6 + .../converter/MarketsTokenItemConverter.kt | 2 +- .../feed/model/feed/FeedComponentModel.kt | 2 +- .../feed/model/feed/FeedModelClickIntents.kt | 2 +- .../details/MarketsTokenDetailsModel.kt | 653 ++++++++++++++++++ .../analytics/MarketDetailsAnalyticsEvent.kt | 84 +++ .../details/converter/DescriptionConverter.kt | 49 ++ .../converter/ExchangeItemStateConverter.kt | 68 ++ .../details/converter/InsightsConverter.kt | 168 +++++ .../details/converter/LinksConverter.kt | 48 ++ .../details/converter/MetricsConverter.kt | 152 ++++ .../converter/PricePerformanceConverter.kt | 71 ++ .../converter/SecurityScoreConverter.kt | 56 ++ .../converter/TokenMarketInfoConverter.kt | 81 +++ .../market/details/formatter/Formatters.kt | 76 ++ .../formatter/MarketsDateTimeFormatters.kt | 140 ++++ .../details/state/QuotesStateUpdater.kt | 96 +++ .../details/state/TokenNetworksState.kt | 12 + .../model/market/list/MarketsListModel.kt | 19 +- .../analytics/MarketsListAnalyticsEvent.kt | 6 +- .../market/list/state/MarketsListUM.kt | 2 +- .../list/state/MarketsNotificationUM.kt | 2 +- .../list/state/SortByBottomSheetContentUM.kt | 2 +- .../MarketsListBatchFlowManager.kt | 4 +- .../statemanager/MarketsListUMStateManager.kt | 7 +- .../feed/ui/EntryBottomSheetContent.kt | 56 -- .../tangem/features/feed/ui/EntryContent.kt | 75 ++ .../tangem/features/feed/ui/feed/FeedList.kt | 2 +- .../preview/FeedListPreviewDataProvider.kt | 2 +- .../features/feed/ui/feed/state/FeedListUM.kt | 2 +- .../feed/state/FeedMarketsBatchFlowManager.kt | 4 +- .../detailed/MarketsTokenDetailsContent.kt | 330 +++++++++ .../components/ExchangesBottomSheet.kt | 204 ++++++ .../detailed/components/InfoBottomSheet.kt | 79 +++ .../market/detailed/components/InfoPoint.kt | 193 ++++++ .../detailed/components/InsightsBlock.kt | 215 ++++++ .../market/detailed/components/LinksBlock.kt | 226 ++++++ .../detailed/components/ListedOnBlock.kt | 143 ++++ .../components/MarketTokenDetailsChart.kt | 84 +++ .../detailed/components/MetricsBlock.kt | 168 +++++ .../components/PricePerformanceBlock.kt | 269 ++++++++ .../detailed/components/ScoreStarsBlock.kt | 100 +++ .../detailed/components/SecurityScoreBlock.kt | 140 ++++ .../components/SecurityScoreBottomSheet.kt | 190 +++++ .../components/TokenMarketDetailsBody.kt | 200 ++++++ .../preview/MarketsTokenDetailsPreview.kt | 139 ++++ .../preview/SecurityScorePreviewData.kt | 60 ++ .../state/ExchangesBottomSheetContent.kt | 72 ++ .../detailed/state/InfoBottomSheetContent.kt | 13 + .../ui/market/detailed/state/InfoPointUM.kt | 14 + .../ui/market/detailed/state/InsightsUM.kt | 12 + .../feed/ui/market/detailed/state/LinksUM.kt | 18 + .../ui/market/detailed/state/ListedOnUM.kt | 44 ++ .../detailed/state/MarketsTokenDetailsUM.kt | 72 ++ .../ui/market/detailed/state/MetricsUM.kt | 7 + .../detailed/state/PricePerformanceUM.kt | 17 + .../state/SecurityScoreBottomSheetContent.kt | 25 + .../market/detailed/state/SecurityScoreUM.kt | 10 + .../feed/ui/market/list/MarketsList.kt | 11 +- .../list/components/MarketsListLazyColumn.kt | 2 +- .../MarketsListSortByBottomSheet.kt | 4 +- .../wallet/child/wallet/WalletComponent.kt | 5 +- 76 files changed, 5247 insertions(+), 176 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableModularBottomSheetContentComponent.kt create mode 100644 features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/analytics/MarketDetailsAnalyticsEvent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/DescriptionConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/ExchangeItemStateConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/InsightsConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/LinksConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/MetricsConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/PricePerformanceConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/SecurityScoreConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/TokenMarketInfoConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/formatter/Formatters.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/formatter/MarketsDateTimeFormatters.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/state/QuotesStateUpdater.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/state/TokenNetworksState.kt rename features/feed/impl/src/main/kotlin/com/tangem/features/feed/{ui => model}/market/list/state/MarketsListUM.kt (97%) rename features/feed/impl/src/main/kotlin/com/tangem/features/feed/{ui => model}/market/list/state/MarketsNotificationUM.kt (93%) rename features/feed/impl/src/main/kotlin/com/tangem/features/feed/{ui => model}/market/list/state/SortByBottomSheetContentUM.kt (81%) delete mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryBottomSheetContent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ExchangesBottomSheet.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoBottomSheet.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoPoint.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InsightsBlock.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MarketTokenDetailsChart.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsBlock.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/PricePerformanceBlock.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ScoreStarsBlock.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBottomSheet.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/SecurityScorePreviewData.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/ExchangesBottomSheetContent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/InfoBottomSheetContent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/InfoPointUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/InsightsUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/LinksUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/ListedOnUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MetricsUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/PricePerformanceUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/SecurityScoreBottomSheetContent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/SecurityScoreUM.kt diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 2462507a7b..820b018fc2 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -16,6 +16,9 @@ import com.tangem.features.createwalletselection.CreateWalletSelectionComponent import com.tangem.features.createwalletstart.CreateWalletStartComponent import com.tangem.features.details.component.DetailsComponent import com.tangem.features.disclaimer.api.components.DisclaimerComponent +import com.tangem.features.feed.entry.components.FeedEntryComponent +import com.tangem.features.feed.entry.components.FeedEntryRoute +import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle import com.tangem.features.home.api.HomeComponent import com.tangem.features.hotwallet.* import com.tangem.features.kyc.KycComponent @@ -38,10 +41,7 @@ import com.tangem.features.staking.api.StakingComponent import com.tangem.features.swap.SwapComponent import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.components.TangemPayOnboardingComponent -import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.ContinueOnboarding -import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.Deeplink -import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.FromBannerOnMain -import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.FromBannerInSettings +import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.* import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.wallet.WalletEntryComponent import com.tangem.features.walletconnect.components.WalletConnectEntryComponent @@ -121,6 +121,8 @@ internal class ChildFactory @Inject constructor( private val yieldSupplyPromoComponentFactory: YieldSupplyPromoComponent.Factory, private val yieldSupplyActiveComponentFactory: YieldSupplyActiveComponent.Factory, private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val feedEntryComponentFactory: FeedEntryComponent.Factory, + private val feedFeatureToggle: FeedFeatureToggle, ) { @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -204,21 +206,39 @@ internal class ChildFactory @Inject constructor( ) } is AppRoute.MarketsTokenDetails -> { - createComponentChild( - context = context, - params = MarketsTokenDetailsComponent.Params( - token = route.token, - appCurrency = route.appCurrency, - shouldShowPortfolio = route.shouldShowPortfolio, - analyticsParams = route.analyticsParams?.let { params -> - MarketsTokenDetailsComponent.AnalyticsParams( - blockchain = params.blockchain, - source = params.source, - ) - }, - ), - componentFactory = marketsTokenDetailsComponentFactory, - ) + if (feedFeatureToggle.isFeedEnabled) { + createComponentChild( + context = context, + params = FeedEntryRoute.MarketTokenDetails( + token = route.token, + appCurrency = route.appCurrency, + shouldShowPortfolio = route.shouldShowPortfolio, + analyticsParams = route.analyticsParams?.let { params -> + FeedEntryRoute.MarketTokenDetails.AnalyticsParams( + blockchain = params.blockchain, + source = params.source, + ) + }, + ), + componentFactory = feedEntryComponentFactory, + ) + } else { + createComponentChild( + context = context, + params = MarketsTokenDetailsComponent.Params( + token = route.token, + appCurrency = route.appCurrency, + shouldShowPortfolio = route.shouldShowPortfolio, + analyticsParams = route.analyticsParams?.let { params -> + MarketsTokenDetailsComponent.AnalyticsParams( + blockchain = params.blockchain, + source = params.source, + ) + }, + ), + componentFactory = marketsTokenDetailsComponentFactory, + ) + } } is AppRoute.Onramp -> { createComponentChild( diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt index 3b3f257ab5..d73e4e8486 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector 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.unit.dp @@ -85,6 +86,7 @@ private fun TrendingArticle(articleConfigUM: ArticleConfigUM) { style = TangemTheme.typography.h3, maxLines = 3, overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, ) SpacerH(8.dp) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButtonAndIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButtonAndIcon.kt index 47544cffc6..3c88e8545b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButtonAndIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButtonAndIcon.kt @@ -15,6 +15,8 @@ import com.tangem.core.ui.res.TangemThemePreview fun AppBarWithBackButtonAndIcon( onBackClick: () -> Unit, modifier: Modifier = Modifier, + backButtonEnabled: Boolean = true, + endButtonEnabled: Boolean = true, text: String? = null, subtitle: String? = null, @DrawableRes backIconRes: Int? = null, @@ -30,11 +32,13 @@ fun AppBarWithBackButtonAndIcon( startButton = TopAppBarButtonUM.Icon( iconRes = backIconRes ?: R.drawable.ic_back_24, onClicked = onBackClick, + isEnabled = backButtonEnabled, ), endButton = if (iconRes != null && onIconClick != null) { TopAppBarButtonUM.Icon( iconRes = iconRes, onClicked = onIconClick, + isEnabled = endButtonEnabled, ) } else { null diff --git a/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableModularBottomSheetContentComponent.kt b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableModularBottomSheetContentComponent.kt new file mode 100644 index 0000000000..64495d533c --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableModularBottomSheetContentComponent.kt @@ -0,0 +1,33 @@ +package com.tangem.core.ui.decompose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState + +/** + * An interface describing the UI part of a component for a modular BottomSheet. + * + * Designed for use in Decompose components. It separates the UI into a title and content, + * providing access to the [BottomSheetState] to react to changes in the sheet's state (collapsed/expanded). + */ +@Stable +interface ComposableModularBottomSheetContentComponent { + + /** + * Renders the title of the bottom sheet. + * @param bottomSheetState The current state of the bottom sheet. This can be used, for example, + * to change navigation buttons (e.g., hiding the "Back" button when collapsed). + */ + @Composable + fun Title(bottomSheetState: State) + + /** + * Renders the main content of the bottom sheet. + * @param bottomSheetState The current state of the bottom sheet. Useful for tracking visibility + * (e.g., for analytics or lifecycle effects when the sheet is [BottomSheetState.EXPANDED]). + */ + @Composable + fun Content(bottomSheetState: State, modifier: Modifier) +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt index 43d68d3d11..f609996618 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt @@ -6,10 +6,12 @@ import androidx.compose.runtime.State import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableContentComponent @Stable -interface FeedEntryComponent { +interface FeedEntryComponent : ComposableContentComponent { @Composable fun BottomSheetContent( @@ -18,7 +20,7 @@ interface FeedEntryComponent { modifier: Modifier, ) - interface Factory { - fun create(context: AppComponentContext): FeedEntryComponent + interface Factory : ComponentFactory { + fun create(context: AppComponentContext, entryRoute: FeedEntryRoute?): FeedEntryComponent } } \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt new file mode 100644 index 0000000000..e6f0e3df45 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt @@ -0,0 +1,27 @@ +package com.tangem.features.feed.entry.components + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams +import kotlinx.serialization.Serializable + +@Serializable +sealed interface FeedEntryRoute { + + @Serializable + data class MarketTokenDetails( + val token: TokenMarketParams, + val appCurrency: AppCurrency, + val shouldShowPortfolio: Boolean, + val analyticsParams: AnalyticsParams? = null, + ) : FeedEntryRoute { + + @Serializable + data class AnalyticsParams( + val blockchain: String?, + val source: String, + ) + } + + @Serializable + data object MarketTokenList : FeedEntryRoute +} \ No newline at end of file diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts index 9d6659788e..ed4e7f4ba6 100644 --- a/features/feed/impl/build.gradle.kts +++ b/features/feed/impl/build.gradle.kts @@ -52,6 +52,8 @@ dependencies { implementation(projects.domain.notifications.models) implementation(projects.domain.transaction) implementation(projects.domain.news) + implementation(projects.domain.yieldSupply.models) + implementation(projects.domain.yieldSupply) // FIXME [REDACTED_TASK_KEY] // Remove the "Buy" and "Sell" actions from the redux middleware. 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 c3f6755841..8b1cc33975 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 @@ -1,31 +1,31 @@ package com.tangem.features.feed.components import androidx.activity.compose.BackHandler -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack -import com.arkivanov.decompose.router.stack.popWhile +import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.value.Value +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState -import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent import com.tangem.features.feed.entry.components.FeedEntryComponent +import com.tangem.features.feed.entry.components.FeedEntryRoute import com.tangem.features.feed.model.feed.FeedModelClickIntents -import com.tangem.features.feed.ui.EntryBottomSheetContent -import com.tangem.features.feed.ui.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.ui.EntryContent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -33,6 +33,9 @@ import dagger.assisted.AssistedInject @Stable internal class DefaultFeedEntryComponent @AssistedInject constructor( @Assisted context: AppComponentContext, + @Assisted entryRoute: FeedEntryRoute?, + analyticsEventHandler: AnalyticsEventHandler, + accountsFeatureToggles: AccountsFeatureToggles, private val feedEntryChildFactory: FeedEntryChildFactory, ) : FeedEntryComponent, AppComponentContext by context { @@ -55,6 +58,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( blockchain = null, source = "Market", ), + onBackClicked = { onChildBack() }, ), ), ) @@ -82,23 +86,26 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( } } - private val stack: Value> = childStack( - key = "main", - source = stackNavigation, - serializer = FeedEntryChildFactory.Child.serializer(), - initialConfiguration = FeedEntryChildFactory.Child.Feed, - handleBackButton = false, - childFactory = { configuration, factoryContext -> - feedEntryChildFactory.createChild( - child = configuration, - appComponentContext = childByContext( - componentContext = factoryContext, - router = innerRouter, - ), - feedEntryClickIntents = clickIntents, - ) - }, - ) + private val stack: Value> = + childStack( + key = "main", + source = stackNavigation, + serializer = FeedEntryChildFactory.Child.serializer(), + initialConfiguration = mapEntryRouteToChild(entryRoute), + handleBackButton = false, + childFactory = { configuration, factoryContext -> + feedEntryChildFactory.createChild( + child = configuration, + appComponentContext = childByContext( + componentContext = factoryContext, + router = innerRouter, + ), + feedEntryClickIntents = clickIntents, + analyticsEventHandler = analyticsEventHandler, + accountsFeatureToggles = accountsFeatureToggles, + ) + }, + ) @Composable override fun BottomSheetContent( @@ -106,27 +113,73 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier, ) { - val stackState by stack.subscribeAsState() - BackHandler(enabled = bottomSheetState.value == BottomSheetState.EXPANDED) { onChildBack() } - EntryBottomSheetContent( - stackState = stackState, + EntryContent( + bottomSheetState = bottomSheetState, + stackState = stack.subscribeAsState(), onHeaderSizeChange = onHeaderSizeChange, + isOpenedInBottomSheet = true, + ) + } + + @Composable + override fun Content(modifier: Modifier) { + val bottomSheetState = remember { + derivedStateOf { BottomSheetState.EXPANDED } + } + + BackHandler { + router.pop() + } + + EntryContent( + bottomSheetState = bottomSheetState, + stackState = stack.subscribeAsState(), + onHeaderSizeChange = {}, + isOpenedInBottomSheet = false, ) } private fun onChildBack() { if (stack.value.active.configuration !is FeedEntryChildFactory.Child.Feed) { - stackNavigation.popWhile { it != FeedEntryChildFactory.Child.Feed } + stackNavigation.pop() + } + } + + private fun mapEntryRouteToChild(entryRoute: FeedEntryRoute?): FeedEntryChildFactory.Child { + return when (entryRoute) { + is FeedEntryRoute.MarketTokenDetails -> FeedEntryChildFactory.Child.TokenDetails( + params = DefaultMarketsTokenDetailsComponent.Params( + token = entryRoute.token, + appCurrency = entryRoute.appCurrency, + shouldShowPortfolio = entryRoute.shouldShowPortfolio, + analyticsParams = entryRoute.analyticsParams?.let { params -> + DefaultMarketsTokenDetailsComponent.AnalyticsParams( + blockchain = params.blockchain, + source = params.source, + ) + }, + onBackClicked = { router.pop() }, + ), + ) + FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList( + DefaultMarketsTokenListComponent.Params( + onBackClicked = { router.pop() }, + onTokenClick = { token, currency -> clickIntents.onMarketItemClick(token, currency) }, + preselectedSortType = SortByTypeUM.Rating, + shouldAlwaysShowSearchBar = false, + ), + ) + null -> FeedEntryChildFactory.Child.Feed } } @AssistedFactory interface Factory : FeedEntryComponent.Factory { - override fun create(context: AppComponentContext): DefaultFeedEntryComponent + override fun create(context: AppComponentContext, entryRoute: FeedEntryRoute?): DefaultFeedEntryComponent } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index 30b8ee5e79..3cbe7851d2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -1,9 +1,11 @@ package com.tangem.features.feed.components import androidx.compose.runtime.Immutable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.navigation.Route -import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent @@ -43,12 +45,16 @@ internal class FeedEntryChildFactory @Inject constructor() { child: Child, appComponentContext: AppComponentContext, feedEntryClickIntents: FeedEntryClickIntents, - ): ComposableModularContentComponent { + analyticsEventHandler: AnalyticsEventHandler, + accountsFeatureToggles: AccountsFeatureToggles, + ): ComposableModularBottomSheetContentComponent { return when (child) { is Child.TokenDetails -> { DefaultMarketsTokenDetailsComponent( appComponentContext = appComponentContext, params = child.params, + analyticsEventHandler = analyticsEventHandler, + accountsFeatureToggles = accountsFeatureToggles, ) } is Child.TokenList -> { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt index 9257a465b1..90ab30cbda 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt @@ -1,13 +1,15 @@ package com.tangem.features.feed.components.feed import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.LifecycleStartEffect 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.ComposableModularContentComponent +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.features.feed.model.feed.FeedComponentModel import com.tangem.features.feed.model.feed.FeedModelClickIntents import com.tangem.features.feed.ui.feed.FeedList @@ -16,18 +18,18 @@ import com.tangem.features.feed.ui.feed.FeedListHeader internal class DefaultFeedComponent( appComponentContext: AppComponentContext, private val params: FeedParams, -) : ComposableModularContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { private val feedComponentModel = getOrCreateModel(params = params) @Composable - override fun Title() { + override fun Title(bottomSheetState: State) { val state by feedComponentModel.state.collectAsStateWithLifecycle() FeedListHeader(state.feedListSearchBar) } @Composable - override fun Content(modifier: Modifier) { + override fun Content(bottomSheetState: State, modifier: Modifier) { LifecycleStartEffect(Unit) { feedComponentModel.isVisibleOnScreen.value = true onStopOrDispose { @@ -42,8 +44,5 @@ internal class DefaultFeedComponent( ) } - @Composable - override fun Footer() = Unit - data class FeedParams(val feedClickIntents: FeedModelClickIntents) } \ No newline at end of file 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 f6933c66cd..a870fb007c 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,28 +1,97 @@ package com.tangem.features.feed.components.market.details 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.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel +import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent +import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent +import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar import kotlinx.serialization.Serializable internal class DefaultMarketsTokenDetailsComponent( appComponentContext: AppComponentContext, val params: Params, -) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + analyticsEventHandler: AnalyticsEventHandler, + private val accountsFeatureToggles: AccountsFeatureToggles, + // TODO add portfolio in migrate [REDACTED_JIRA] +) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { - @Composable - override fun Title() { + // applying l2 compatibility + private val updatedParams = params.copy( + token = params.token.copy( + id = CryptoCurrency.RawID(getTokenIdIfL2Network(params.token.id.value)), + ), + ) + private val analyticsParams = params.analyticsParams + private val model: MarketsTokenDetailsModel = getOrCreateModel(updatedParams) + + init { + // === Analytics === + if (analyticsParams != null) { + analyticsEventHandler.send( + MarketDetailsAnalyticsEvent.EventBuilder( + token = params.token, + ).screenOpened( + blockchain = analyticsParams.blockchain, + source = analyticsParams.source, + ), + ) + } } @Composable - override fun Content(modifier: Modifier) { + override fun Title(bottomSheetState: State) { + val state by model.state.collectAsStateWithLifecycle() + MarketsTokenDetailsTopBar( + onBackClick = { params.onBackClicked() }, + isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED, + shouldShowPriceSubtitle = state.shouldShowPriceSubtitle, + tokenName = state.tokenName, + tokenPrice = state.priceText, + backgroundColor = TangemTheme.colors.background.tertiary, + ) } @Composable - override fun Footer() { + override fun Content(bottomSheetState: State, modifier: Modifier) { + LifecycleStartEffect(Unit) { + model.isVisibleOnScreen.value = true + onStopOrDispose { + model.isVisibleOnScreen.value = false + } + } + val state by model.state.collectAsStateWithLifecycle() + val bsState by bottomSheetState + LaunchedEffect(bsState) { + model.isVisibleOnScreen.value = bsState == BottomSheetState.EXPANDED + } + + MarketsTokenDetailsContent( + modifier = modifier, + backgroundColor = LocalMainBottomSheetColor.current.value, + state = state, + isAccountEnabled = accountsFeatureToggles.isFeatureEnabled, + portfolioBlock = { + // TODO add portfolio in migrate [REDACTED_JIRA] + }, + ) } @Serializable @@ -31,6 +100,7 @@ internal class DefaultMarketsTokenDetailsComponent( val appCurrency: AppCurrency, val shouldShowPortfolio: Boolean, val analyticsParams: AnalyticsParams?, + val onBackClicked: () -> Unit, ) @Serializable 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 75c9ca0587..b459f3e7d8 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 @@ -1,56 +1,64 @@ package com.tangem.features.feed.components.market.list 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.lifecycle.compose.LifecycleStartEffect 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.ComposableModularContentComponent +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.model.market.list.MarketsListModel +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.market.list.MarketsList import com.tangem.features.feed.ui.market.list.TopBarWithSearch -import com.tangem.features.feed.ui.market.list.state.SortByTypeUM import kotlinx.serialization.Serializable internal class DefaultMarketsTokenListComponent( appComponentContext: AppComponentContext, private val params: Params, -) : ComposableModularContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { private val model: MarketsListModel = getOrCreateModel(params = params) @Composable - override fun Title() { + override fun Title(bottomSheetState: State) { val state by model.state.collectAsStateWithLifecycle() TopBarWithSearch( onBackClick = params.onBackClicked, onSearchClick = state.onSearchClicked, marketsSearchBar = state.marketsSearchBar, + buttonsEnabled = bottomSheetState.value == BottomSheetState.EXPANDED, ) } @Composable - override fun Content(modifier: Modifier) { + override fun Content(bottomSheetState: State, modifier: Modifier) { LifecycleStartEffect(Unit) { model.isVisibleOnScreen.value = true onStopOrDispose { model.isVisibleOnScreen.value = false } } + + val bsState by bottomSheetState val state by model.state.collectAsStateWithLifecycle() + + LaunchedEffect(bsState) { + model.containerBottomSheetState.value = bsState + } + MarketsList( modifier = modifier, state = state, ) } - @Composable - override fun Footer() = Unit - @Serializable data class Params( val onBackClicked: () -> Unit, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt index bad826640c..49913d39ef 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt @@ -1,23 +1,19 @@ package com.tangem.features.feed.components.news.details import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.ui.Modifier import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent internal class DefaultNewsDetailsComponent( appComponentContext: AppComponentContext, -) : ComposableModularContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { @Composable - override fun Title() { - } + override fun Title(bottomSheetState: State) {} @Composable - override fun Content(modifier: Modifier) { - } - - @Composable - override fun Footer() { - } + override fun Content(bottomSheetState: State, modifier: Modifier) {} } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt index bdde19a5c8..992bd9120c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt @@ -1,23 +1,21 @@ package com.tangem.features.feed.components.news.list import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.ui.Modifier import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent internal class DefaultNewsListComponent( appComponentContext: AppComponentContext, -) : ComposableModularContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { @Composable - override fun Title() { + override fun Title(bottomSheetState: State) { } @Composable - override fun Content(modifier: Modifier) { - } - - @Composable - override fun Footer() { + override fun Content(bottomSheetState: State, modifier: Modifier) { } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt index fbce8929f3..4d988a12de 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt @@ -3,6 +3,7 @@ package com.tangem.features.feed.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.feed.model.feed.FeedComponentModel +import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel import com.tangem.features.feed.model.market.list.MarketsListModel import dagger.Binds import dagger.Module @@ -23,4 +24,9 @@ internal interface ModelModule { @IntoMap @ClassKey(MarketsListModel::class) fun provideMarketsListModel(model: MarketsListModel): Model + + @Binds + @IntoMap + @ClassKey(MarketsTokenDetailsModel::class) + fun provideMarketsTokenDetailsModel(model: MarketsTokenDetailsModel): Model } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/MarketsTokenItemConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/MarketsTokenItemConverter.kt index d7ba20e636..3dea584bad 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/MarketsTokenItemConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/MarketsTokenItemConverter.kt @@ -12,7 +12,7 @@ import com.tangem.core.ui.format.bigdecimal.* import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket import com.tangem.features.feed.impl.R -import com.tangem.features.feed.ui.market.list.state.MarketsListUM +import com.tangem.features.feed.model.market.list.state.MarketsListUM import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index e61e95a217..966fedb289 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -17,7 +17,7 @@ import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.feed.state.* -import com.tangem.features.feed.ui.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt index 97621a1ef9..2cc84cf12f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt @@ -2,7 +2,7 @@ package com.tangem.features.feed.model.feed import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams -import com.tangem.features.feed.ui.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM /** * Callback interface for feed model navigation actions. 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 new file mode 100644 index 0000000000..08e876be53 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -0,0 +1,653 @@ +package com.tangem.features.feed.model.market.details + +import androidx.compose.runtime.Stable +import arrow.core.Either +import arrow.core.getOrElse +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.MarketChartDataProducer +import com.tangem.common.ui.charts.state.sorted +import com.tangem.core.analytics.api.AnalyticsEventHandler +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.navigation.url.UrlOpener +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.core.ui.format.bigdecimal.price +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.markets.* +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.settings.usercountry.GetUserCountryUseCase +import com.tangem.domain.settings.usercountry.models.UserCountry +import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent +import com.tangem.features.feed.model.market.details.converter.DescriptionConverter +import com.tangem.features.feed.model.market.details.converter.ExchangeItemStateConverter +import com.tangem.features.feed.model.market.details.converter.TokenMarketInfoConverter +import com.tangem.features.feed.model.market.details.formatter.* +import com.tangem.features.feed.model.market.details.state.QuotesStateUpdater +import com.tangem.features.feed.model.market.details.state.TokenNetworksState +import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent +import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import org.joda.time.DateTime +import java.math.BigDecimal +import java.util.Locale +import javax.inject.Inject + +@Suppress("LargeClass", "LongParameterList") +@Stable +@ModelScoped +internal class MarketsTokenDetailsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getUserCountryUseCase: GetUserCountryUseCase, + paramsContainer: ParamsContainer, + private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, + private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, + private val getTokenFullQuotesUseCase: GetTokenFullQuotesUseCase, + private val getTokenExchangesUseCase: GetTokenExchangesUseCase, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, + private val getUserWalletsUseCase: GetWalletsUseCase, + private val excludedBlockchains: ExcludedBlockchains, + private val urlOpener: UrlOpener, +) : Model() { + + private val quotesJob = JobHolder() + private var userCountry: UserCountry? = null + private val params = paramsContainer.require() + private val analyticsEventBuilder = MarketDetailsAnalyticsEvent.EventBuilder(token = params.token) + + private val currentAppCurrency = getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + }.stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = params.appCurrency, + ) + + private val infoConverter = TokenMarketInfoConverter( + appCurrency = Provider { currentAppCurrency.value }, + onInfoClick = { showBottomSheet(it) }, + onListedOnClick = ::onListedOnClick, + onLinkClick = { link -> + urlOpener.openUrl(link.url) + // === Analytics === + analyticsEventHandler.send(analyticsEventBuilder.linkClicked(linkTitle = link.title)) + }, + onSecurityScoreInfoClick = { content -> + showBottomSheet(content) + + // === Analytics === + analyticsEventHandler.send(analyticsEventBuilder.securityScoreOpened()) + }, + onSecurityScoreProviderLinkClick = { securityScoreProviderUM -> + securityScoreProviderUM.urlData?.fullUrl?.let { url -> + urlOpener.openUrl(url) + } + + // === Analytics === + analyticsEventHandler.send(analyticsEventBuilder.securityScoreProviderClicked(securityScoreProviderUM.name)) + }, + // === Analytics === + onPricePerformanceIntervalChanged = { interval -> + analyticsEventHandler.send( + analyticsEventBuilder.intervalChanged( + intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance, + interval = interval, + ), + ) + }, + onInsightsIntervalChanged = { interval -> + analyticsEventHandler.send( + analyticsEventBuilder.intervalChanged( + intervalType = MarketDetailsAnalyticsEvent.IntervalType.Insights, + interval = interval, + ), + ) + }, + needApplyFCARestrictions = Provider { + userCountry.needApplyFCARestrictions() + }, + // ================== + ) + + private val descriptionConverter = DescriptionConverter( + onReadModeClicked = { content -> + showBottomSheet(content) + // === Analytics === + analyticsEventHandler.send(analyticsEventBuilder.readMoreClicked()) + }, + needApplyFCARestrictions = Provider { + userCountry.needApplyFCARestrictions() + }, + onGeneratedAINotificationClick = { + modelScope.launch { + sendFeedbackEmailUseCase( + type = FeedbackEmailType.CurrencyDescriptionError( + currencyId = params.token.id.value, + currencyName = params.token.name, + ), + ) + } + }, + ) + + private val chartDataProducer = MarketChartDataProducer.build(dispatcher = dispatchers.default) { + chartData = MarketChartData.NoData.Loading + + updateLook { marketChartLook -> + val percentChangeType = params.token.tokenQuotes.h24Percent.percentChangeType() + + marketChartLook.copy( + type = percentChangeType.toChartType(), + xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(PriceChangeInterval.H24), + yAxisFormatter = { value -> + value.format { + fiat( + fiatCurrencyCode = currentAppCurrency.value.code, + fiatCurrencySymbol = currentAppCurrency.value.symbol, + ).price() + } + }, + ) + } + } + + private val currentQuotes = MutableStateFlow( + TokenQuotes( + currentPrice = params.token.tokenQuotes.currentPrice, + h24ChangePercent = params.token.tokenQuotes.h24Percent, + weekChangePercent = params.token.tokenQuotes.weekPercent, + monthChangePercent = params.token.tokenQuotes.monthPercent, + m3ChangePercent = null, + m6ChangePercent = null, + yearChangePercent = null, + allTimeChangePercent = null, + ), + ) + + private val currentTokenInfo = MutableStateFlow(null) + private val lastUpdatedTimestamp = MutableStateFlow(DateTime.now().millis) + + val isVisibleOnScreen = MutableStateFlow(false) + val networksState = MutableStateFlow(TokenNetworksState.Loading) + + val state = MutableStateFlow( + MarketsTokenDetailsUM( + tokenName = params.token.name, + priceText = params.token.tokenQuotes.currentPrice.format { + fiat( + fiatCurrencyCode = currentAppCurrency.value.code, + fiatCurrencySymbol = currentAppCurrency.value.symbol, + ).price() + }, + dateTimeText = resourceReference(R.string.common_today), + priceChangePercentText = params.token.tokenQuotes.h24Percent?.format { percent() }, + priceChangeType = params.token.tokenQuotes.h24Percent.percentChangeType(), + iconUrl = params.token.imageUrl, + chartState = MarketsTokenDetailsUM.ChartState( + dataProducer = chartDataProducer, + onLoadRetryClick = ::onLoadRetryClicked, + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + onMarkerPointSelected = ::onMarkerPointSelected, + ), + selectedInterval = PriceChangeInterval.H24, + onSelectedIntervalChange = ::onSelectedIntervalChange, + isMarkerSet = false, + body = MarketsTokenDetailsUM.Body.Loading, + triggerPriceChange = consumedEvent(), + bottomSheetConfig = TangemBottomSheetConfig( + isShown = false, + onDismissRequest = {}, + content = TangemBottomSheetConfigContent.Empty, + ), + shouldShowPriceSubtitle = false, + onShouldShowPriceSubtitleChange = ::onShouldShowPriceSubtitleChange, + ), + ) + + private val quotesStateUpdater = QuotesStateUpdater( + currentAppCurrency = Provider { currentAppCurrency.value }, + state = state, + currentQuotes = currentQuotes, + lastUpdatedTimestamp = lastUpdatedTimestamp, + currentTokenInfo = currentTokenInfo, + onPricePerformanceIntervalChanged = { interval -> + analyticsEventHandler.send( + analyticsEventBuilder.intervalChanged( + intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance, + interval = interval, + ), + ) + }, + ) + + private val loadChartJobHolder = JobHolder() + + init { + userCountry = getUserCountryUseCase.invokeSync().getOrNull() + ?: UserCountry.Other(Locale.getDefault().country) + // reload screen if currency changed + modelScope.launch { + currentAppCurrency + .filter { it != params.appCurrency } + .collectLatest { + initialLoad() + } + } + + initialLoad() + } + + private fun initialLoad() { + loadInfo() + loadChart(state.value.selectedInterval) + modelScope.loadQuotesWithTimer(QUOTES_UPDATE_INTERVAL_MILLIS) + } + + private fun loadQuotes() { + modelScope.launch { + val result = getTokenFullQuotesUseCase( + tokenId = params.token.id, + appCurrency = currentAppCurrency.value, + tokenSymbol = params.token.symbol, + ) + + result.onRight { res -> + updateQuotes(res) + } + } + } + + private fun loadChart(interval: PriceChangeInterval) { + modelScope.launch { + state.update { marketsTokenDetailsUM -> + marketsTokenDetailsUM.copy( + chartState = marketsTokenDetailsUM.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + ), + ) + } + + chartDataProducer.runTransactionSuspend { + chartData = MarketChartData.NoData.Loading + } + + val chart = getTokenPriceChartUseCase.invoke( + appCurrency = currentAppCurrency.value, + interval = interval, + tokenId = params.token.id, + tokenSymbol = params.token.symbol, + preview = false, + ) + + state.update { marketsTokenDetailsUM -> + marketsTokenDetailsUM.copy( + selectedInterval = interval, + chartState = marketsTokenDetailsUM.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + ), + ) + } + + chart + .onRight { updateTokenChart(it) } + .onLeft { + state.update { marketsTokenDetailsUM -> + marketsTokenDetailsUM.copy( + chartState = marketsTokenDetailsUM.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.ERROR, + ), + body = if (marketsTokenDetailsUM.body is MarketsTokenDetailsUM.Body.Error) { + MarketsTokenDetailsUM.Body.Nothing + } else { + marketsTokenDetailsUM.body + }, + ) + } + } + }.saveIn(loadChartJobHolder) + } + + private suspend fun updateTokenChart(tokenChart: TokenChart) { + val xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(state.value.selectedInterval) + + chartDataProducer.runTransactionSuspend { + chartData = MarketChartData.Data( + y = tokenChart.priceY.toImmutableList(), + x = tokenChart.timeStamps.map { it.toBigDecimal() }.toImmutableList(), + ).sorted() + + updateLook { marketChartLook -> + marketChartLook.copy( + xAxisFormatter = xAxisFormatter, + type = state.value.priceChangeType.toChartType(), + ) + } + } + + state.update { marketsTokenDetailsUM -> + marketsTokenDetailsUM.copy( + chartState = marketsTokenDetailsUM.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.DATA, + ), + body = if (marketsTokenDetailsUM.body is MarketsTokenDetailsUM.Body.Nothing) { + MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked) + } else { + marketsTokenDetailsUM.body + }, + ) + } + } + + private fun loadInfo() { + state.update { marketsTokenDetailsUM -> + marketsTokenDetailsUM.copy( + body = MarketsTokenDetailsUM.Body.Loading, + ) + } + + modelScope.launch { + val tokenMarketInfo = getTokenMarketInfoUseCase( + appCurrency = currentAppCurrency.value, + tokenId = params.token.id, + tokenSymbol = params.token.symbol, + ) + + tokenMarketInfo.fold( + ifRight = { result -> updateInfo(result) }, + ifLeft = { + state.update { marketsTokenDetailsUM -> + if (marketsTokenDetailsUM.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) { + marketsTokenDetailsUM.copy( + body = MarketsTokenDetailsUM.Body.Error( + onLoadRetryClick = ::onLoadRetryClicked, + ), + ) + } else { + marketsTokenDetailsUM.copy( + body = MarketsTokenDetailsUM.Body.Nothing, + ) + } + } + }, + ) + } + } + + private fun updateInfo(newInfo: TokenMarketInfo) { + lastUpdatedTimestamp.value = DateTime.now().millis + + currentTokenInfo.value = newInfo + currentQuotes.value = newInfo.quotes + + val percent = newInfo.quotes.getPercentByInterval(interval = state.value.selectedInterval) + + state.update { marketsTokenDetailsUM -> + marketsTokenDetailsUM.copy( + priceText = newInfo.quotes.currentPrice.format { + fiat( + fiatCurrencySymbol = currentAppCurrency.value.symbol, + fiatCurrencyCode = currentAppCurrency.value.code, + ).price() + }, + priceChangePercentText = newInfo.quotes.getFormattedPercentByInterval( + interval = marketsTokenDetailsUM.selectedInterval, + ), + priceChangeType = percent.percentChangeType(), + body = MarketsTokenDetailsUM.Body.Content( + description = descriptionConverter.convert(newInfo), + infoBlocks = infoConverter.convert(newInfo), + ), + ) + } + + val isAllWalletsIsHot = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } + + val networks = newInfo.networks?.filter { network -> + BlockchainUtils.isSupportedNetworkId( + blockchainId = network.networkId, + excludedBlockchains = excludedBlockchains, + hotExcludedBlockchains = hotWalletExcludedBlockchains, + hasOnlyHotWallets = isAllWalletsIsHot, + ) + } + + networksState.value = if (networks.isNullOrEmpty()) { + TokenNetworksState.NoNetworksAvailable + } else { + TokenNetworksState.NetworksAvailable(networks) + } + + chartDataProducer.runTransaction { + updateLook { + it.copy(type = percent.percentChangeType().toChartType()) + } + } + } + + private suspend fun updateQuotes(newQuotes: TokenQuotes) { + val populatedNewQuotes = currentQuotes.value.populateWith(newQuotes) + + quotesStateUpdater.updateQuotes(newQuotes = populatedNewQuotes) + + val percent = populatedNewQuotes + .getPercentByInterval(interval = state.value.selectedInterval) + + chartDataProducer.runTransaction { + updateLook { + it.copy(type = percent.percentChangeType().toChartType()) + } + } + } + + private fun onSelectedIntervalChange(interval: PriceChangeInterval) { + if (state.value.selectedInterval == interval) return + + // === Analytics === + analyticsEventHandler.send( + analyticsEventBuilder.intervalChanged( + intervalType = MarketDetailsAnalyticsEvent.IntervalType.Chart, + interval = interval, + ), + ) + // ================== + + val quotes = currentQuotes.value + val priceChangePercent = quotes.getFormattedPercentByInterval(interval) + + state.update { marketsTokenDetailsUM -> + marketsTokenDetailsUM.copy( + priceChangePercentText = priceChangePercent, + selectedInterval = interval, + priceChangeType = quotes.getPercentByInterval(interval)?.percentChangeType() + ?: PriceChangeType.NEUTRAL, + dateTimeText = getDefaultDateTimeString(interval), + ) + } + + loadChart(interval) + + if (priceChangePercent.isEmpty()) { + loadQuotes() + } + } + + private fun onShouldShowPriceSubtitleChange(shouldShow: Boolean) { + state.update { marketsTokenDetailsUM -> + marketsTokenDetailsUM.copy(shouldShowPriceSubtitle = shouldShow) + } + } + + @Suppress("MagicNumber") + private fun onMarkerPointSelected(markerTimestamp: BigDecimal?, price: BigDecimal?) { + val currentState = state.value + + val dateTimeText = markerTimestamp?.let { bigDecimal -> + MarketsDateTimeFormatters.formatDateByIntervalWithMarker( + interval = currentState.selectedInterval, + markerTimestamp = bigDecimal, + ) + } ?: getDefaultDateTimeString(currentState.selectedInterval) + + val priceText = (price ?: currentQuotes.value.currentPrice).format { + fiat( + fiatCurrencySymbol = currentAppCurrency.value.symbol, + fiatCurrencyCode = currentAppCurrency.value.code, + ).price() + } + + val percent = price?.let { bigDecimal -> + getChangePercentBetween( + previousPrice = bigDecimal, + currentPrice = currentQuotes.value.currentPrice, + ) + } ?: currentQuotes.value.getPercentByInterval(currentState.selectedInterval) + + val percentText = percent?.format { percent() }.orEmpty() + + state.update { stateToUpdate -> + stateToUpdate.copy( + isMarkerSet = markerTimestamp != null, + dateTimeText = dateTimeText, + priceText = priceText, + priceChangePercentText = percentText, + priceChangeType = percent.percentChangeType(), + ) + } + + chartDataProducer.runTransaction { + updateLook { marketChartLook -> + marketChartLook.copy( + type = percent.percentChangeType().toChartType(), + ) + } + } + } + + private fun showBottomSheet(content: TangemBottomSheetConfigContent) { + state.update { stateToUpdate -> + stateToUpdate.copy( + bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy( + isShown = true, + onDismissRequest = ::hideBottomSheet, + content = content, + ), + ) + } + } + + private fun hideBottomSheet() { + state.update { stateToUpdate -> + stateToUpdate.copy( + bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy(isShown = false), + ) + } + } + + private fun onLoadRetryClicked() { + val currentState = state.value + + if (currentState.chartState.status == MarketsTokenDetailsUM.ChartState.Status.ERROR) { + loadChart(currentState.selectedInterval) + } + + if (currentState.body is MarketsTokenDetailsUM.Body.Error || + currentState.body is MarketsTokenDetailsUM.Body.Nothing + ) { + loadInfo() + modelScope.loadQuotesWithTimer(QUOTES_UPDATE_INTERVAL_MILLIS) + } + } + + private fun onListedOnClick(exchangesCount: Int) { + modelScope.launch { + analyticsEventHandler.send(analyticsEventBuilder.exchangesScreenOpened()) + + showBottomSheet(content = ExchangesBottomSheetContent.Loading(exchangesCount)) + + val maybeExchanges = getTokenExchangesUseCase(tokenId = params.token.id) + + // Delay to show the bottom sheet + delay(timeMillis = 400L) + + updateExchangeBSContent(maybeExchanges = maybeExchanges, exchangesCount = exchangesCount) + } + } + + private fun updateExchangeBSContent( + maybeExchanges: Either>, + exchangesCount: Int, + ) { + val content = maybeExchanges + .fold( + ifLeft = { _ -> + ExchangesBottomSheetContent.Error(onRetryClick = { onListedOnClick(exchangesCount) }) + }, + ifRight = { list -> + ExchangesBottomSheetContent.Content( + exchangeItems = ExchangeItemStateConverter.convertList(list).toImmutableList(), + ) + }, + ) + + state.update { stateToUpdate -> + stateToUpdate.copy( + bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy(content = content), + ) + } + } + + private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) { + launch { + while (true) { + delay(timeMillis) + // Update quotes only when content is visible on the screen + isVisibleOnScreen.first { it } + + loadQuotes() + } + }.saveIn(quotesJob) + } + + private fun getDefaultDateTimeString(interval: PriceChangeInterval): TextReference { + return MarketsDateTimeFormatters.formatDateByInterval( + interval = interval, + startTimestamp = MarketsDateTimeFormatters.getStartTimestampByInterval( + interval = interval, + currentTimestamp = lastUpdatedTimestamp.value, + ), + ) + } + + private companion object { + const val QUOTES_UPDATE_INTERVAL_MILLIS = 60000L + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/analytics/MarketDetailsAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/analytics/MarketDetailsAnalyticsEvent.kt new file mode 100644 index 0000000000..dee935f3d9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/analytics/MarketDetailsAnalyticsEvent.kt @@ -0,0 +1,84 @@ +package com.tangem.features.feed.model.market.details.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.domain.markets.TokenMarketParams + +internal class MarketDetailsAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) { + + data class EventBuilder( + val token: TokenMarketParams, + ) { + fun screenOpened(blockchain: String?, source: String) = MarketDetailsAnalyticsEvent( + event = "Token Chart Screen Opened", + params = buildMap { + put("Token", token.symbol) + blockchain?.let { put("blockchain", it) } + put("Source", source) + }, + ) + + fun intervalChanged(intervalType: IntervalType, interval: PriceChangeInterval) = MarketDetailsAnalyticsEvent( + event = "Button - Period", + params = mapOf( + "Token" to token.symbol, + "Period" to interval.toAnalyticsString(), + "Source" to intervalType.source, + ), + ) + + fun readMoreClicked() = MarketDetailsAnalyticsEvent( + event = "Button - Read More", + params = mapOf( + "Token" to token.symbol, + ), + ) + + fun linkClicked(linkTitle: String) = MarketDetailsAnalyticsEvent( + event = "Button - Links", + params = mapOf( + "Token" to token.symbol, + "Link" to linkTitle, + ), + ) + + fun exchangesScreenOpened() = MarketDetailsAnalyticsEvent( + event = "Exchanges Screen Opened", + params = mapOf( + "Token" to token.symbol, + ), + ) + + fun securityScoreOpened() = MarketDetailsAnalyticsEvent( + event = "Security Score Info", + params = mapOf("Token" to token.symbol), + ) + + fun securityScoreProviderClicked(provider: String) = MarketDetailsAnalyticsEvent( + event = "Security Score Provider Clicked", + params = mapOf( + "Token" to token.symbol, + "Provider" to provider, + ), + ) + } + + enum class IntervalType(val source: String) { + Chart("Chart"), + PricePerformance("Price"), + Insights("Insights"), + } +} + +private fun PriceChangeInterval.toAnalyticsString() = when (this) { + PriceChangeInterval.H24 -> "24h" + PriceChangeInterval.WEEK -> "7d" + PriceChangeInterval.MONTH -> "1m" + PriceChangeInterval.MONTH3 -> "3m" + PriceChangeInterval.MONTH6 -> "6m" + PriceChangeInterval.YEAR -> "1y" + PriceChangeInterval.ALL_TIME -> "All" +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/DescriptionConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/DescriptionConverter.kt new file mode 100644 index 0000000000..0f1407141a --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/DescriptionConverter.kt @@ -0,0 +1,49 @@ +package com.tangem.features.feed.model.market.details.converter + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.state.InfoBottomSheetContent +import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter + +@Suppress("NestedScopeFunctions") +@Stable +internal class DescriptionConverter( + private val onReadModeClicked: (InfoBottomSheetContent) -> Unit, + private val onGeneratedAINotificationClick: () -> Unit, + private val needApplyFCARestrictions: Provider, +) : Converter { + + override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.Description? { + if (needApplyFCARestrictions()) return null + return value.shortDescription?.let { desc -> + MarketsTokenDetailsUM.Description( + shortDescription = stringReference(desc), + fullDescription = value.fullDescription?.let { fullDescription -> + stringReference(fullDescription) + }, + onReadMoreClick = { + onReadModeClicked( + InfoBottomSheetContent( + title = resourceReference( + R.string.markets_token_details_about_token_title, + wrappedList( + value.name, + ), + ), + body = stringReference(value.fullDescription.orEmpty()), + generatedAINotificationUM = InfoBottomSheetContent.GeneratedAINotificationUM( + onClick = onGeneratedAINotificationClick, + ), + ), + ) + }, + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/ExchangeItemStateConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/ExchangeItemStateConverter.kt new file mode 100644 index 0000000000..7727ce3162 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/ExchangeItemStateConverter.kt @@ -0,0 +1,68 @@ +package com.tangem.features.feed.model.market.details.converter + +import com.tangem.core.ui.components.audits.AuditLabelUM +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.price +import com.tangem.domain.markets.TokenMarketExchange +import com.tangem.domain.markets.TokenMarketExchange.TrustScore +import com.tangem.features.feed.impl.R +import com.tangem.utils.converter.Converter + +/** +* Converter from [TokenMarketExchange] to [TokenItemState] +* +[REDACTED_AUTHOR] +*/ +internal object ExchangeItemStateConverter : Converter { + + override fun convert(value: TokenMarketExchange): TokenItemState { + return TokenItemState.Content( + id = value.id, + iconState = CurrencyIconState.CoinIcon( + url = value.imageUrl, + fallbackResId = R.drawable.ic_alert_24, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + titleState = TokenItemState.TitleState.Content(text = stringReference(value.name)), + fiatAmountState = TokenItemState.FiatAmountState.Content( + text = value.volumeInUsd.format { + fiat( + fiatCurrencyCode = "USD", + fiatCurrencySymbol = "$", + ).price() + }, + ), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = stringReference(value = if (value.isCentralized) "CEX" else "DEX"), + ), + subtitle2State = TokenItemState.Subtitle2State.LabelContent( + auditLabelUM = value.trustScore.toAuditLabelUM(), + ), + onItemClick = null, + onItemLongClick = null, + ) + } + + private fun TrustScore.toAuditLabelUM(): AuditLabelUM { + return when (this) { + TrustScore.Risky -> AuditLabelUM( + text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_risky), + type = AuditLabelUM.Type.Prohibition, + ) + TrustScore.Caution -> AuditLabelUM( + text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_caution), + type = AuditLabelUM.Type.Warning, + ) + TrustScore.Trusted -> AuditLabelUM( + text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_trusted), + type = AuditLabelUM.Type.Permit, + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/InsightsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/InsightsConverter.kt new file mode 100644 index 0000000000..5c7158c85c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/InsightsConverter.kt @@ -0,0 +1,168 @@ +package com.tangem.features.feed.model.market.details.converter + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.compact +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.rawCompact +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.state.InfoBottomSheetContent +import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM +import com.tangem.features.feed.ui.market.detailed.state.InsightsUM +import com.tangem.utils.Provider +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +@Stable +internal class InsightsConverter( + private val appCurrency: Provider, + private val onInfoClick: (InfoBottomSheetContent) -> Unit, + private val onIntervalChanged: (PriceChangeInterval) -> Unit, +) : Converter { + + override fun convert(value: TokenMarketInfo.Insights): InsightsUM { + return with(value) { + InsightsUM( + h24Info = createInfoPointList( + experiencedBuyerChange = experiencedBuyerChange?.day, + holdersChange = holdersChange?.day, + liquidityChange = liquidityChange?.day, + buyPressureChange = buyPressureChange?.day, + ), + weekInfo = createInfoPointList( + experiencedBuyerChange = experiencedBuyerChange?.week, + holdersChange = holdersChange?.week, + liquidityChange = liquidityChange?.week, + buyPressureChange = buyPressureChange?.week, + ), + monthInfo = createInfoPointList( + experiencedBuyerChange = experiencedBuyerChange?.month, + holdersChange = holdersChange?.month, + liquidityChange = liquidityChange?.month, + buyPressureChange = buyPressureChange?.month, + ), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_insights), + body = resourceReference( + R.string.markets_insights_info_description_message, + wrappedList(value.sourceNetworks.joinToString { it.name }), + ), + ), + ) + }, + onIntervalChanged = onIntervalChanged, + ) + } + } + + private fun createInfoPointList( + experiencedBuyerChange: BigDecimal?, + holdersChange: BigDecimal?, + liquidityChange: BigDecimal?, + buyPressureChange: BigDecimal?, + ): ImmutableList { + return listOfNotNull( + experiencedBuyerChange?.let { + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = experiencedBuyerChange.convertChange(), + change = experiencedBuyerChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_experienced_buyers_full), + body = resourceReference(R.string.markets_token_details_experienced_buyers_description), + ), + ) + }, + ) + }, + buyPressureChange?.let { + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = buyPressureChange.convertChange(isFiatValue = true), + change = buyPressureChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_buy_pressure_full), + body = resourceReference(R.string.markets_token_details_buy_pressure_description), + ), + ) + }, + ) + }, + holdersChange?.let { + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = holdersChange.convertChange(), + change = holdersChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_holders_full), + body = resourceReference(R.string.markets_token_details_holders_description), + ), + ) + }, + ) + }, + liquidityChange?.let { + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = liquidityChange.convertChange(), + change = liquidityChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_liquidity_full), + body = resourceReference(R.string.markets_token_details_liquidity_description), + ), + ) + }, + ) + }, + ).toImmutableList() + } + + private fun BigDecimal.changeType(): InfoPointUM.ChangeType? { + return when { + this > BigDecimal.ZERO -> InfoPointUM.ChangeType.UP + this < BigDecimal.ZERO -> InfoPointUM.ChangeType.DOWN + else -> null + } + } + + private fun BigDecimal.convertChange(isFiatValue: Boolean = false): String { + val value = if (isFiatValue) { + this.abs().format { + val currency = appCurrency() + fiat( + fiatCurrencyCode = currency.code, + fiatCurrencySymbol = currency.symbol, + ).compact() + } + } else { + this.abs().format { + rawCompact() + } + } + + return when { + this > BigDecimal.ZERO -> StringsSigns.PLUS + value + this < BigDecimal.ZERO -> StringsSigns.MINUS + value + this == BigDecimal.ZERO -> value + else -> StringsSigns.DASH_SIGN + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/LinksConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/LinksConverter.kt new file mode 100644 index 0000000000..ad79878e17 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/LinksConverter.kt @@ -0,0 +1,48 @@ +package com.tangem.features.feed.model.market.details.converter + +import androidx.compose.runtime.Stable +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.state.LinksUM +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList + +@Stable +internal class LinksConverter( + private val onLinkClick: (LinksUM.Link) -> Unit, +) : Converter { + + override fun convert(value: TokenMarketInfo.Links): LinksUM { + return LinksUM( + officialLinks = value.officialLinks?.map { it.convert() }.orEmpty().toImmutableList(), + social = value.social?.map { it.convert() }.orEmpty().toImmutableList(), + repository = value.repository?.map { it.convert() }.orEmpty().toImmutableList(), + blockchainSite = value.blockchainSite?.map { it.convert() }.orEmpty().toImmutableList(), + onLinkClick = onLinkClick, + ) + } + + private fun TokenMarketInfo.Link.convert(): LinksUM.Link { + return LinksUM.Link( + title = title, + iconRes = getIconById(id), + url = link, + ) + } + + private fun getIconById(id: String?): Int { + return when (id) { + "linkedin" -> R.drawable.ic_linkedin_24 + "discord" -> R.drawable.ic_discord_24 + "youtube" -> R.drawable.ic_youtube_24 + "telegram" -> R.drawable.ic_telegram_24 + "github" -> R.drawable.ic_github_24 + "twitter" -> R.drawable.ic_twitter_24 + "facebook" -> R.drawable.ic_facebook_24 + "reddit" -> R.drawable.ic_reddit_24 + "instagram" -> R.drawable.ic_instagram_24 + "whitepaper" -> R.drawable.ic_doc_24 + else -> R.drawable.ic_arrow_top_right_24 + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/MetricsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/MetricsConverter.kt new file mode 100644 index 0000000000..e849e636cf --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/MetricsConverter.kt @@ -0,0 +1,152 @@ +package com.tangem.features.feed.model.market.details.converter + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.format.bigdecimal.compact +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.state.InfoBottomSheetContent +import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM +import com.tangem.features.feed.ui.market.detailed.state.MetricsUM +import com.tangem.utils.Provider +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal + +@Stable +internal class MetricsConverter( + private val appCurrency: Provider, + private val tokenSymbol: String, + private val onInfoClick: (InfoBottomSheetContent) -> Unit, +) : Converter { + + @Suppress("LongMethod") + override fun convert(value: TokenMarketInfo.Metrics): MetricsUM { + return with(value) { + MetricsUM( + metrics = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_market_capitalization), + value = marketCap.formatAmount(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference( + R.string.markets_token_details_market_capitalization_full, + ), + body = resourceReference( + R.string.markets_token_details_market_capitalization_description, + ), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_market_rating), + value = marketRating?.toString() ?: StringsSigns.DASH_SIGN, + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_market_rating_full), + body = resourceReference(R.string.markets_token_details_market_rating_description), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_trading_volume), + value = volume24h.formatAmount(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_trading_volume_full), + body = resourceReference( + R.string.markets_token_details_trading_volume_24h_description, + ), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_fully_diluted_valuation), + value = fullyDilutedValuation.formatAmount(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference( + R.string.markets_token_details_fully_diluted_valuation_full, + ), + body = resourceReference( + R.string.markets_token_details_fully_diluted_valuation_description, + ), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_circulating_supply), + value = circulatingSupply.formatAmount(crypto = true), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_circulating_supply_full), + body = resourceReference( + R.string.markets_token_details_circulating_supply_description, + ), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_max_supply), + value = maxSupply.formatMaxSupply(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_max_supply_full), + body = resourceReference(R.string.markets_token_details_total_supply_description), + ), + ) + }, + ), + ), + ) + } + } + + private fun BigDecimal?.formatMaxSupply(): String { + when (this) { + null -> return StringsSigns.DASH_SIGN + BigDecimal.ZERO -> return StringsSigns.INFINITY_SIGN + } + + return this.formatAmount(crypto = true) + } + + private fun BigDecimal?.formatAmount(crypto: Boolean = false): String { + if (this == null) return StringsSigns.DASH_SIGN + + return if (crypto) { + format { + crypto( + symbol = tokenSymbol, + decimals = 2, + ).compact() + } + } else { + val currency = appCurrency() + + format { + fiat( + fiatCurrencyCode = currency.code, + fiatCurrencySymbol = currency.symbol, + ).compact() + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/PricePerformanceConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/PricePerformanceConverter.kt new file mode 100644 index 0000000000..aed0754f55 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/PricePerformanceConverter.kt @@ -0,0 +1,71 @@ +package com.tangem.features.feed.model.market.details.converter + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.price +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.feed.ui.market.detailed.state.PricePerformanceUM +import com.tangem.utils.Provider +import com.tangem.utils.StringsSigns +import java.math.BigDecimal +import java.math.RoundingMode + +@Stable +internal class PricePerformanceConverter( + private val appCurrency: Provider, + private val onIntervalChanged: (PriceChangeInterval) -> Unit, +) { + + fun convert(value: TokenMarketInfo.PricePerformance, currentPrice: BigDecimal): PricePerformanceUM { + return PricePerformanceUM( + h24 = value.day.convert(currentPrice), + month = value.month.convert(currentPrice), + all = value.allTime.convert(currentPrice), + onIntervalChanged = onIntervalChanged, + ) + } + + private fun TokenMarketInfo.Range?.convert(currentPrice: BigDecimal): PricePerformanceUM.Value { + if (this == null || this.low == null || this.high == null) { + return PricePerformanceUM.Value( + low = StringsSigns.DASH_SIGN, + high = StringsSigns.DASH_SIGN, + indicatorFraction = 0f, + ) + } + + return PricePerformanceUM.Value( + low = low.convert(), + high = high.convert(), + indicatorFraction = calculateFraction(currentPrice), + ) + } + + private fun BigDecimal?.convert(): String { + val currency = appCurrency() + + return format { + fiat( + fiatCurrencyCode = currency.code, + fiatCurrencySymbol = currency.symbol, + ).price() + } + } + + private fun TokenMarketInfo.Range.calculateFraction(currentPrice: BigDecimal): Float { + val currentLow = low ?: return 0f + val currentHigh = high ?: return 0f + return when { + high == BigDecimal.ZERO || currentPrice < low -> 0f + currentPrice > high || low == high -> 1f + else -> { + (currentPrice - currentLow).divide(currentHigh - currentLow, RoundingMode.HALF_UP) + .setScale(2, RoundingMode.HALF_UP) + .toFloat().coerceAtMost(1f) + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/SecurityScoreConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/SecurityScoreConverter.kt new file mode 100644 index 0000000000..851ea95456 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/SecurityScoreConverter.kt @@ -0,0 +1,56 @@ +package com.tangem.features.feed.model.market.details.converter + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.model.market.details.formatter.MarketsDateTimeFormatters +import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreBottomSheetContent +import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreUM +import com.tangem.utils.converter.Converter + +@Stable +internal class SecurityScoreConverter( + private val onSecurityScoreInfoClick: (SecurityScoreBottomSheetContent) -> Unit, + private val onSecurityScoreProviderLinkClick: (SecurityScoreBottomSheetContent.SecurityScoreProviderUM) -> Unit, +) : Converter { + + override fun convert(value: TokenMarketInfo.SecurityData): SecurityScoreUM { + val ratingsCount = value.securityScoreProviderData.size + return SecurityScoreUM( + score = value.totalSecurityScore, + description = pluralReference( + id = R.plurals.markets_token_details_based_on_ratings, + count = ratingsCount, + formatArgs = wrappedList(ratingsCount), + ), + onInfoClick = { + onSecurityScoreInfoClick( + SecurityScoreBottomSheetContent( + title = resourceReference(R.string.markets_token_details_security_score), + description = resourceReference(R.string.markets_token_details_security_score_description), + providers = value.securityScoreProviderData.map { securityScoreProvider -> + SecurityScoreBottomSheetContent.SecurityScoreProviderUM( + name = securityScoreProvider.providerName, + lastAuditDate = securityScoreProvider.lastAuditDate?.let { date -> + MarketsDateTimeFormatters.formatAsDate(date.millis) + }, + score = securityScoreProvider.securityScore, + urlData = securityScoreProvider.urlData?.let { urlData -> + SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData( + fullUrl = urlData.fullUrl, + rootHost = urlData.rootHost, + ) + }, + iconUrl = securityScoreProvider.iconUrl, + ) + }, + onProviderLinkClick = onSecurityScoreProviderLinkClick, + ), + ) + }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/TokenMarketInfoConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/TokenMarketInfoConverter.kt new file mode 100644 index 0000000000..b0c2eddb35 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/TokenMarketInfoConverter.kt @@ -0,0 +1,81 @@ +package com.tangem.features.feed.model.market.details.converter + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.feed.ui.market.detailed.state.LinksUM +import com.tangem.features.feed.ui.market.detailed.state.ListedOnUM +import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM +import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreBottomSheetContent +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter + +@Stable +@Suppress("LongParameterList") +internal class TokenMarketInfoConverter( + private val appCurrency: Provider, + private val needApplyFCARestrictions: Provider, + private val onInfoClick: (TangemBottomSheetConfigContent) -> Unit, + private val onListedOnClick: (Int) -> Unit, + onSecurityScoreInfoClick: (SecurityScoreBottomSheetContent) -> Unit, + onLinkClick: (LinksUM.Link) -> Unit, + onSecurityScoreProviderLinkClick: (SecurityScoreBottomSheetContent.SecurityScoreProviderUM) -> Unit, + onPricePerformanceIntervalChanged: (PriceChangeInterval) -> Unit, + onInsightsIntervalChanged: (PriceChangeInterval) -> Unit, +) : Converter { + + private val insightsConverter = InsightsConverter( + appCurrency = appCurrency, + onInfoClick = onInfoClick, + onIntervalChanged = onInsightsIntervalChanged, + ) + + private val securityScoreConverter = SecurityScoreConverter( + onSecurityScoreInfoClick = onSecurityScoreInfoClick, + onSecurityScoreProviderLinkClick = onSecurityScoreProviderLinkClick, + ) + private val pricePerformanceConverter = PricePerformanceConverter( + appCurrency = appCurrency, + onIntervalChanged = onPricePerformanceIntervalChanged, + ) + private val linksConverter = LinksConverter(onLinkClick = onLinkClick) + + override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.InformationBlocks { + val metricsConverter = MetricsConverter( + tokenSymbol = value.symbol, + appCurrency = appCurrency, + onInfoClick = onInfoClick, + ) + + val exchangesAmount = value.exchangesAmount + val insights = if (needApplyFCARestrictions()) { + null + } else { + value.insights?.let { insightsConverter.convert(it) } + } + val securityScore = if (needApplyFCARestrictions()) { + null + } else { + value.securityData?.let { securityScoreConverter.convert(it) } + } + return MarketsTokenDetailsUM.InformationBlocks( + insights = insights, + securityScore = securityScore, + metrics = value.metrics?.let { metricsConverter.convert(it) }, + pricePerformance = value.pricePerformance?.let { pricePerformance -> + pricePerformanceConverter.convert( + value = pricePerformance, + currentPrice = value.quotes.currentPrice, + ) + }, + listedOn = if (exchangesAmount != null && exchangesAmount > 0) { + ListedOnUM.Content(onClick = { onListedOnClick(exchangesAmount) }, amount = exchangesAmount) + } else { + ListedOnUM.Empty + }, + links = value.links?.let { linksConverter.convert(it) }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/formatter/Formatters.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/formatter/Formatters.kt new file mode 100644 index 0000000000..218580167e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/formatter/Formatters.kt @@ -0,0 +1,76 @@ +package com.tangem.features.feed.model.market.details.formatter + +import com.tangem.common.ui.charts.state.MarketChartLook +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.getFiatPriceAmountWithScale +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.domain.markets.TokenQuotes +import java.math.BigDecimal +import java.math.RoundingMode + +internal fun TokenQuotes.getFormattedPercentByInterval(interval: PriceChangeInterval): String { + val percent = when (interval) { + PriceChangeInterval.H24 -> h24ChangePercent + PriceChangeInterval.WEEK -> weekChangePercent + PriceChangeInterval.MONTH -> monthChangePercent + PriceChangeInterval.MONTH3 -> m3ChangePercent + PriceChangeInterval.MONTH6 -> m6ChangePercent + PriceChangeInterval.YEAR -> yearChangePercent + PriceChangeInterval.ALL_TIME -> allTimeChangePercent + } + + return percent?.format { percent() }.orEmpty() +} + +internal fun TokenQuotes.getPercentByInterval(interval: PriceChangeInterval): BigDecimal? { + return when (interval) { + PriceChangeInterval.H24 -> h24ChangePercent + PriceChangeInterval.WEEK -> weekChangePercent + PriceChangeInterval.MONTH -> monthChangePercent + PriceChangeInterval.MONTH3 -> m3ChangePercent + PriceChangeInterval.MONTH6 -> m6ChangePercent + PriceChangeInterval.YEAR -> yearChangePercent + PriceChangeInterval.ALL_TIME -> allTimeChangePercent + } +} + +@Suppress("MagicNumber") +internal fun BigDecimal?.percentChangeType(): PriceChangeType { + val scaled = this?.setScale(4, RoundingMode.HALF_UP) + return when { + scaled == null -> PriceChangeType.NEUTRAL + scaled > BigDecimal.ZERO -> PriceChangeType.UP + scaled < BigDecimal.ZERO -> PriceChangeType.DOWN + else -> PriceChangeType.NEUTRAL + } +} + +@Suppress("MagicNumber") +internal fun getChangePercentBetween(currentPrice: BigDecimal, previousPrice: BigDecimal): BigDecimal { + return if (previousPrice == BigDecimal.ZERO) { + BigDecimal.ZERO + } else { + currentPrice.subtract(previousPrice).divide(previousPrice, 4, RoundingMode.HALF_UP) + } +} + +internal fun getFormattedPriceChange(currentPrice: BigDecimal, updatedPrice: BigDecimal): PriceChangeType { + val current = getFiatPriceAmountWithScale(value = currentPrice).first + val updated = getFiatPriceAmountWithScale(value = updatedPrice).first + + return when { + updated > current -> PriceChangeType.UP + updated < current -> PriceChangeType.DOWN + else -> PriceChangeType.NEUTRAL + } +} + +internal fun PriceChangeType.toChartType(): MarketChartLook.Type { + return when (this) { + PriceChangeType.UP -> MarketChartLook.Type.Growing + PriceChangeType.DOWN -> MarketChartLook.Type.Falling + PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/formatter/MarketsDateTimeFormatters.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/formatter/MarketsDateTimeFormatters.kt new file mode 100644 index 0000000000..f785be2a53 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/formatter/MarketsDateTimeFormatters.kt @@ -0,0 +1,140 @@ +package com.tangem.features.feed.model.market.details.formatter + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.core.ui.utils.formatAsDateTime +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.features.feed.impl.R +import com.tangem.utils.H24_MILLIS +import com.tangem.utils.WEEK_MILLIS +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import java.math.BigDecimal + +internal object MarketsDateTimeFormatters { + + private val dateTimeMMMFormatter by lazy { + DateTimeFormatters.getBestFormatterBySkeleton("dd MMM Hm") + } + + private val dateFormatter = DateTimeFormatters.dateDDMMYYYY + + fun getChartXFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String { + return when (interval) { + PriceChangeInterval.H24 -> { value: BigDecimal -> + value.toLong().formatAsDateTime(DateTimeFormatters.timeFormatter) + } + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + PriceChangeInterval.MONTH6, + -> { value -> + value.toLong().formatAsDateTime(DateTimeFormatters.dateMMMdd) + } + PriceChangeInterval.YEAR -> { value -> + value.toLong().formatAsDateTime(DateTimeFormatters.dateMMMdd) + } + PriceChangeInterval.ALL_TIME -> { value -> + value.toLong().formatAsDateTime(DateTimeFormatters.dateYYYY) + } + } + } + + fun formatDateByInterval(interval: PriceChangeInterval, startTimestamp: Long): TextReference { + return when (interval) { + PriceChangeInterval.H24 -> resourceReference(R.string.common_today) + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + -> { + resourceReference( + R.string.common_range_with_space, + wrappedList( + stringReference( + startTimestamp.formatAsDateTime(dateTimeMMMFormatter), + ), + resourceReference(R.string.common_now), + ), + ) + } + PriceChangeInterval.MONTH6, + PriceChangeInterval.YEAR, + -> { + resourceReference( + R.string.common_range_with_space, + wrappedList( + stringReference( + startTimestamp.formatAsDateTime(dateFormatter), + ), + resourceReference(R.string.common_now), + ), + ) + } + PriceChangeInterval.ALL_TIME -> resourceReference(R.string.common_all) + } + } + + fun formatDateByIntervalWithMarker(interval: PriceChangeInterval, markerTimestamp: BigDecimal): TextReference { + return when (interval) { + PriceChangeInterval.H24, + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + -> { + resourceReference( + R.string.common_range_with_space, + wrappedList( + stringReference( + markerTimestamp.toLong().formatAsDateTime(dateTimeMMMFormatter), + ), + resourceReference(R.string.common_now), + ), + ) + } + PriceChangeInterval.MONTH6, + PriceChangeInterval.YEAR, + PriceChangeInterval.ALL_TIME, + -> { + resourceReference( + R.string.common_range_with_space, + wrappedList( + stringReference( + markerTimestamp.toLong().formatAsDateTime(dateFormatter), + ), + resourceReference(R.string.common_now), + ), + ) + } + } + } + + @Suppress("MagicNumber") + fun getStartTimestampByInterval(interval: PriceChangeInterval, currentTimestamp: Long): Long { + return when (interval) { + PriceChangeInterval.H24 -> currentTimestamp - H24_MILLIS + PriceChangeInterval.WEEK -> currentTimestamp - WEEK_MILLIS + PriceChangeInterval.MONTH -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(1).millis + PriceChangeInterval.MONTH3 -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(3).millis + PriceChangeInterval.MONTH6 -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(6).millis + PriceChangeInterval.YEAR -> DateTime(currentTimestamp, DateTimeZone.UTC).minusYears(1).millis + PriceChangeInterval.ALL_TIME -> 0 + } + } + + fun getDefaultDateTimeString(interval: PriceChangeInterval, currentTimestamp: Long): TextReference { + return formatDateByInterval( + interval = interval, + startTimestamp = getStartTimestampByInterval( + interval = interval, + currentTimestamp = currentTimestamp, + ), + ) + } + + fun formatAsDate(timestamp: Long): String { + return timestamp.formatAsDateTime(dateFormatter) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/state/QuotesStateUpdater.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/state/QuotesStateUpdater.kt new file mode 100644 index 0000000000..8ea1c11938 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/state/QuotesStateUpdater.kt @@ -0,0 +1,96 @@ +package com.tangem.features.feed.model.market.details.state + +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.event.triggeredEvent +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.price +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenQuotes +import com.tangem.features.feed.model.market.details.converter.PricePerformanceConverter +import com.tangem.features.feed.model.market.details.formatter.* +import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM +import com.tangem.utils.Provider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import org.joda.time.DateTime +import java.math.BigDecimal + +internal class QuotesStateUpdater( + private val currentAppCurrency: Provider, + private val state: MutableStateFlow, + private val currentQuotes: MutableStateFlow, + private val lastUpdatedTimestamp: MutableStateFlow, + private val currentTokenInfo: MutableStateFlow, + private val onPricePerformanceIntervalChanged: (PriceChangeInterval) -> Unit, +) { + private val pricePerformanceConverter = PricePerformanceConverter( + currentAppCurrency, + onIntervalChanged = onPricePerformanceIntervalChanged, + ) + + suspend fun updateQuotes(newQuotes: TokenQuotes) { + val triggerPriceChangeType = getFormattedPriceChange( + currentPrice = currentQuotes.value.currentPrice, + updatedPrice = newQuotes.currentPrice, + ) + val trigger = if (triggerPriceChangeType != PriceChangeType.NEUTRAL) { + triggeredEvent( + data = triggerPriceChangeType, + onConsume = { + state.update { it.copy(triggerPriceChange = consumedEvent()) } + }, + ) + } else { + consumedEvent() + } + + val percent = newQuotes.getPercentByInterval(interval = state.value.selectedInterval) + val priceChangeType = percent.percentChangeType() + + // wait until marker is removed + state.first { it.isMarkerSet.not() } + + currentQuotes.value = newQuotes + lastUpdatedTimestamp.value = DateTime.now().millis + + state.update { stateToUpdate -> + stateToUpdate.copy( + priceText = newQuotes.currentPrice.format { + fiat( + fiatCurrencySymbol = currentAppCurrency().symbol, + fiatCurrencyCode = currentAppCurrency().code, + ).price() + }, + priceChangePercentText = newQuotes.getFormattedPercentByInterval( + interval = stateToUpdate.selectedInterval, + ), + priceChangeType = priceChangeType, + triggerPriceChange = trigger, + dateTimeText = MarketsDateTimeFormatters.getDefaultDateTimeString( + stateToUpdate.selectedInterval, + currentTimestamp = lastUpdatedTimestamp.value, + ), + body = stateToUpdate.body.updatePricePerformance(newQuotes.currentPrice), + ) + } + } + + private fun MarketsTokenDetailsUM.Body.updatePricePerformance(price: BigDecimal): MarketsTokenDetailsUM.Body { + val currentPricePerformance = currentTokenInfo.value?.pricePerformance ?: return this + + return if (this is MarketsTokenDetailsUM.Body.Content) { + copy( + infoBlocks = infoBlocks.copy( + pricePerformance = pricePerformanceConverter.convert(currentPricePerformance, price), + ), + ) + } else { + this + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/state/TokenNetworksState.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/state/TokenNetworksState.kt new file mode 100644 index 0000000000..eab03458ef --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/state/TokenNetworksState.kt @@ -0,0 +1,12 @@ +package com.tangem.features.feed.model.market.details.state + +import com.tangem.domain.markets.TokenMarketInfo + +internal sealed class TokenNetworksState { + + data object Loading : TokenNetworksState() + + data object NoNetworksAvailable : TokenNetworksState() + + data class NetworksAvailable(val networks: List) : TokenNetworksState() +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt index f7ae1d61d4..19e70cf231 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt @@ -7,6 +7,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler 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.components.bottomsheets.state.BottomSheetState import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase @@ -19,10 +20,10 @@ import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListCo import com.tangem.features.feed.model.market.list.analytics.MarketsListAnalyticsEvent import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager import com.tangem.features.feed.model.market.list.statemanager.MarketsListUMStateManager -import com.tangem.features.feed.ui.market.list.state.ListUM -import com.tangem.features.feed.ui.market.list.state.MarketsListUM -import com.tangem.features.feed.ui.market.list.state.MarketsNotificationUM -import com.tangem.features.feed.ui.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.state.ListUM +import com.tangem.features.feed.model.market.list.state.MarketsListUM +import com.tangem.features.feed.model.market.list.state.MarketsNotificationUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder @@ -105,6 +106,7 @@ internal class MarketsListModel @Inject constructor( private var activeListManager: MarketsListBatchFlowManager = mainMarketsListManager + val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED) val isVisibleOnScreen = MutableStateFlow(false) val state = marketsListUMStateManager.state.asStateFlow() @@ -289,6 +291,12 @@ internal class MarketsListModel @Inject constructor( } private fun initAnalytics() { + containerBottomSheetState.onEach { bottomSheetState -> + if (bottomSheetState == BottomSheetState.EXPANDED) { + analyticsEventHandler.send(MarketsListAnalyticsEvent.BottomSheetOpened()) + } + }.launchIn(modelScope) + state.filter { it.isInSearchMode.not() } .map { MarketsListAnalyticsEvent.SortBy(it.selectedSortBy, it.selectedInterval) }.distinctUntilChanged() .onEach { @@ -308,6 +316,9 @@ internal class MarketsListModel @Inject constructor( launch { while (true) { delay(timeMillis) + // Update quotes only when the container bottom sheet is in the expanded state + containerBottomSheetState.first { it == BottomSheetState.EXPANDED } + // and is visible on the screen isVisibleOnScreen.first { it } activeListManager.updateQuotes() } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/analytics/MarketsListAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/analytics/MarketsListAnalyticsEvent.kt index de0d59ab8c..2bd7212326 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/analytics/MarketsListAnalyticsEvent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/analytics/MarketsListAnalyticsEvent.kt @@ -1,14 +1,16 @@ package com.tangem.features.feed.model.market.list.analytics import com.tangem.core.analytics.models.AnalyticsEvent -import com.tangem.features.feed.ui.market.list.state.MarketsListUM -import com.tangem.features.feed.ui.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.state.MarketsListUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM internal sealed class MarketsListAnalyticsEvent( event: String, params: Map = emptyMap(), ) : AnalyticsEvent(category = "Markets", event = event, params = params) { + class BottomSheetOpened : MarketsListAnalyticsEvent(event = "Markets Screen Opened") + data class SortBy( val sortByTypeUM: SortByTypeUM, val interval: MarketsListUM.TrendInterval, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/MarketsListUM.kt similarity index 97% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsListUM.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/MarketsListUM.kt index 9ae4f28516..9529c3db53 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/MarketsListUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.ui.market.list.state +package com.tangem.features.feed.model.market.list.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.markets.models.MarketsListItemUM diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsNotificationUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/MarketsNotificationUM.kt similarity index 93% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsNotificationUM.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/MarketsNotificationUM.kt index 424060c994..f51b1dd0fa 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/MarketsNotificationUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/MarketsNotificationUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.ui.market.list.state +package com.tangem.features.feed.model.market.list.state import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/SortByBottomSheetContentUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/SortByBottomSheetContentUM.kt similarity index 81% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/SortByBottomSheetContentUM.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/SortByBottomSheetContentUM.kt index 947ee23844..2b279aa592 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/state/SortByBottomSheetContentUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/SortByBottomSheetContentUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.ui.market.list.state +package com.tangem.features.feed.model.market.list.state import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt index 4bf209e2e0..c4632a41af 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt @@ -8,8 +8,8 @@ import com.tangem.features.feed.model.converter.MarketsTokenItemConverter import com.tangem.features.feed.model.market.list.utils.logAction import com.tangem.features.feed.model.market.list.utils.logStatus import com.tangem.features.feed.model.market.list.utils.logUpdateResults -import com.tangem.features.feed.ui.market.list.state.MarketsListUM -import com.tangem.features.feed.ui.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.state.MarketsListUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.pagination.Batch import com.tangem.pagination.BatchAction import com.tangem.pagination.BatchFetchResult diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt index 2e269facb3..92e8aeafea 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt @@ -9,7 +9,12 @@ import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.feed.impl.R -import com.tangem.features.feed.ui.market.list.state.* +import com.tangem.features.feed.model.market.list.state.ListUM +import com.tangem.features.feed.model.market.list.state.MarketsListUM +import com.tangem.features.feed.model.market.list.state.MarketsNotificationUM +import com.tangem.features.feed.model.market.list.state.MarketsSearchBar +import com.tangem.features.feed.model.market.list.state.SortByBottomSheetContentUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.utils.Provider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryBottomSheetContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryBottomSheetContent.kt deleted file mode 100644 index a94ee42c3c..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryBottomSheetContent.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.tangem.features.feed.ui - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.arkivanov.decompose.router.stack.ChildStack -import com.tangem.core.ui.decompose.ComposableModularContentComponent -import com.tangem.core.ui.res.LocalMainBottomSheetColor -import com.tangem.features.feed.components.FeedEntryChildFactory - -@Composable -internal fun EntryBottomSheetContent( - stackState: ChildStack, - onHeaderSizeChange: (Dp) -> Unit, -) { - val density = LocalDensity.current - val background = LocalMainBottomSheetColor.current.value - - Scaffold( - containerColor = background, - contentWindowInsets = WindowInsets(0.dp), - topBar = { - AnimatedContent( - targetState = stackState.active.instance, - modifier = Modifier.onGloballyPositioned { coordinates -> - if (coordinates.size.height > 0) { - with(density) { - onHeaderSizeChange(coordinates.size.height.toDp()) - } - } - }, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - ) { currentState -> - currentState.Title() - } - }, - content = { contentPadding -> - AnimatedContent( - targetState = stackState.active.instance, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - ) { currentState -> - currentState.Content(modifier = Modifier.padding(contentPadding)) - } - }, - ) -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt new file mode 100644 index 0000000000..950630f411 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt @@ -0,0 +1,75 @@ +package com.tangem.features.feed.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +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.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.utils.WindowInsetsZero +import com.tangem.features.feed.components.FeedEntryChildFactory + +@Composable +internal fun EntryContent( + bottomSheetState: State, + stackState: State>, + onHeaderSizeChange: (Dp) -> Unit, + isOpenedInBottomSheet: Boolean, +) { + val density = LocalDensity.current + val background = LocalMainBottomSheetColor.current.value + + Surface(contentColor = background) { + Scaffold( + containerColor = background, + contentWindowInsets = WindowInsetsZero, + topBar = { + AnimatedContent( + targetState = stackState.value.active.instance, + modifier = Modifier + .then( + if (!isOpenedInBottomSheet) { + Modifier.statusBarsPadding() + } else { + Modifier + }, + ) + .onGloballyPositioned { coordinates -> + if (coordinates.size.height > 0) { + with(density) { + onHeaderSizeChange(coordinates.size.height.toDp()) + } + } + }, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + ) { currentState -> currentState.Title(bottomSheetState) } + }, + content = { contentPadding -> + Children( + stack = stackState.value, + animation = stackAnimation(fade()), + ) { child -> + child.instance.Content( + modifier = Modifier.padding(contentPadding), + bottomSheetState = bottomSheetState, + ) + } + }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index 4cc64dcbba..e1362d244a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -51,7 +51,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState import com.tangem.features.feed.ui.feed.state.* -import com.tangem.features.feed.ui.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM @Composable internal fun FeedListHeader(feedListSearchBar: FeedListSearchBar, modifier: Modifier = Modifier) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt index 50b22f6f46..8b084d229a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt @@ -10,7 +10,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.feed.ui.feed.state.* -import com.tangem.features.feed.ui.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import kotlinx.collections.immutable.* @Suppress("MagicNumber") diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt index 3c05e90f30..15e71f5a11 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.core.ui.extensions.TextReference -import com.tangem.features.feed.ui.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toPersistentList diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt index 7d975bc782..6c2014aedf 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt @@ -5,8 +5,8 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.feed.model.converter.MarketsTokenItemConverter -import com.tangem.features.feed.ui.market.list.state.MarketsListUM -import com.tangem.features.feed.ui.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.state.MarketsListUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.pagination.Batch import com.tangem.pagination.BatchAction import com.tangem.pagination.BatchFetchResult 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 new file mode 100644 index 0000000000..4773bac517 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt @@ -0,0 +1,330 @@ +package com.tangem.features.feed.ui.market.detailed + +import android.content.res.Configuration +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SpacerH32 +import com.tangem.core.ui.components.SpacerH4 +import com.tangem.core.ui.components.SpacerW4 +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.components.currency.icon.CoinIcon +import com.tangem.core.ui.components.marketprice.PriceChangeInPercent +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.event.EventEffect +import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.components.* +import com.tangem.features.feed.ui.market.detailed.preview.MarketsTokenDetailsPreview +import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent +import com.tangem.features.feed.ui.market.detailed.state.InfoBottomSheetContent +import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM +import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreBottomSheetContent +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.distinctUntilChanged + +@Suppress("LongParameterList") +@Composable +internal fun MarketsTokenDetailsContent( + state: MarketsTokenDetailsUM, + backgroundColor: Color, + isAccountEnabled: Boolean, + modifier: Modifier = Modifier, + portfolioBlock: @Composable ((Modifier) -> Unit)?, +) { + Content( + modifier = modifier, + backgroundColor = backgroundColor, + state = state, + portfolioBlock = portfolioBlock, + isAccountEnabled = isAccountEnabled, + ) + + when (state.bottomSheetConfig.content) { + is InfoBottomSheetContent -> InfoBottomSheet(config = state.bottomSheetConfig) + is SecurityScoreBottomSheetContent -> SecurityScoreBottomSheet(config = state.bottomSheetConfig) + is ExchangesBottomSheetContent -> ExchangesBottomSheet(config = state.bottomSheetConfig) + } +} + +@Suppress("LongParameterList") +@Composable +private fun Content( + state: MarketsTokenDetailsUM, + backgroundColor: Color, + isAccountEnabled: Boolean, + modifier: Modifier = Modifier, + portfolioBlock: @Composable ((Modifier) -> Unit)?, +) { + val density = LocalDensity.current + val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } + val lazyListState = rememberLazyListState() + ShowPriceSubtitleEffect( + lazyListState = lazyListState, + onShouldShowPriceSubtitleChange = state.onShouldShowPriceSubtitleChange, + ) + + Column( + modifier = modifier + .drawBehind { drawRect(backgroundColor) } + .fillMaxSize(), + ) { + SpacerH4() + + LazyColumn( + state = lazyListState, + contentPadding = PaddingValues(bottom = bottomBarHeight), + ) { + item("header") { + Header( + state = state, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + ) + } + item { SpacerH16() } + item("intervalSelector") { + IntervalSelector( + trendInterval = state.selectedInterval, + onIntervalClick = state.onSelectedIntervalChange, + isEnabled = state.body !is MarketsTokenDetailsUM.Body.Nothing, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + ) + } + item { SpacerH32() } + item("chart") { + MarketTokenDetailsChart( + modifier = Modifier.fillMaxWidth(), + backgroundColor = backgroundColor, + state = state.chartState, + ) + } + item { SpacerH16() } + + tokenMarketDetailsBody( + state = state.body, + isAccountEnabled = isAccountEnabled, + portfolioBlock = portfolioBlock, + ) + } + } +} + +@Suppress("LongParameterList") +@Composable +internal fun MarketsTokenDetailsTopBar( + backgroundColor: Color, + shouldShowPriceSubtitle: Boolean, + tokenName: String, + tokenPrice: String, + isBackButtonEnabled: Boolean, + onBackClick: () -> Unit, +) { + TangemTopAppBar( + modifier = Modifier.drawBehind { drawRect(backgroundColor) }, + title = tokenName, + subtitle = if (shouldShowPriceSubtitle) tokenPrice else null, + startButton = TopAppBarButtonUM.Back( + onBackClicked = onBackClick, + enabled = isBackButtonEnabled, + ), + ) +} + +@Composable +private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + TokenPriceText( + price = state.priceText, + triggerPriceChange = state.triggerPriceChange, + ) + Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) { + Text( + text = state.dateTimeText.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + if (state.priceChangePercentText != null) { + PriceChangeInPercent( + valueInPercent = state.priceChangePercentText, + type = state.priceChangeType, + textStyle = TangemTheme.typography.caption2, + ) + } + } + } + SpacerW4() + CoinIcon( + modifier = Modifier.requiredSize(TangemTheme.dimens.size48), + url = state.iconUrl, + alpha = 1f, + colorFilter = null, + fallbackResId = R.drawable.ic_custom_token_44, + ) + } +} + +@Composable +private fun TokenPriceText( + price: String, + triggerPriceChange: StateEvent, + modifier: Modifier = Modifier, +) { + val growColor = TangemTheme.colors.text.accent + val fallColor = TangemTheme.colors.text.warning + val generalColor = TangemTheme.colors.text.primary1 + + val color = remember(generalColor) { Animatable(generalColor) } + + EventEffect(triggerPriceChange) { priceChangeType -> + val nextColor = when (priceChangeType) { + PriceChangeType.UP, + -> growColor + PriceChangeType.DOWN -> fallColor + PriceChangeType.NEUTRAL -> return@EventEffect + } + + color.animateTo(nextColor, snap()) + color.animateTo(generalColor, tween(durationMillis = 500)) + } + + Text( + text = price, + modifier = modifier, + color = color.value, + autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography.head.fontSize), + maxLines = 1, + style = TangemTheme.typography.head, + ) +} + +@Composable +private fun IntervalSelector( + trendInterval: PriceChangeInterval, + isEnabled: Boolean, + onIntervalClick: (PriceChangeInterval) -> Unit, + modifier: Modifier = Modifier, +) { + SegmentedButtons( + config = persistentListOf( + PriceChangeInterval.H24, + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + PriceChangeInterval.MONTH6, + PriceChangeInterval.YEAR, + PriceChangeInterval.ALL_TIME, + ), + color = TangemTheme.colors.button.secondary, + initialSelectedItem = trendInterval, + onClick = onIntervalClick, + isEnabled = isEnabled, + modifier = modifier, + ) { + Box( + Modifier + .fillMaxSize() + .align(Alignment.Center) + .padding( + vertical = TangemTheme.dimens.spacing4, + ), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = it.getText().resolveReference(), + style = TangemTheme.typography.caption1, + color = if (isEnabled) { + TangemTheme.colors.text.primary1 + } else { + TangemTheme.colors.text.disabled + }, + ) + } + } +} + +@Composable +private fun ShowPriceSubtitleEffect(lazyListState: LazyListState, onShouldShowPriceSubtitleChange: (Boolean) -> Unit) { + val showPriceSubtitleFlow = remember(lazyListState) { + snapshotFlow { lazyListState.firstVisibleItemIndex > 1 } + .distinctUntilChanged() + } + LaunchedEffect(showPriceSubtitleFlow) { + showPriceSubtitleFlow.collect { isVisible -> + onShouldShowPriceSubtitleChange(isVisible) + } + } +} + +@Composable +fun PriceChangeInterval.getText(): TextReference { + return when (this) { + PriceChangeInterval.H24 -> resourceReference(R.string.markets_selector_interval_24h_title) + PriceChangeInterval.WEEK -> resourceReference(R.string.markets_selector_interval_7d_title) + PriceChangeInterval.MONTH -> resourceReference(R.string.markets_selector_interval_1m_title) + PriceChangeInterval.MONTH3 -> resourceReference(R.string.markets_selector_interval_3m_title) + PriceChangeInterval.MONTH6 -> resourceReference(R.string.markets_selector_interval_6m_title) + PriceChangeInterval.YEAR -> resourceReference(R.string.markets_selector_interval_1y_title) + PriceChangeInterval.ALL_TIME -> resourceReference(R.string.markets_selector_interval_all_title) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun MarketsTokenDetailsContent_Preview( + @PreviewParameter(MarketsTokenDetailsContentPreviewProvider::class) params: MarketsTokenDetailsUM, +) { + TangemThemePreview { + MarketsTokenDetailsContent( + state = params, + backgroundColor = TangemTheme.colors.background.tertiary, + portfolioBlock = {}, + isAccountEnabled = true, + ) + } +} + +private class MarketsTokenDetailsContentPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + MarketsTokenDetailsPreview.loadingState, + MarketsTokenDetailsPreview.contentState, + ) +} +// endregion \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ExchangesBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ExchangesBottomSheet.kt new file mode 100644 index 0000000000..8b6dc00d33 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ExchangesBottomSheet.kt @@ -0,0 +1,204 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +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.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.audits.AuditLabelUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent +import kotlinx.collections.immutable.toImmutableList + +/** + * Exchanges bottom sheet + * + * @param config bottom sheet config + * +[REDACTED_AUTHOR] + */ +@Composable +internal fun ExchangesBottomSheet(config: TangemBottomSheetConfig) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + + TangemBottomSheet( + config = config, + addBottomInsets = false, + title = { Title(textResId = it.titleResId, onBackClick = config.onDismissRequest) }, + content = { content -> + Box { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = bottomBarHeight), + ) { + item(key = "subtitle") { + Subtitle( + subtitleRes = content.subtitleResId, + volumeReference = content.volumeReference, + modifier = Modifier.padding( + start = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing8, + ), + ) + } + + items( + items = content.exchangeItems, + key = TokenItemState::id, + itemContent = { TokenItem(state = it, isBalanceHidden = false) }, + ) + } + + if (content is ExchangesBottomSheetContent.Error) { + Error( + content = content, + modifier = Modifier.align(Alignment.Center), + ) + } + } + }, + ) +} + +@Composable +private fun Title(@StringRes textResId: Int, onBackClick: () -> Unit) { + TangemTopAppBar( + title = stringResourceSafe(id = textResId), + startButton = TopAppBarButtonUM.Back(onBackClicked = onBackClick), + ) +} + +@Composable +private fun Subtitle(@StringRes subtitleRes: Int, volumeReference: TextReference, modifier: Modifier = Modifier) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + SubtitleText(textReference = resourceReference(id = subtitleRes)) + + SubtitleText(textReference = volumeReference) + } +} + +@Composable +private fun SubtitleText(textReference: TextReference) { + Text( + text = textReference.resolveReference(), + color = TangemTheme.colors.text.tertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = TangemTheme.typography.body2, + ) +} + +@Composable +private fun Error(content: ExchangesBottomSheetContent.Error, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResourceSafe(id = content.message), + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + style = TangemTheme.typography.caption1, + ) + + SpacerH12() + + SecondarySmallButton( + config = SmallButtonConfig( + text = resourceReference(id = R.string.alert_button_try_again), + onClick = content.onRetryClick, + ), + ) + } +} + +@Preview +@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_ExchangesBottomSheet( + @PreviewParameter(ExchangesBottomSheetContentProvider::class) content: ExchangesBottomSheetContent, +) { + TangemThemePreview { + ExchangesBottomSheet( + config = TangemBottomSheetConfig( + onDismissRequest = {}, + content = content, + isShown = true, + ), + ) + } +} + +private class ExchangesBottomSheetContentProvider : CollectionPreviewParameterProvider( + listOf( + ExchangesBottomSheetContent.Loading(exchangesCount = 13), + ExchangesBottomSheetContent.Error(onRetryClick = {}), + ExchangesBottomSheetContent.Content( + exchangeItems = List(size = 13) { index -> + TokenItemState.Content( + id = index.toString(), + iconState = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.ic_facebook_24, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "OKX")), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = "$67.52M"), + subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference(value = "CEX")), + subtitle2State = TokenItemState.Subtitle2State.LabelContent( + auditLabelUM = AuditLabelUM( + text = stringReference("Caution"), + type = AuditLabelUM.Type.Warning, + ), + ), + onItemClick = {}, + onItemLongClick = {}, + ) + } + .toImmutableList(), + ), + ), +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoBottomSheet.kt new file mode 100644 index 0000000000..345f000aa3 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoBottomSheet.kt @@ -0,0 +1,79 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.state.InfoBottomSheetContent +import dev.jeziellago.compose.markdowntext.MarkdownText + +@Composable +internal fun InfoBottomSheet(config: TangemBottomSheetConfig) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + + TangemBottomSheet( + config = config, + addBottomInsets = false, + title = { TangemBottomSheetTitle(title = it.title) }, + content = { content -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = TangemTheme.dimens.spacing16), + ) { + MarkdownText( + markdown = content.body.resolveReference(), + disableLinkMovementMethod = true, + linkifyMask = 0, + syntaxHighlightColor = TangemTheme.colors.text.secondary, + style = TangemTheme.typography.body2.copy( + color = TangemTheme.colors.text.secondary, + ), + ) + + if (content.generatedAINotificationUM != null) { + AdditionalInfoNotification( + onClick = content.generatedAINotificationUM.onClick, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing12, bottom = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + ) + } + + SpacerH(bottomBarHeight) + } + }, + ) +} + +@Composable +private fun AdditionalInfoNotification(onClick: () -> Unit, modifier: Modifier = Modifier) { + Notification( + config = NotificationConfig( + subtitle = TextReference.Res(id = R.string.information_generated_with_ai), + iconResId = R.drawable.ic_magic_28, + onClick = onClick, + shouldShowArrowIcon = false, + ), + modifier = modifier, + subtitleColor = TangemTheme.colors.text.primary1, + containerColor = TangemTheme.colors.button.disabled, + iconTint = TangemTheme.colors.icon.accent, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoPoint.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoPoint.kt new file mode 100644 index 0000000000..a3d85081b4 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoPoint.kt @@ -0,0 +1,193 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +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.R +import com.tangem.core.ui.components.SpacerW4 +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.text.TooltipText +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM + +@Composable +internal fun InfoPoint(infoPointUM: InfoPointUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8), + horizontalAlignment = Alignment.Start, + ) { + if (infoPointUM.onInfoClick != null) { + TooltipText( + text = infoPointUM.title, + onInfoClick = infoPointUM.onInfoClick, + textStyle = TangemTheme.typography.caption2, + ) + } else { + Text( + text = infoPointUM.title.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row { + Text( + text = infoPointUM.value, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + if (infoPointUM.change != null) { + SpacerW4() + Icon( + modifier = Modifier + .size(TangemTheme.dimens.size8) + .align(Alignment.CenterVertically), + imageVector = ImageVector.vectorResource( + id = when (infoPointUM.change) { + InfoPointUM.ChangeType.UP -> R.drawable.ic_arrow_up_8 + InfoPointUM.ChangeType.DOWN -> R.drawable.ic_arrow_down_8 + }, + ), + tint = when (infoPointUM.change) { + InfoPointUM.ChangeType.UP -> TangemTheme.colors.icon.accent + InfoPointUM.ChangeType.DOWN -> TangemTheme.colors.icon.warning + }, + contentDescription = null, + ) + } + } + } +} + +@Composable +internal fun InfoPointShimmer(modifier: Modifier = Modifier, withTooltip: Boolean = false) { + Column( + modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8), + horizontalAlignment = Alignment.Start, + ) { + if (withTooltip) { + Box( + modifier = Modifier + .requiredHeight(TangemTheme.dimens.size16) + .fillMaxWidth(), + contentAlignment = Alignment.CenterStart, + ) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.caption2, + textSizeHeight = false, + ) + } + } else { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.caption2, + textSizeHeight = true, + ) + } + TextShimmer( + modifier = Modifier.fillMaxWidth(fraction = 0.5f), + style = TangemTheme.typography.body1, + textSizeHeight = true, + ) + } +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + Column( + modifier = Modifier + .width(150.dp) + .background(TangemTheme.colors.background.tertiary), + ) { + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000,000", + ), + ) + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000,000", + onInfoClick = { }, + ), + ) + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000", + change = InfoPointUM.ChangeType.UP, + onInfoClick = { }, + ), + ) + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000", + change = InfoPointUM.ChangeType.DOWN, + onInfoClick = { }, + ), + ) + } + } +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewShimmer() { + TangemThemePreview { + PreviewShimmerContainer( + shimmerContent = { + Column( + modifier = Modifier + .width(150.dp) + .background(TangemTheme.colors.background.tertiary), + ) { + InfoPointShimmer(modifier = Modifier.fillMaxWidth()) + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + withTooltip = true, + ) + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + withTooltip = true, + ) + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + withTooltip = true, + ) + } + }, + actualContent = { + ContentPreview() + }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InsightsBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InsightsBlock.kt new file mode 100644 index 0000000000..69a803858b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InsightsBlock.kt @@ -0,0 +1,215 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.block.information.GridItems +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.components.text.TooltipText +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.getText +import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM +import com.tangem.features.feed.ui.market.detailed.state.InsightsUM +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) { + var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) } + + InformationBlock( + modifier = modifier, + title = { + TooltipText( + text = resourceReference(R.string.markets_token_details_insights), + textStyle = TangemTheme.typography.subtitle2, + onInfoClick = state.onInfoClick, + ) + }, + action = { + SegmentedButtons( + config = persistentListOf( + PriceChangeInterval.H24, + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + ), + initialSelectedItem = PriceChangeInterval.H24, + onClick = { interval -> + currentInterval = interval + state.onIntervalChanged(interval) + }, + modifier = Modifier.width(IntrinsicSize.Min), + ) { + Box( + Modifier + .fillMaxSize() + .align(Alignment.Center) + .padding( + horizontal = 14.dp, + vertical = 4.dp, + ), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = it.getText().resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + } + } + }, + content = { + val infoPoints = when (currentInterval) { + PriceChangeInterval.H24 -> state.h24Info + PriceChangeInterval.WEEK -> state.weekInfo + PriceChangeInterval.MONTH -> state.monthInfo + else -> state.h24Info + } + + GridItems( + items = infoPoints, + itemContent = { infoPointUM -> + InfoPoint( + modifier = Modifier.align(Alignment.CenterStart), + infoPointUM = infoPointUM, + ) + }, + ) + }, + ) +} + +@Composable +internal fun InsightsBlockPlaceholder(modifier: Modifier = Modifier) { + val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() } + val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() } + val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4 + + InformationBlock( + modifier = modifier, + title = { + RectangleShimmer( + modifier = Modifier + .height(headerHeight) + .fillMaxWidth(), + radius = TangemTheme.dimens.radius3, + ) + }, + content = { + GridItems( + items = List(size = 4) { it }.toImmutableList(), + horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + itemContent = { + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + ) + }, + ) + }, + ) +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + InsightsBlock( + state = InsightsUM( + h24Info = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = "1 000 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = "1 000 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = "1 000 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = "1 000 000 000", + ), + ), + weekInfo = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = "1 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = "1 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = "1 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = "1 000 000", + ), + ), + monthInfo = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = "1 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = "1 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = "1 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = "1 000", + ), + ), + onInfoClick = {}, + onIntervalChanged = {}, + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewPlaceholder() { + TangemThemePreview { + PreviewShimmerContainer( + actualContent = { ContentPreview() }, + shimmerContent = { InsightsBlockPlaceholder() }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt new file mode 100644 index 0000000000..2d0693b4c4 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt @@ -0,0 +1,226 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.ChipShimmer +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.chip.Chip +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.state.LinksUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun LinksBlock(state: LinksUM, modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier, + contentHorizontalPadding = 0.dp, + title = { + Text( + text = stringResourceSafe(id = R.string.markets_token_details_links), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + content = { + Column { + SubBlock( + title = stringResourceSafe(id = R.string.markets_token_details_official_links), + links = state.officialLinks, + onLinkClick = state.onLinkClick, + ) + SubBlock( + title = stringResourceSafe(id = R.string.markets_token_details_social), + links = state.social, + onLinkClick = state.onLinkClick, + ) + SubBlock( + title = stringResourceSafe(id = R.string.markets_token_details_repository), + links = state.repository, + onLinkClick = state.onLinkClick, + ) + SubBlock( + title = stringResourceSafe(id = R.string.markets_token_details_blockchain_site), + links = state.blockchainSite, + onLinkClick = state.onLinkClick, + lastBlock = true, + ) + } + }, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SubBlock( + links: ImmutableList, + onLinkClick: (LinksUM.Link) -> Unit, + modifier: Modifier = Modifier, + lastBlock: Boolean = false, + title: String = "Official links", +) { + if (links.isEmpty()) return + + DividerContainer( + modifier = modifier, + showDivider = !lastBlock, + ) { + Column( + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Text( + text = title, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + links.fastForEach { link -> + Chip( + text = stringReference(link.title), + iconResId = link.iconRes, + onClick = { onLinkClick(link) }, + ) + } + } + } + } +} + +@Composable +fun LinksBlockPlaceholder(modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier, + contentHorizontalPadding = 0.dp, + title = { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.subtitle2, + ) + }, + content = { + Column { + SubBlockPlaceholder() + SubBlockPlaceholder() + SubBlockPlaceholder(lastBlock = true) + } + }, + ) +} + +@Composable +private fun SubBlockPlaceholder(modifier: Modifier = Modifier, lastBlock: Boolean = false) { + DividerContainer( + modifier = modifier, + showDivider = !lastBlock, + ) { + Column( + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + TextShimmer( + modifier = Modifier.width(78.dp), + style = TangemTheme.typography.caption2, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + repeat(times = 3) { + ChipShimmer( + modifier = Modifier.weight(1f), + ) + } + } + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + LinksBlock( + state = LinksUM( + officialLinks = persistentListOf( + LinksUM.Link( + title = "Website", + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + LinksUM.Link( + title = "Website", + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + LinksUM.Link( + title = "Website", + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + ), + social = persistentListOf( + LinksUM.Link( + title = "Twitter", + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + LinksUM.Link( + title = "Facebook", + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + ), + repository = persistentListOf( + LinksUM.Link( + title = "Github", + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + ), + blockchainSite = persistentListOf(), + onLinkClick = {}, + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PlaceholderPreview() { + TangemThemePreview { + PreviewShimmerContainer( + shimmerContent = { LinksBlockPlaceholder() }, + actualContent = { ContentPreview() }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt new file mode 100644 index 0000000000..77d5ba5239 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt @@ -0,0 +1,143 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import android.content.res.Configuration +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +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.datasource.CollectionPreviewParameterProvider +import com.tangem.common.ui.R +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.ui.market.detailed.state.ListedOnUM +import kotlinx.coroutines.delay + +/** + * "Listed on" block + * + * @param state block state + * +[REDACTED_AUTHOR] + */ +@Composable +internal fun ListedOnBlock(state: ListedOnUM, modifier: Modifier = Modifier) { + Box(modifier = modifier) { + InformationBlock( + title = { + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + modifier = Modifier + .clip(shape = TangemTheme.shapes.roundedCornersXMedium) + .clickable(enabled = state is ListedOnUM.Content) { + (state as? ListedOnUM.Content)?.onClick?.invoke() + }, + ) { + Description( + state = state, + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + ) + } + + if (state is ListedOnUM.Content) { + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = TangemTheme.dimens.spacing12), + ) + } + } +} + +@Composable +internal fun ListedOnBlockPlaceholder(modifier: Modifier = Modifier) { + InformationBlock( + title = { + TextShimmer( + style = TangemTheme.typography.subtitle2, + modifier = Modifier.fillMaxWidth(fraction = 0.5f), + ) + }, + modifier = modifier, + ) { + TextShimmer( + style = TangemTheme.typography.body2, + modifier = Modifier + .fillMaxWidth(fraction = 0.3f) + .padding(bottom = TangemTheme.dimens.spacing12), + ) + } +} + +@Composable +private fun Description(state: ListedOnUM, modifier: Modifier = Modifier) { + Text( + text = state.description.resolveReference(), + modifier = modifier, + color = TangemTheme.colors.text.tertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = TangemTheme.typography.body2, + ) +} + +@Preview(widthDp = 328, heightDp = 68) +@Preview(name = "Dark Theme", widthDp = 328, heightDp = 68, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_ListedOnBlock(@PreviewParameter(ListenOnUMProvider::class) state: ListedOnUM?) { + TangemThemePreview { + if (state == null) { + ListedOnBlockPlaceholder() + } else { + ListedOnBlock(state = state) + } + } +} + +@Preview +@Composable +private fun Preview_ListedOnBlock_StateChanging() { + var state by remember { mutableStateOf(value = null) } + + Preview_ListedOnBlock(state = state) + + LaunchedEffect(key1 = null) { + delay(timeMillis = 3000) + + state = ListedOnUM.Empty + } +} + +private class ListenOnUMProvider : CollectionPreviewParameterProvider( + collection = listOf( + ListedOnUM.Empty, + ListedOnUM.Content(onClick = {}, amount = 5), + null, + ), +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MarketTokenDetailsChart.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MarketTokenDetailsChart.kt new file mode 100644 index 0000000000..3d45e7bd87 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MarketTokenDetailsChart.kt @@ -0,0 +1,84 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import com.tangem.common.ui.charts.MarketChart +import com.tangem.common.ui.charts.getMarketChartBottomAxisHeight +import com.tangem.common.ui.charts.state.MarketChartLook +import com.tangem.common.ui.charts.state.rememberMarketChartState +import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM + +@Composable +internal fun MarketTokenDetailsChart( + state: MarketsTokenDetailsUM.ChartState, + backgroundColor: Color, + modifier: Modifier = Modifier, +) { + val growingColor = TangemTheme.colors.icon.accent + val fallingColor = TangemTheme.colors.icon.warning + val neutralColor = TangemTheme.colors.icon.informative + + val chartState = rememberMarketChartState( + dataProducer = state.dataProducer, + colorMapper = { type -> + when (type) { + MarketChartLook.Type.Growing -> growingColor + MarketChartLook.Type.Falling -> fallingColor + MarketChartLook.Type.Neutral -> neutralColor + } + }, + onMarkerShown = state.onMarkerPointSelected, + ) + + val bottomChartAxisHeight = getMarketChartBottomAxisHeight() + + Box(modifier) { + MarketChart( + modifier = Modifier.fillMaxWidth(), + state = chartState, + ) + + if (state.status != MarketsTokenDetailsUM.ChartState.Status.DATA) { + Box( + Modifier + .drawBehind { drawRect(backgroundColor) } + .matchParentSize() + .padding(bottom = bottomChartAxisHeight), + ) { + when (state.status) { + MarketsTokenDetailsUM.ChartState.Status.LOADING -> { + CircularProgressIndicator( + modifier = Modifier + .size(TangemTheme.dimens.size16) + .align(Alignment.Center), + color = TangemTheme.colors.text.accent, + strokeWidth = TangemTheme.dimens.size2, + ) + } + MarketsTokenDetailsUM.ChartState.Status.ERROR -> { + UnableToLoadData( + modifier = Modifier + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ) + .align(Alignment.Center), + onRetryClick = state.onLoadRetryClick, + ) + } + else -> {} + } + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsBlock.kt new file mode 100644 index 0000000000..1059eeb198 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsBlock.kt @@ -0,0 +1,168 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.TextButton +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.GridItems +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM +import com.tangem.features.feed.ui.market.detailed.state.MetricsUM +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +const val MAX_METRICS_COUNT = 6 + +@Composable +internal fun MetricsBlock(state: MetricsUM, modifier: Modifier = Modifier) { + var isExpanded by remember { mutableStateOf(false) } + + InformationBlock( + modifier = modifier, + title = { + Text( + text = stringResourceSafe(id = R.string.markets_token_details_metrics), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + action = { + if (state.metrics.size > MAX_METRICS_COUNT) { + ShowLessMoreButton(expanded = isExpanded, onClick = { isExpanded = !isExpanded }) + } + }, + content = { + val metrics = if (isExpanded) { + state.metrics + } else { + state.metrics.take(MAX_METRICS_COUNT).toImmutableList() + } + + GridItems( + items = metrics, + itemContent = { + InfoPoint(infoPointUM = it) + }, + ) + }, + ) +} + +// TODO make TextButton clickable area smaller and remove paddings for an action in InformationBlock +@Composable +private fun ShowLessMoreButton(expanded: Boolean, onClick: () -> Unit) { + // FIXME add string resources + val text = if (expanded) { + "See less" + } else { + "See more" + } + + TextButton( + text = text, + onClick = onClick, + colors = TangemButtonsDefaults.positiveButtonColors, + textStyle = TangemTheme.typography.body2, + ) +} + +@Composable +internal fun MetricsBlockPlaceholder(modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier, + title = { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + radius = TangemTheme.dimens.radius3, + style = TangemTheme.typography.subtitle2, + ) + }, + action = { + Box(Modifier) + }, + content = { + GridItems( + items = List(size = 6) { it }.toImmutableList(), + horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + itemContent = { + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + withTooltip = true, + ) + }, + ) + }, + ) +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun BlockPreview() { + TangemThemePreview { + MetricsBlock( + state = MetricsUM( + metrics = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_market_capitalization), + value = "1.2T", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_market_rating), + value = "A", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_trading_volume), + value = "1.2T", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_fully_diluted_valuation), + value = "1.2T", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_circulating_supply), + value = "1.2T", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_total_supply), + value = "1.2T", + onInfoClick = {}, + ), + ), + ), + ) + } +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewPlaceholder() { + TangemThemePreview { + PreviewShimmerContainer( + actualContent = { BlockPreview() }, + shimmerContent = { MetricsBlockPlaceholder() }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/PricePerformanceBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/PricePerformanceBlock.kt new file mode 100644 index 0000000000..6e43c327ee --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/PricePerformanceBlock.kt @@ -0,0 +1,269 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalDensity +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.unit.dp +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerW8 +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.components.progressbar.TangemLinearProgressIndicator +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemAnimations +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.getText +import com.tangem.features.feed.ui.market.detailed.state.PricePerformanceUM +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun PricePerformanceBlock(state: PricePerformanceUM, modifier: Modifier = Modifier) { + var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) } + + InformationBlock( + modifier = modifier, + title = { + Text( + text = stringResourceSafe(id = R.string.markets_token_details_price_performance), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + action = { + SegmentedButtons( + config = persistentListOf( + PriceChangeInterval.H24, + PriceChangeInterval.MONTH, + PriceChangeInterval.ALL_TIME, + ), + initialSelectedItem = PriceChangeInterval.H24, + onClick = { interval -> + currentInterval = interval + state.onIntervalChanged(interval) + }, + modifier = Modifier.width(IntrinsicSize.Min), + ) { + Box( + Modifier + .fillMaxSize() + .align(Alignment.Center) + .padding( + horizontal = 14.dp, + vertical = TangemTheme.dimens.spacing4, + ), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = it.getText().resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + } + } + }, + content = { + val value = when (currentInterval) { + PriceChangeInterval.H24 -> state.h24 + PriceChangeInterval.MONTH -> state.month + PriceChangeInterval.ALL_TIME -> state.all + else -> error("") + } + + Content( + modifier = Modifier.fillMaxWidth(), + state = value, + ) + }, + ) +} + +@Composable +private fun Content(state: PricePerformanceUM.Value, modifier: Modifier = Modifier) { + val animatedIndicatorFraction by TangemAnimations.horizontalIndicatorAsState( + targetFraction = state.indicatorFraction, + ) + + Column( + modifier = modifier + .padding(vertical = TangemTheme.dimens.spacing8), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResourceSafe(R.string.markets_token_details_low), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + SpacerW8() + Text( + text = stringResourceSafe(R.string.markets_token_details_high), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + TangemLinearProgressIndicator( + modifier = Modifier + .height(TangemTheme.dimens.size6) + .fillMaxWidth(), + progress = { animatedIndicatorFraction }, + color = TangemTheme.colors.text.accent, + backgroundColor = TangemTheme.colors.background.tertiary, + strokeCap = StrokeCap.Round, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + Text( + modifier = Modifier.weight(1f), + text = state.low, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier.weight(1f), + text = state.high, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.End, + ) + } + } +} + +@Composable +internal fun PricePerformanceBlockPlaceholder(modifier: Modifier = Modifier) { + val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() } + val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() } + val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4 + + InformationBlock( + modifier = modifier, + title = { + RectangleShimmer( + modifier = Modifier + .height(headerHeight) + .fillMaxWidth(), + radius = TangemTheme.dimens.radius3, + ) + }, + content = { + Column( + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing8), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextShimmer( + modifier = Modifier.width(35.dp), + style = TangemTheme.typography.caption2, + ) + SpacerW8() + TextShimmer( + modifier = Modifier.width(35.dp), + style = TangemTheme.typography.caption2, + ) + } + RectangleShimmer( + modifier = Modifier + .height(TangemTheme.dimens.size6) + .fillMaxWidth(), + radius = 27.dp, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextShimmer( + modifier = Modifier.width(TangemTheme.dimens.size56), + style = TangemTheme.typography.body1, + ) + SpacerW8() + TextShimmer( + modifier = Modifier.width(TangemTheme.dimens.size56), + style = TangemTheme.typography.body1, + ) + } + } + }, + ) +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + PricePerformanceBlock( + modifier = Modifier, + state = PricePerformanceUM( + h24 = PricePerformanceUM.Value( + low = "\$38,5K", + high = "\$58,5K", + indicatorFraction = 0.5f, + ), + month = PricePerformanceUM.Value( + low = "\$500,5K", + high = "\$5800,5K", + indicatorFraction = 0.8f, + ), + all = PricePerformanceUM.Value( + low = "\$58,52", + high = "\$580,5M", + indicatorFraction = 0.2f, + ), + onIntervalChanged = {}, + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PlaceholderPreview() { + TangemThemePreview { + PreviewShimmerContainer( + shimmerContent = { + PricePerformanceBlockPlaceholder() + }, + actualContent = { + ContentPreview() + }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ScoreStarsBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ScoreStarsBlock.kt new file mode 100644 index 0000000000..6190849b99 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ScoreStarsBlock.kt @@ -0,0 +1,100 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import androidx.annotation.FloatRange +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.impl.R +import kotlin.math.round + +private const val STARS_COUNT = 5 + +@Composable +internal fun ScoreStarsBlock( + score: Float, + horizontalSpacing: Dp, + scoreTextStyle: TextStyle, + modifier: Modifier = Modifier, +) { + val rounded = score.roundTo1decimal() + val percentage = rounded / STARS_COUNT + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(horizontalSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = rounded.toString(), + style = scoreTextStyle, + color = TangemTheme.colors.text.primary1, + ) + Stars(fraction = percentage) + } +} + +@Suppress("MagicNumber") +@Composable +private fun Stars(@FloatRange(0.0, 1.0) fraction: Float = 0f) { + val grayColor = TangemTheme.colors.icon.inactive + + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(times = 5) { i -> + Box( + modifier = Modifier.size(TangemTheme.dimens.size16), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier + .requiredSize(16.dp) + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithCache { + onDrawWithContent { + val starFraction = ((fraction - i * 0.2) / 0.2).coerceIn(0.0, 1.0) + val starFractionFloat = starFraction + .toFloat() + .roundTo1decimal() + + drawContent() + drawRect( + color = grayColor, + topLeft = Offset(x = size.width * starFractionFloat, y = 0f), + size = Size(size.width * (1 - starFractionFloat), size.height), + blendMode = BlendMode.SrcIn, + ) + } + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_star_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + } + } + } +} + +@Suppress("MagicNumber") +private fun Float.roundTo1decimal(): Float { + return round(this * 10) / 10 +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt new file mode 100644 index 0000000000..1fe9bbc7fa --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt @@ -0,0 +1,140 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +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.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.text.TooltipText +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreUM + +@Composable +internal fun SecurityScoreBlock(state: SecurityScoreUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action) + .fillMaxWidth() + .heightIn(max = TangemTheme.dimens.size72) + .padding(all = TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1F) + .fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + TooltipText( + text = resourceReference(R.string.markets_token_details_security_score), + onInfoClick = state.onInfoClick, + textStyle = TangemTheme.typography.subtitle2, + ) + + Text( + text = state.description.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + ScoreStarsBlock( + score = state.score, + scoreTextStyle = TangemTheme.typography.body1, + horizontalSpacing = TangemTheme.dimens.spacing8, + ) + } +} + +@Composable +internal fun SecurityScoreBlockPlaceholder(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.primary) + .fillMaxWidth() + .heightIn(max = TangemTheme.dimens.size72) + .padding(all = TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier + .fillMaxWidth(fraction = 0.4f) + .padding(vertical = TangemTheme.dimens.spacing2) + .fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.subtitle2, + textSizeHeight = true, + ) + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + } + + TextShimmer( + modifier = Modifier.fillMaxWidth(fraction = 0.5f), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + } +} + +@Preview(widthDp = 328, showBackground = true) +@Preview(widthDp = 328, showBackground = true, locale = "ru") +@Preview(widthDp = 328, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + SecurityScoreBlock( + state = SecurityScoreUM( + score = 3.5f, + description = stringReference("Based on 3 ratings"), + onInfoClick = {}, + ), + ) + } +} + +@Preview(widthDp = 328, showBackground = true) +@Preview(widthDp = 328, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewPlaceholder() { + TangemThemePreview { + PreviewShimmerContainer( + shimmerContent = { + SecurityScoreBlockPlaceholder( + modifier = Modifier.fillMaxWidth(), + ) + }, + actualContent = { + ContentPreview() + }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBottomSheet.kt new file mode 100644 index 0000000000..fc40da9bcc --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBottomSheet.kt @@ -0,0 +1,190 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.util.fastForEachIndexed +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.preview.SecurityScorePreviewData +import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreBottomSheetContent + +@Composable +internal fun SecurityScoreBottomSheet(config: TangemBottomSheetConfig) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + + TangemBottomSheet( + config = config, + addBottomInsets = false, + title = { TangemBottomSheetTitle(title = it.title) }, + content = { content -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = TangemTheme.dimens.spacing16), + ) { + Text( + text = content.description.resolveReference(), + style = TangemTheme.typography.body2.copy( + color = TangemTheme.colors.text.secondary, + ), + ) + + SpacerH12() + content.providers.fastForEachIndexed { index, provider -> + DividerContainer( + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = content.providers.lastIndex, + addDefaultPadding = false, + ) + .background(TangemTheme.colors.background.action), + showDivider = index != content.providers.lastIndex, + ) { + SecurityScoreProviderRow( + providerUM = provider, + onLinkClick = { content.onProviderLinkClick(provider) }, + ) + } + } + + SpacerH16() + SpacerH(bottomBarHeight) + } + }, + ) +} + +@Composable +private fun SecurityScoreProviderRow( + providerUM: SecurityScoreBottomSheetContent.SecurityScoreProviderUM, + onLinkClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing12) + .heightIn(min = TangemTheme.dimens.size68), + verticalAlignment = Alignment.CenterVertically, + ) { + SubcomposeAsyncImage( + modifier = Modifier + .size(size = TangemTheme.dimens.size40) + .clip(TangemTheme.shapes.roundedCorners8), + model = ImageRequest.Builder(context = LocalContext.current) + .data(providerUM.iconUrl) + .crossfade(enable = true) + .allowHardware(false) + .build(), + loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) }, + error = { RectangleShimmer(radius = TangemTheme.dimens.radius8) }, + contentDescription = null, + ) + + Column( + modifier = Modifier.padding(start = TangemTheme.dimens.spacing12), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + ) { + Text( + text = providerUM.name, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + providerUM.lastAuditDate?.let { auditDate -> + Text( + text = auditDate, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + + SpacerWMax() + + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + modifier = Modifier.clickable( + enabled = providerUM.urlData != null, + indication = ripple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + onClick = onLinkClick, + ), + ) { + ScoreStarsBlock( + score = providerUM.score, + scoreTextStyle = TangemTheme.typography.body2, + horizontalSpacing = TangemTheme.dimens.spacing3, + ) + + UrlBlock(providerUM) + } + } +} + +@Composable +private fun UrlBlock(providerUM: SecurityScoreBottomSheetContent.SecurityScoreProviderUM) { + val urlData = providerUM.urlData + val rootHost = urlData?.rootHost + if (urlData != null && rootHost != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + ) { + Text( + text = urlData.rootHost, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + Icon( + modifier = Modifier + .size(TangemTheme.dimens.size16), + painter = painterResource(id = R.drawable.ic_arrow_top_right_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun SecurityScoreBottomSheetPreview() { + TangemThemePreview { + SecurityScoreBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = SecurityScorePreviewData.bottomSheetContent, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt new file mode 100644 index 0000000000..2bb53ddbc1 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -0,0 +1,200 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.components.items.DescriptionItem +import com.tangem.core.ui.components.items.DescriptionPlaceholder +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM + +@Suppress("CanBeNonNullable") // TODO will be removed after [REDACTED_JIRA] +internal fun LazyListScope.tokenMarketDetailsBody( + state: MarketsTokenDetailsUM.Body, + isAccountEnabled: Boolean, + portfolioBlock: @Composable ((Modifier) -> Unit)?, +) { + when (state) { + MarketsTokenDetailsUM.Body.Loading -> { + item("description-loading") { + DescriptionPlaceholder(modifier = Modifier.blockPaddings()) + } + + if (portfolioBlock != null) { + item(key = "portfolio") { + portfolioBlock(Modifier.blockPaddings()) + } + } + + if (isAccountEnabled) { + aboutCoinHeader() + } + + loadingInfoBlocks() + } + is MarketsTokenDetailsUM.Body.Content -> { + if (state.description != null) { + description(state.description) + } + + if (portfolioBlock != null) { + item(key = "portfolio") { + portfolioBlock(Modifier.blockPaddings()) + } + } + + if (isAccountEnabled) { + aboutCoinHeader() + } + + infoBlocksList(state.infoBlocks) + } + is MarketsTokenDetailsUM.Body.Error -> { + error(state) + } + MarketsTokenDetailsUM.Body.Nothing -> { + // Do nothing + } + } +} + +private fun LazyListScope.error(state: MarketsTokenDetailsUM.Body.Error) { + item("body-error") { + Box(Modifier.fillMaxWidth()) { + UnableToLoadData( + modifier = Modifier + .align(Alignment.Center) + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing40, + ), + onRetryClick = state.onLoadRetryClick, + ) + } + } +} + +private fun LazyListScope.aboutCoinHeader() { + item("aboutCoinHeader") { + Text( + modifier = Modifier.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing20, + ), + text = stringResourceSafe(R.string.markets_about_coin_header), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.h3, + ) + } +} + +private fun LazyListScope.description(description: MarketsTokenDetailsUM.Description) { + item("description") { + DescriptionItem( + modifier = Modifier.blockPaddings(), + description = description.shortDescription, + hasFullDescription = description.fullDescription != null, + onReadMoreClick = description.onReadMoreClick, + ) + } +} + +internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.InformationBlocks) { + if (state.insights != null) { + item("insights") { + InsightsBlock( + modifier = Modifier.blockPaddings(), + state = state.insights, + ) + } + } + + if (state.securityScore != null) { + item("securityScore") { + SecurityScoreBlock( + modifier = Modifier.blockPaddings(), + state = state.securityScore, + ) + } + } + + if (state.metrics != null) { + item("metrics") { + MetricsBlock( + modifier = Modifier.blockPaddings(), + state = state.metrics, + ) + } + } + + if (state.pricePerformance != null) { + item("pricePerformance") { + PricePerformanceBlock( + modifier = Modifier.blockPaddings(), + state = state.pricePerformance, + ) + } + } + + item(key = "listedOn") { + ListedOnBlock( + state = state.listedOn, + modifier = Modifier.blockPaddings(), + ) + } + + if (state.links != null) { + item("links") { + LinksBlock( + modifier = Modifier.blockPaddings(), + state = state.links, + ) + } + } +} + +private fun LazyListScope.loadingInfoBlocks() { + item("insights-loading") { + InsightsBlockPlaceholder( + modifier = Modifier.blockPaddings(), + ) + } + + item("securityScore-loading") { + SecurityScoreBlockPlaceholder(modifier = Modifier.blockPaddings()) + } + + item("metrics-loading") { + MetricsBlockPlaceholder(modifier = Modifier.blockPaddings()) + } + + item("pricePerformance-loading") { + PricePerformanceBlockPlaceholder(modifier = Modifier.blockPaddings()) + } + + item(key = "listedOn-loading") { + ListedOnBlockPlaceholder(modifier = Modifier.blockPaddings()) + } + + item("links-loading") { + LinksBlockPlaceholder(modifier = Modifier.blockPaddings()) + } +} + +@Composable +private fun Modifier.blockPaddings(): Modifier { + return this.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing12, + ) +} \ 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 new file mode 100644 index 0000000000..8f44d04000 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt @@ -0,0 +1,139 @@ +package com.tangem.features.feed.ui.market.detailed.preview + +import com.tangem.common.ui.charts.state.MarketChartDataProducer +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM +import com.tangem.features.feed.ui.market.detailed.state.InsightsUM +import com.tangem.features.feed.ui.market.detailed.state.ListedOnUM +import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM +import com.tangem.features.feed.ui.market.detailed.state.MetricsUM +import com.tangem.features.feed.ui.market.detailed.state.PricePerformanceUM +import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreUM +import kotlinx.collections.immutable.persistentListOf + +internal object MarketsTokenDetailsPreview { + private val infoPoint = InfoPointUM( + title = stringReference("1"), + value = "2", + change = InfoPointUM.ChangeType.DOWN, + onInfoClick = {}, + ) + + val loadingState = MarketsTokenDetailsUM( + tokenName = "Token Name", + priceText = "$0.00000000324", + dateTimeText = stringReference("Today"), + priceChangePercentText = "52.00%", + iconUrl = "", + priceChangeType = PriceChangeType.UP, + chartState = MarketsTokenDetailsUM.ChartState( + dataProducer = MarketChartDataProducer.build { }, + onLoadRetryClick = {}, + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + onMarkerPointSelected = { _, _ -> }, + ), + selectedInterval = PriceChangeInterval.H24, + onSelectedIntervalChange = { }, + body = MarketsTokenDetailsUM.Body.Loading, + bottomSheetConfig = TangemBottomSheetConfig( + isShown = false, + onDismissRequest = {}, + content = TangemBottomSheetConfigContent.Empty, + ), + isMarkerSet = false, + triggerPriceChange = consumedEvent(), + onShouldShowPriceSubtitleChange = {}, + shouldShowPriceSubtitle = false, + ) + + val contentState = MarketsTokenDetailsUM( + tokenName = "Token Name", + priceText = "$0.00000000324", + dateTimeText = stringReference("Today"), + priceChangePercentText = "52.00%", + iconUrl = "", + priceChangeType = PriceChangeType.UP, + chartState = MarketsTokenDetailsUM.ChartState( + dataProducer = MarketChartDataProducer.build { }, + onLoadRetryClick = {}, + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + onMarkerPointSelected = { _, _ -> }, + ), + selectedInterval = PriceChangeInterval.H24, + onSelectedIntervalChange = { }, + body = MarketsTokenDetailsUM.Body.Content( + description = MarketsTokenDetailsUM.Description( + shortDescription = stringReference("markets_token_details_description_short"), + fullDescription = stringReference("markets_token_details_description_full"), + onReadMoreClick = {}, + ), + infoBlocks = MarketsTokenDetailsUM.InformationBlocks( + insights = InsightsUM( + h24Info = persistentListOf( + infoPoint, + infoPoint, + infoPoint, + ), + weekInfo = persistentListOf( + infoPoint, + infoPoint, + infoPoint, + ), + monthInfo = persistentListOf( + infoPoint, + infoPoint, + infoPoint, + ), + onInfoClick = {}, + onIntervalChanged = {}, + ), + securityScore = SecurityScoreUM( + score = 2.3f, + description = stringReference("markets_token_details_security_score_description"), + onInfoClick = {}, + ), + metrics = MetricsUM( + metrics = persistentListOf( + infoPoint, + infoPoint, + infoPoint, + ), + ), + pricePerformance = PricePerformanceUM( + h24 = PricePerformanceUM.Value( + low = "1", + high = "2", + indicatorFraction = 0.3f, + ), + month = PricePerformanceUM.Value( + low = "1", + high = "2", + indicatorFraction = 0.3f, + ), + all = PricePerformanceUM.Value( + low = "1", + high = "2", + indicatorFraction = 0.3f, + ), + onIntervalChanged = {}, + ), + listedOn = ListedOnUM.Empty, + links = null, + ), + ), + bottomSheetConfig = TangemBottomSheetConfig( + isShown = false, + onDismissRequest = {}, + content = TangemBottomSheetConfigContent.Empty, + ), + isMarkerSet = true, + triggerPriceChange = consumedEvent(), + onShouldShowPriceSubtitleChange = {}, + shouldShowPriceSubtitle = false, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/SecurityScorePreviewData.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/SecurityScorePreviewData.kt new file mode 100644 index 0000000000..4ef81e47c6 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/SecurityScorePreviewData.kt @@ -0,0 +1,60 @@ +package com.tangem.features.feed.ui.market.detailed.preview + +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreBottomSheetContent + +internal object SecurityScorePreviewData { + + val bottomSheetContent = SecurityScoreBottomSheetContent( + title = stringReference("Security score"), + description = stringReference( + "Security score of a token is a metric that assesses the " + + "security level of a blockchain or token based on various factors and is compiled from " + + "the sources listed below.", + ), + providers = listOf( + SecurityScoreBottomSheetContent.SecurityScoreProviderUM( + name = "Moralis", + lastAuditDate = "21.10.2024", + score = 4.9F, + urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData( + fullUrl = "https://moralis.com/", + rootHost = "moralis.com", + ), + iconUrl = "", + ), + SecurityScoreBottomSheetContent.SecurityScoreProviderUM( + name = "Certik", + lastAuditDate = "10.07.2024", + score = 4.6F, + urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData( + fullUrl = "https://certik.com/", + rootHost = "certik.com", + ), + iconUrl = "", + ), + SecurityScoreBottomSheetContent.SecurityScoreProviderUM( + name = "Cyberscope", + lastAuditDate = "25.06.2023", + score = 4.5F, + urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData( + fullUrl = "https://cyberscope.com/", + rootHost = "cyberscope.com", + ), + iconUrl = "", + ), + SecurityScoreBottomSheetContent.SecurityScoreProviderUM( + name = "TokenInsight", + lastAuditDate = "17.01.2022", + score = 4.0F, + urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData( + fullUrl = "https://tokeninsight.com/", + rootHost = "tokeninsight.com", + ), + iconUrl = "", + ), + + ), + onProviderLinkClick = {}, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/ExchangesBottomSheetContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/ExchangesBottomSheetContent.kt new file mode 100644 index 0000000000..2647413b82 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/ExchangesBottomSheetContent.kt @@ -0,0 +1,72 @@ +package com.tangem.features.feed.ui.market.detailed.state + +import androidx.annotation.StringRes +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.plus +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.feed.impl.R +import com.tangem.utils.StringsSigns.DOT +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +/** + * Exchanges bottom sheet content + * +[REDACTED_AUTHOR] + */ +internal sealed interface ExchangesBottomSheetContent : TangemBottomSheetConfigContent { + + /** Title of bottom sheet. Like, app bar. */ + @get:StringRes + val titleResId: Int + get() = R.string.markets_token_details_exchanges_title + + /** Subtitle */ + @get:StringRes + val subtitleResId: Int + get() = R.string.markets_token_details_exchange + + /** Volume info */ + val volumeReference: TextReference + get() = resourceReference(id = R.string.markets_token_details_volume) + + stringReference(value = " $DOT ") + + resourceReference(id = R.string.markets_selector_interval_24h_title) + + /** Exchange items */ + val exchangeItems: ImmutableList + + /** + * Loading state + * + * @property exchangesCount count of exchanges + */ + data class Loading(val exchangesCount: Int) : ExchangesBottomSheetContent { + + override val exchangeItems: ImmutableList + get() = List(size = exchangesCount) { index -> TokenItemState.Loading(id = "loading#$index") } + .toImmutableList() + } + + /** + * Content state + * + * @property exchangeItems exchanges + */ + data class Content( + override val exchangeItems: ImmutableList, + ) : ExchangesBottomSheetContent + + /** Error state */ + data class Error( + val onRetryClick: () -> Unit, + ) : ExchangesBottomSheetContent { + override val exchangeItems: ImmutableList = persistentListOf() + + @StringRes + val message: Int = R.string.markets_loading_error_title + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/InfoBottomSheetContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/InfoBottomSheetContent.kt new file mode 100644 index 0000000000..42420bf5be --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/InfoBottomSheetContent.kt @@ -0,0 +1,13 @@ +package com.tangem.features.feed.ui.market.detailed.state + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference + +internal data class InfoBottomSheetContent( + val title: TextReference, + val body: TextReference, + val generatedAINotificationUM: GeneratedAINotificationUM? = null, +) : TangemBottomSheetConfigContent { + + data class GeneratedAINotificationUM(val onClick: () -> Unit) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/InfoPointUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/InfoPointUM.kt new file mode 100644 index 0000000000..933be4b48c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/InfoPointUM.kt @@ -0,0 +1,14 @@ +package com.tangem.features.feed.ui.market.detailed.state + +import com.tangem.core.ui.extensions.TextReference + +internal data class InfoPointUM( + val title: TextReference, + val value: String, + val change: ChangeType? = null, + val onInfoClick: (() -> Unit)? = null, +) { + enum class ChangeType { + UP, DOWN + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/InsightsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/InsightsUM.kt new file mode 100644 index 0000000000..76137e14dd --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/InsightsUM.kt @@ -0,0 +1,12 @@ +package com.tangem.features.feed.ui.market.detailed.state + +import com.tangem.domain.markets.PriceChangeInterval +import kotlinx.collections.immutable.ImmutableList + +internal data class InsightsUM( + val h24Info: ImmutableList, + val weekInfo: ImmutableList, + val monthInfo: ImmutableList, + val onInfoClick: () -> Unit, + val onIntervalChanged: (PriceChangeInterval) -> Unit, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/LinksUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/LinksUM.kt new file mode 100644 index 0000000000..4440e39fcf --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/LinksUM.kt @@ -0,0 +1,18 @@ +package com.tangem.features.feed.ui.market.detailed.state + +import androidx.annotation.DrawableRes +import kotlinx.collections.immutable.ImmutableList + +internal data class LinksUM( + val officialLinks: ImmutableList, + val social: ImmutableList, + val repository: ImmutableList, + val blockchainSite: ImmutableList, + val onLinkClick: (Link) -> Unit, +) { + data class Link( + @DrawableRes val iconRes: Int, + val title: String, + val url: String, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/ListedOnUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/ListedOnUM.kt new file mode 100644 index 0000000000..4f4876f07b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/ListedOnUM.kt @@ -0,0 +1,44 @@ +package com.tangem.features.feed.ui.market.detailed.state + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.features.feed.impl.R + +/** + * "Listed on" block UI model + * +[REDACTED_AUTHOR] + */ +internal sealed interface ListedOnUM { + + /** Title */ + val title: TextReference + get() = resourceReference(id = R.string.markets_token_details_listed_on) + + /** Description */ + val description: TextReference + + /** Empty state. No exchanges found */ + data object Empty : ListedOnUM { + override val description = resourceReference(id = R.string.markets_token_details_empty_exchanges) + } + + /** + * Content with number of exchanges + * + * @property onClick lambda be invoked when button is clicked + * @property amount amount of exchanges + */ + data class Content( + val onClick: () -> Unit, + private val amount: Int, + ) : ListedOnUM { + override val description: TextReference = pluralReference( + id = R.plurals.markets_token_details_amount_exchanges, + count = amount, + formatArgs = wrappedList(amount), + ) + } +} \ 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 new file mode 100644 index 0000000000..86685f6ac9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt @@ -0,0 +1,72 @@ +package com.tangem.features.feed.ui.market.detailed.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.charts.state.MarketChartDataProducer +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.markets.PriceChangeInterval +import java.math.BigDecimal + +internal data class MarketsTokenDetailsUM( + val tokenName: String, + val priceText: String, + val iconUrl: String?, + val dateTimeText: TextReference, + val priceChangePercentText: String?, + val priceChangeType: PriceChangeType, + val selectedInterval: PriceChangeInterval, + val isMarkerSet: Boolean, + val chartState: ChartState, + val onSelectedIntervalChange: (PriceChangeInterval) -> Unit, + val bottomSheetConfig: TangemBottomSheetConfig, + val triggerPriceChange: StateEvent, + val body: Body, + val shouldShowPriceSubtitle: Boolean, + val onShouldShowPriceSubtitleChange: (Boolean) -> Unit, +) { + + data class ChartState( + val status: Status, + val dataProducer: MarketChartDataProducer, + val onLoadRetryClick: () -> Unit, + val onMarkerPointSelected: (time: BigDecimal?, price: BigDecimal?) -> Unit, + ) { + enum class Status { + LOADING, ERROR, DATA + } + } + + data class InformationBlocks( + val insights: InsightsUM?, + val securityScore: SecurityScoreUM?, + val metrics: MetricsUM?, + val pricePerformance: PricePerformanceUM?, + val listedOn: ListedOnUM, + val links: LinksUM?, + ) + + @Immutable + sealed interface Body { + + data class Error( + val onLoadRetryClick: () -> Unit, + ) : Body + + data object Loading : Body + + data class Content( + val description: Description?, + val infoBlocks: InformationBlocks, + ) : Body + + data object Nothing : Body + } + + data class Description( + val shortDescription: TextReference, + val fullDescription: TextReference?, + val onReadMoreClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MetricsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MetricsUM.kt new file mode 100644 index 0000000000..75552e0a4a --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MetricsUM.kt @@ -0,0 +1,7 @@ +package com.tangem.features.feed.ui.market.detailed.state + +import kotlinx.collections.immutable.ImmutableList + +internal data class MetricsUM( + val metrics: ImmutableList, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/PricePerformanceUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/PricePerformanceUM.kt new file mode 100644 index 0000000000..4bc72c92dd --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/PricePerformanceUM.kt @@ -0,0 +1,17 @@ +package com.tangem.features.feed.ui.market.detailed.state + +import androidx.annotation.FloatRange +import com.tangem.domain.markets.PriceChangeInterval + +internal data class PricePerformanceUM( + val h24: Value, + val month: Value, + val all: Value, + val onIntervalChanged: (PriceChangeInterval) -> Unit, +) { + data class Value( + val low: String, + val high: String, + @FloatRange(from = 0.0, to = 1.0) val indicatorFraction: Float, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/SecurityScoreBottomSheetContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/SecurityScoreBottomSheetContent.kt new file mode 100644 index 0000000000..f49944ea9f --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/SecurityScoreBottomSheetContent.kt @@ -0,0 +1,25 @@ +package com.tangem.features.feed.ui.market.detailed.state + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference + +internal data class SecurityScoreBottomSheetContent( + val title: TextReference, + val description: TextReference, + val providers: List, + val onProviderLinkClick: (SecurityScoreProviderUM) -> Unit, +) : TangemBottomSheetConfigContent { + + data class SecurityScoreProviderUM( + val name: String, + val lastAuditDate: String?, + val score: Float, + val urlData: UrlData?, + val iconUrl: String?, + ) { + data class UrlData( + val fullUrl: String, + val rootHost: String?, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/SecurityScoreUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/SecurityScoreUM.kt new file mode 100644 index 0000000000..73fbf6ccf1 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/SecurityScoreUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.feed.ui.market.detailed.state + +import androidx.annotation.FloatRange +import com.tangem.core.ui.extensions.TextReference + +internal data class SecurityScoreUM( + @FloatRange(from = 0.0, to = 5.0) val score: Float, + val description: TextReference, + val onInfoClick: () -> Unit, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt index b4024d9bce..0268627666 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt @@ -38,10 +38,10 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.feed.impl.R +import com.tangem.features.feed.model.market.list.state.* import com.tangem.features.feed.ui.market.list.components.MarketsListLazyColumn import com.tangem.features.feed.ui.market.list.components.MarketsListSortByBottomSheet import com.tangem.features.feed.ui.market.list.components.YieldSupplyInMarketsPromoNotification -import com.tangem.features.feed.ui.market.list.state.* import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay @@ -49,7 +49,12 @@ import kotlinx.coroutines.delay private const val SHOW_MORE_KEY = "privacyPolicy" @Composable -internal fun TopBarWithSearch(onBackClick: () -> Unit, onSearchClick: () -> Unit, marketsSearchBar: MarketsSearchBar) { +internal fun TopBarWithSearch( + buttonsEnabled: Boolean, + onBackClick: () -> Unit, + onSearchClick: () -> Unit, + marketsSearchBar: MarketsSearchBar, +) { val background = LocalMainBottomSheetColor.current.value val focusRequester: FocusRequester = remember { FocusRequester() } @@ -59,6 +64,8 @@ internal fun TopBarWithSearch(onBackClick: () -> Unit, onSearchClick: () -> Unit if (showAppBarWithBackIcon) { AppBarWithBackButtonAndIcon( onBackClick = onBackClick, + backButtonEnabled = buttonsEnabled, + endButtonEnabled = buttonsEnabled, text = stringResourceSafe(R.string.markets_common_title), iconRes = R.drawable.ic_search_24, onIconClick = onSearchClick, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt index 495d901693..86d36b10cb 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt @@ -25,7 +25,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.test.MarketsTestTags import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.feed.impl.R -import com.tangem.features.feed.ui.market.list.state.ListUM +import com.tangem.features.feed.model.market.list.state.ListUM import kotlinx.coroutines.launch private const val LOAD_NEXT_PAGE_ON_END_INDEX = 50 diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListSortByBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListSortByBottomSheet.kt index 52f19e96f5..3b4d9ab05d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListSortByBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListSortByBottomSheet.kt @@ -18,8 +18,8 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.feed.impl.R -import com.tangem.features.feed.ui.market.list.state.SortByBottomSheetContentUM -import com.tangem.features.feed.ui.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.state.SortByBottomSheetContentUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM @Composable fun MarketsListSortByBottomSheet(config: TangemBottomSheetConfig) { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt index 32454e1a3f..a48d9edade 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt @@ -54,7 +54,10 @@ internal class WalletComponent @AssistedInject constructor( private val model: WalletModel = getOrCreateModel() private val feedEntryComponent by lazy { - feedEntryComponentFactory.create(child("feedEntryComponent")) + feedEntryComponentFactory.create( + context = child("feedEntryComponent"), + entryRoute = null, + ) } private val marketsEntryComponent by lazy { marketsEntryComponentFactory.create(child("marketsEntryComponent")) From c922665d8bceeedfd381cdbc5222fff7456a24dd Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Dec 2025 12:07:07 +0300 Subject: [PATCH 016/172] Updated on 2026-08-14 --- .../presentation/model/StakingClickIntents.kt | 2 + .../impl/presentation/model/StakingModel.kt | 12 ++ .../presentation/state/StakingNotification.kt | 13 +- .../state/stub/StakingClickIntentsStub.kt | 2 + .../AddStakingNotificationsTransformer.kt | 182 +++++++++++------- ...issStakingNotificationsStateTransformer.kt | 2 +- .../StakingInfoNotificationsFactory.kt | 71 ++++++- 7 files changed, 203 insertions(+), 81 deletions(-) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt index 9d21d90396..7f0264f917 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt @@ -76,4 +76,6 @@ internal interface StakingClickIntents : AmountScreenClickIntents { fun onActivateTonAccountNotificationShow() fun onActivateTonAccountClick() + + fun onAmountReduceByFeeClick(reduceAmount: BigDecimal, notification: Class) } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index c7556c4f23..30806bf543 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -6,6 +6,7 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer +import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary import com.tangem.common.ui.bottomsheet.permission.state.ApproveType @@ -1081,6 +1082,17 @@ internal class StakingModel @Inject constructor( } } + override fun onAmountReduceByFeeClick(reduceAmount: BigDecimal, notification: Class) { + stateController.update( + AmountReduceByStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + value = ReduceByData(reduceAmount, reduceAmount), + minimumTransactionAmount = null, + ), + ) + onNotificationCancel(notification) + } + private suspend fun setupApprovalNeeded() { val approval = StakingIntegrationID.create(currencyId = cryptoCurrencyStatus.currency.id)?.approval ?: StakingApproval.Empty diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt index b5485b47ba..f1b90ef243 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt @@ -89,9 +89,20 @@ internal object StakingNotification { subtitle = subtitleText, ) - data object StakeEntireBalance : Info( + data class StakeEntireBalance( + private val reduceAmountValue: TextReference?, + private val onReduceClick: () -> Unit, + ) : Info( title = resourceReference(R.string.common_network_fee_title), subtitle = resourceReference(R.string.staking_notification_stake_entire_balance_text), + buttonsState = if (reduceAmountValue != null) { + NotificationConfig.ButtonsState.PrimaryButtonConfig( + text = reduceAmountValue, + onClick = onReduceClick, + ) + } else { + null + }, ) data class Unstake( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt index 5b91544cd2..1584adb3a1 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt @@ -84,4 +84,6 @@ internal object StakingClickIntentsStub : StakingClickIntents { override fun onActivateTonAccountNotificationShow() {} override fun onActivateTonAccountClick() {} + + override fun onAmountReduceByFeeClick(reduceAmount: BigDecimal, notification: Class) {} } \ 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 9912043b5d..636649a4ad 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 @@ -33,6 +33,7 @@ import com.tangem.lib.crypto.BlockchainUtils.isTon import com.tangem.utils.Provider import com.tangem.utils.extensions.orZero import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal @@ -56,29 +57,44 @@ internal class AddStakingNotificationsTransformer( isSubtractAvailable = isSubtractAvailable, ) - @Suppress("LongMethod") override fun transform(prevState: StakingUiState): StakingUiState { val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() - val balance = cryptoCurrencyStatus.value.amount.orZero() - val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState val amountState = prevState.amountState as? AmountState.Data ?: return prevState - val feeState = confirmationState.feeState as? FeeState.Content - val amountValue = amountState.amountTextField.cryptoAmount.value.orZero() - val feeValue = feeState?.fee?.amount?.value.orZero() - val reduceAmountBy = confirmationState.reduceAmountBy.orZero() - - val isEnterAction = prevState.actionType is StakingActionCommonType.Enter - val isFeeCoverage = checkFeeCoverage( - amountValue = amountValue, - feeValue = feeValue, - balance = balance, - isSubtractAvailable = isSubtractAvailable, - reduceAmountBy = reduceAmountBy, + val sendingAmount = calculateSendingAmount( + prevState = prevState, + cryptoCurrencyStatus = cryptoCurrencyStatus, + amountState = amountState, + confirmationState = confirmationState, ) - val minimumRequirement = integration.enterMinimumAmount.orZero() - val sendingAmount = if (isEnterAction) { + val notifications = buildNotifications( + prevState = prevState, + amountState = amountState, + confirmationState = confirmationState, + sendingAmount = sendingAmount, + ) + val isActualSources = areSourcesActual(cryptoCurrencyStatus) + + return prevState.copy( + confirmationState = confirmationState.copy( + notifications = notifications, + isPrimaryButtonEnabled = isPrimaryButtonEnabled(notifications, isActualSources), + ), + ) + } + + private fun calculateSendingAmount( + prevState: StakingUiState, + cryptoCurrencyStatus: CryptoCurrencyStatus, + amountState: AmountState.Data, + confirmationState: StakingStates.ConfirmationState.Data, + ): BigDecimal { + val isEnterAction = prevState.actionType is StakingActionCommonType.Enter + return if (isEnterAction) { + val amountValue = amountState.amountTextField.cryptoAmount.value.orZero() + val feeValue = (confirmationState.feeState as? FeeState.Content)?.fee?.amount?.value.orZero() + val reduceAmountBy = confirmationState.reduceAmountBy.orZero() checkAndCalculateSubtractedAmount( isAmountSubtractAvailable = isSubtractAvailable, cryptoCurrencyStatus = cryptoCurrencyStatus, @@ -87,63 +103,89 @@ internal class AddStakingNotificationsTransformer( reduceAmountBy = reduceAmountBy, ) } else { - // No amount is taken from account balance on exit or pending actions BigDecimal.ZERO } - - val notifications = if (isAccountInitializedProvider.invoke()) { - buildList { - // errors - addErrorNotifications( - prevState = prevState, - feeError = feeError, - sendingAmount = sendingAmount, - onReload = prevState.clickIntents::getFee, - feeValue = feeValue, - ) - addStakingErrorNotifications(stakingError = stakingError, onReload = prevState.clickIntents::getFee) - // warnings - addWarningNotifications( - prevState = prevState, - amountState = amountState, - feeState = feeState, - sendingAmount = sendingAmount, - isFeeCoverage = isFeeCoverage && isEnterAction && !sendingAmount.equals(minimumRequirement), - ) - - stakingInfoNotificationsFactory.addInfoNotifications( - notifications = this, - prevState = prevState, - sendingAmount = sendingAmount, - actionAmount = amountValue, - feeValue = feeValue, - tonBalanceExtraFeeThreshold = TON_BALANCE_EXTRA_FEE_THRESHOLD, - ) - }.toImmutableList() - } else { - buildList { - addTonInitializeAccountNotification(prevState) - }.toImmutableList() - } - - val isActualSources = with(cryptoCurrencyStatus.value) { - sources.stakingBalanceSource.isActual() && sources.networkSource.isActual() - } - - return prevState.copy( - confirmationState = confirmationState.copy( - notifications = notifications.toImmutableList(), - isPrimaryButtonEnabled = notifications.none { - it is StakingNotification.Error || - it is NotificationUM.Error || - it is NotificationUM.Warning.NetworkFeeUnreachable || - it is StakingNotification.Warning.TransactionInProgress || - it is StakingNotification.Warning.InitializeTonAccount - } && isActualSources, - ), - ) } + private fun buildNotifications( + prevState: StakingUiState, + amountState: AmountState.Data, + confirmationState: StakingStates.ConfirmationState.Data, + sendingAmount: BigDecimal, + ) = if (isAccountInitializedProvider.invoke()) { + buildInitializedAccountNotifications( + prevState = prevState, + amountState = amountState, + confirmationState = confirmationState, + sendingAmount = sendingAmount, + ) + } else { + buildList { addTonInitializeAccountNotification(prevState) }.toImmutableList() + } + + private fun buildInitializedAccountNotifications( + prevState: StakingUiState, + amountState: AmountState.Data, + confirmationState: StakingStates.ConfirmationState.Data, + sendingAmount: BigDecimal, + ) = buildList { + val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() + val balance = cryptoCurrencyStatus.value.amount.orZero() + val feeState = confirmationState.feeState as? FeeState.Content + val amountValue = amountState.amountTextField.cryptoAmount.value.orZero() + val feeValue = feeState?.fee?.amount?.value.orZero() + val reduceAmountBy = confirmationState.reduceAmountBy.orZero() + val isEnterAction = prevState.actionType is StakingActionCommonType.Enter + val minimumRequirement = integration.enterMinimumAmount.orZero() + + val isFeeCoverage = checkFeeCoverage( + amountValue = amountValue, + feeValue = feeValue, + balance = balance, + isSubtractAvailable = isSubtractAvailable, + reduceAmountBy = reduceAmountBy, + ) + + addErrorNotifications( + prevState = prevState, + feeError = feeError, + sendingAmount = sendingAmount, + onReload = prevState.clickIntents::getFee, + feeValue = feeValue, + ) + addStakingErrorNotifications(stakingError = stakingError, onReload = prevState.clickIntents::getFee) + addWarningNotifications( + prevState = prevState, + amountState = amountState, + feeState = feeState, + sendingAmount = sendingAmount, + isFeeCoverage = isFeeCoverage && isEnterAction && !sendingAmount.equals(minimumRequirement), + ) + stakingInfoNotificationsFactory.addInfoNotifications( + notifications = this, + prevState = prevState, + sendingAmount = sendingAmount, + actionAmount = amountValue, + feeAmount = feeState?.fee?.amount, + isFeeApproximate = feeState?.isFeeApproximate == true, + onAmountReduceByFeeClick = prevState.clickIntents::onAmountReduceByFeeClick, + tonBalanceExtraFeeThreshold = TON_BALANCE_EXTRA_FEE_THRESHOLD, + ) + }.toImmutableList() + + private fun areSourcesActual(cryptoCurrencyStatus: CryptoCurrencyStatus) = with(cryptoCurrencyStatus.value) { + sources.stakingBalanceSource.isActual() && sources.networkSource.isActual() + } + + private fun isPrimaryButtonEnabled(notifications: ImmutableList, isActualSources: Boolean) = + notifications.none { + it is StakingNotification.Error || + it is NotificationUM.Error || + it is NotificationUM.Warning.NetworkFeeUnreachable || + it is StakingNotification.Warning.TransactionInProgress || + it is StakingNotification.Warning.InitializeTonAccount + } && isActualSources + private fun MutableList.addStakingErrorNotifications( stakingError: StakingError?, onReload: () -> Unit, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/DismissStakingNotificationsStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/DismissStakingNotificationsStateTransformer.kt index ba9f953c0d..7c05c0ad2e 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/DismissStakingNotificationsStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/DismissStakingNotificationsStateTransformer.kt @@ -13,7 +13,7 @@ internal class DismissStakingNotificationsStateTransformer( override fun transform(prevState: StakingUiState): StakingUiState { val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data val updatedNotifications = confirmationState?.notifications - ?.filterNot { it::class == notification }?.toPersistentList() + ?.filterNot { it::class.java == notification }?.toPersistentList() ?: persistentListOf() return prevState.copy( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt index 905ca35ddb..908f550629 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt @@ -1,9 +1,13 @@ package com.tangem.features.staking.impl.presentation.state.transformers.notifications +import com.tangem.blockchain.common.Amount import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fee +import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.staking.BalanceType import com.tangem.domain.models.staking.StakingBalance @@ -35,7 +39,10 @@ internal class StakingInfoNotificationsFactory( * @param prevState current screen state to update * @param sendingAmount amount being transferred from user account * @param actionAmount any amount being transferred or used action - * @param feeValue fee amount payed from user account + * @param feeAmount fee amount payed from user account + * @param tonBalanceExtraFeeThreshold threshold for TON extra fee notification + * @param isFeeApproximate whether fee is approximate + * @param onAmountReduceByFeeClick callback to reduce amount by fee */ @Suppress("LongParameterList") fun addInfoNotifications( @@ -43,17 +50,24 @@ internal class StakingInfoNotificationsFactory( prevState: StakingUiState, sendingAmount: BigDecimal, actionAmount: BigDecimal, - feeValue: BigDecimal, + feeAmount: Amount?, tonBalanceExtraFeeThreshold: BigDecimal, + isFeeApproximate: Boolean, + onAmountReduceByFeeClick: (BigDecimal, notification: Class) -> Unit, ) = with(notifications) { addStakingLowBalanceNotification(prevState, actionAmount) addTonExtraFeeInfoNotification(tonBalanceExtraFeeThreshold) when (prevState.actionType) { - is StakingActionCommonType.Enter -> addEnterInfoNotifications(sendingAmount, feeValue) + is StakingActionCommonType.Enter -> addEnterInfoNotifications( + sendingAmount = sendingAmount, + feeAmount = feeAmount, + isFeeApproximate = isFeeApproximate, + onAmountReduceByFeeClick = onAmountReduceByFeeClick, + ) is StakingActionCommonType.Exit -> addExitInfoNotifications(prevState) is StakingActionCommonType.Pending -> { - addCardanoRestakeMinimumAmountNotification(feeValue) + addCardanoRestakeMinimumAmountNotification(feeAmount?.value.orZero()) addPendingInfoNotifications(prevState) addTonHaveToUnstakeAllNotification(prevState) } @@ -67,12 +81,19 @@ internal class StakingInfoNotificationsFactory( private fun MutableList.addEnterInfoNotifications( sendingAmount: BigDecimal, - feeValue: BigDecimal, + feeAmount: Amount?, + isFeeApproximate: Boolean, + onAmountReduceByFeeClick: (BigDecimal, notification: Class) -> Unit, ) { - addCardanoStakeMinimumAmountNotification(feeValue) + addCardanoStakeMinimumAmountNotification(feeAmount?.value.orZero()) addTronRevoteNotification() addCardanoStakeNotification() - addStakingEntireBalanceNotification(sendingAmount, feeValue) + addStakingEntireBalanceNotification( + sendingAmount = sendingAmount, + feeAmount = feeAmount, + isFeeApproximate = isFeeApproximate, + onAmountReduceByFeeClick = onAmountReduceByFeeClick, + ) } private fun MutableList.addPendingInfoNotifications(prevState: StakingUiState) { @@ -194,15 +215,46 @@ internal class StakingInfoNotificationsFactory( private fun MutableList.addStakingEntireBalanceNotification( sendingAmount: BigDecimal, - feeValue: BigDecimal, + feeAmount: Amount?, + isFeeApproximate: Boolean, + onAmountReduceByFeeClick: (BigDecimal, notification: Class) -> Unit, ) { val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() val balance = cryptoCurrencyStatus.value.amount.orZero() + val feeValue = feeAmount?.value.orZero() val isEntireBalance = sendingAmount.plus(feeValue) == balance if (isEntireBalance && isSubtractAvailable && !isCardano(cryptoCurrencyStatus.currency.network.rawId)) { - add(StakingNotification.Info.StakeEntireBalance) + val value = feeValue.multiply(FEE_DECIMALS_MULTIPLIER) + + val reduceAmountValue = feeAmount + ?.takeIf { feeValue != BigDecimal.ZERO } + ?.let { amount -> + resourceReference( + R.string.send_notification_reduce_by, + wrappedList( + value.format { + crypto( + symbol = amount.currencySymbol, + decimals = amount.decimals, + ).fee(canBeLower = isFeeApproximate) + }, + ), + ) + } + + add( + StakingNotification.Info.StakeEntireBalance( + reduceAmountValue, + { + onAmountReduceByFeeClick.invoke( + value, + StakingNotification.Info.StakeEntireBalance::class.java, + ) + }, + ), + ) } } @@ -286,5 +338,6 @@ internal class StakingInfoNotificationsFactory( private companion object { val MINIMUM_STAKE_BALANCE = "5".toBigDecimal() val MINIMUM_RESTAKE_BALANCE = "3".toBigDecimal() + val FEE_DECIMALS_MULTIPLIER = "3".toBigDecimal() } } \ No newline at end of file From d661eda82a67f2cca72860e950fbb0762f0f1e07 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Dec 2025 16:09:50 +0500 Subject: [PATCH 017/172] Updated on 2026-08-14 --- ...downExtension.kt => AnnotatedStringExt.kt} | 19 ++ .../core/ui/extensions/ColorReference.kt | 3 +- .../core/ui/extensions/SpanStyleReference.kt | 19 ++ .../core/ui/extensions/TextReference.kt | 241 +++++++++++++++++- 4 files changed, 271 insertions(+), 11 deletions(-) rename core/ui/src/main/java/com/tangem/core/ui/extensions/{MarkdownExtension.kt => AnnotatedStringExt.kt} (86%) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/extensions/SpanStyleReference.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/MarkdownExtension.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/AnnotatedStringExt.kt similarity index 86% rename from core/ui/src/main/java/com/tangem/core/ui/extensions/MarkdownExtension.kt rename to core/ui/src/main/java/com/tangem/core/ui/extensions/AnnotatedStringExt.kt index f1e49e308f..e824de1e5d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/MarkdownExtension.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/AnnotatedStringExt.kt @@ -56,12 +56,31 @@ fun AnnotatedString.Builder.appendMarkdown(markdownText: String, node: ASTNode): return this } +/** + * Appends a single space character to the [AnnotatedString.Builder]. + */ fun AnnotatedString.Builder.appendSpace() = append(" ") +/** + * Appends text with the specified [Color] to the [AnnotatedString.Builder]. + * + * @param text The text to append. + * @param color The [Color] to apply to the appended text. + */ fun AnnotatedString.Builder.appendColored(text: String, color: Color) = withStyle(SpanStyle(color = color)) { append(text) } +/** + * Appends text with the specified [SpanStyle] to the [AnnotatedString.Builder]. + * + * @param text The text to append. + * @param spanStyle The [SpanStyle] to apply to the appended text. + */ +fun AnnotatedString.Builder.appendStyled(text: String, spanStyle: SpanStyle) = withStyle(spanStyle) { + append(text) +} + /** * Appends text from a template string to the AnnotatedString.Builder, replacing a placeholder (default "%s") * with custom styled content provided by a lambda. The lambda allows you to insert styled or complex content diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt index 8add413e59..604a8a16e1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt @@ -7,10 +7,11 @@ import androidx.compose.ui.graphics.Color /** * Utility class for keeping themed color reference from app theme. * - * It necessary to use [Immutable] annotation for runtime stability. + * It is necessary to use [Immutable] annotation for runtime stability. * * @property value color provider from theme */ +@Deprecated("Use TextReference with applied SpanStyleReference for colored text.") @Immutable data class ColorReference(val value: @Composable () -> Color) diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/SpanStyleReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/SpanStyleReference.kt new file mode 100644 index 0000000000..bd4ab76903 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/SpanStyleReference.kt @@ -0,0 +1,19 @@ +package com.tangem.core.ui.extensions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable +import androidx.compose.ui.text.SpanStyle + +/** + * Utility functional interface for keeping themed [SpanStyle] reference from app theme. + * It is necessary to use [Stable] annotation for runtime stability. + */ +@Stable +@FunctionalInterface +fun interface SpanStyleReference { + + @ReadOnlyComposable + @Composable + operator fun invoke(): SpanStyle +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt index 9141b2e002..9997470e8c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt @@ -1,16 +1,33 @@ package com.tangem.core.ui.extensions +import android.content.res.Configuration import android.content.res.Resources import androidx.annotation.PluralsRes import androidx.annotation.StringRes +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.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString.Builder +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink +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.res.getPluralStringSafe import com.tangem.core.res.getStringSafe +import com.tangem.core.ui.R +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview import com.tangem.utils.StringsSigns.THREE_STARS import org.intellij.markdown.MarkdownElementTypes import kotlin.contracts.ExperimentalContracts @@ -69,6 +86,35 @@ sealed interface TextReference { */ data class Combined(val refs: WrappedList) : TextReference + /** + * Styled string value + * + * @property value string value + * @property spanStyleReference text style reference + * @property onClick optional click action + */ + data class StyledStr( + val value: String, + val spanStyleReference: SpanStyleReference, + val onClick: (() -> Unit)? = null, + ) : TextReference + + /** + * Styled string resource + * + * @property id resource id + * @property formatArgs arguments. Impossible to use [kotlinx.collections.immutable.ImmutableList] because + * [Any] is unstable. + * @property spanStyleReference text style reference + * @property onClick optional click action + */ + data class StyledRes( + @StringRes val id: Int, + val formatArgs: WrappedList = WrappedList(emptyList()), + val spanStyleReference: SpanStyleReference, + val onClick: (() -> Unit)? = null, + ) : TextReference + companion object { /** Empty string as [TextReference] */ @@ -139,6 +185,42 @@ fun pluralReference( return TextReference.PluralRes(id, count, formatArgs) } +/** + * Creates a [TextReference] using a plain string value with optional span style and click action. + * + * @param value The plain string value. + * @param spanStyleReference A [SpanStyleReference] representing the text style to be applied. + * @param onClick An optional lambda function to be invoked when the text is clicked. + * @return A [TextReference] representing the styled string with click action. + */ +fun styledStringReference(value: String, spanStyleReference: SpanStyleReference, onClick: (() -> Unit)? = null) = + TextReference.StyledStr( + value = value, + onClick = onClick, + spanStyleReference = spanStyleReference, + ) + +/** + * Creates a [TextReference] using a string resource ID with optional format arguments, span style, and click action. + * + * @param id The resource ID of the string. + * @param formatArgs A list of format arguments to be applied to the string resource. + * @param spanStyleReference A [SpanStyleReference] representing the text style to be applied. + * @param onClick An optional lambda function to be invoked when the text is clicked. + * @return A [TextReference] representing the styled string with click action. + */ +fun styledResourceReference( + @StringRes id: Int, + formatArgs: WrappedList = WrappedList(emptyList()), + spanStyleReference: SpanStyleReference, + onClick: (() -> Unit)? = null, +) = TextReference.StyledRes( + id = id, + formatArgs = formatArgs, + spanStyleReference = spanStyleReference, + onClick = onClick, +) + /** * Combines multiple [TextReference] instances into a single [TextReference]. * @@ -165,9 +247,7 @@ fun combinedReference(vararg refs: TextReference): TextReference { fun TextReference.resolveReference(): String { return when (this) { is TextReference.Res -> { - val args = formatArgs - .map { if (it is TextReference) it.resolveReference() else it } - .toTypedArray() + val args = formatArgs.map { if (it is TextReference) it.resolveReference() else it }.toTypedArray() val resolvedReference = stringResourceSafe(id = id, *args) @@ -187,6 +267,12 @@ fun TextReference.resolveReference(): String { } } } + is TextReference.StyledRes -> { + val args = formatArgs.map { if (it is TextReference) it.resolveReference() else it }.toTypedArray() + + stringResourceSafe(id = id, *args) + } + is TextReference.StyledStr -> value } } @@ -194,9 +280,7 @@ fun TextReference.resolveReference(): String { fun TextReference.resolveReference(resources: Resources): String { return when (this) { is TextReference.Res -> { - val args = formatArgs - .map { if (it is TextReference) it.resolveReference(resources) else it } - .toTypedArray() + val args = formatArgs.map { if (it is TextReference) it.resolveReference(resources) else it }.toTypedArray() resources.getStringSafe(id, *args) } @@ -210,6 +294,12 @@ fun TextReference.resolveReference(resources: Resources): String { } } } + is TextReference.StyledRes -> { + val args = formatArgs.map { if (it is TextReference) it.resolveReference(resources) else it }.toTypedArray() + + resources.getStringSafe(id, *args) + } + is TextReference.StyledStr -> value } } @@ -218,9 +308,7 @@ fun TextReference.resolveReference(resources: Resources): String { fun TextReference.resolveAnnotatedReference(): AnnotatedString { return when (this) { is TextReference.Res -> { - val args = formatArgs - .map { if (it is TextReference) it.resolveReference() else it } - .toTypedArray() + val args = formatArgs.map { if (it is TextReference) it.resolveReference() else it }.toTypedArray() formatAnnotated(stringResourceSafe(id = id, *args)) } @@ -234,6 +322,21 @@ fun TextReference.resolveAnnotatedReference(): AnnotatedString { append(it.resolveAnnotatedReference()) } } + is TextReference.StyledRes -> { + val args = formatArgs.map { if (it is TextReference) it.resolveReference() else it }.toTypedArray() + val text = stringResourceSafe(id = id, *args) + + createStyledText( + text = text, + spanStyleReference = spanStyleReference, + onClick = onClick, + ) + } + is TextReference.StyledStr -> createStyledText( + text = value, + spanStyleReference = spanStyleReference, + onClick = onClick, + ) } } @@ -245,6 +348,8 @@ operator fun TextReference.plus(ref: TextReference): TextReference { is TextReference.Res, is TextReference.Str, is TextReference.Annotated, + is TextReference.StyledRes, + is TextReference.StyledStr, -> TextReference.Combined(refs = wrappedList(this, ref)) } } @@ -280,4 +385,120 @@ private fun formatAnnotated(rawString: String): AnnotatedString { */ fun TextReference.orMaskWithStars(maskWithStars: Boolean): TextReference { return if (maskWithStars) stringReference(THREE_STARS) else this -} \ No newline at end of file +} + +@ReadOnlyComposable +@Composable +private fun createStyledText( + text: String, + spanStyleReference: SpanStyleReference, + onClick: (() -> Unit)?, +): AnnotatedString = buildAnnotatedString { + if (onClick != null) { + withLink( + link = LinkAnnotation.Clickable( + tag = text, + linkInteractionListener = { onClick() }, + ), + block = { + appendStyled( + text = text, + spanStyle = spanStyleReference(), + ) + }, + ) + } else { + appendStyled( + text = text, + spanStyle = spanStyleReference(), + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TextReference_Preview(@PreviewParameter(TextReferencePreviewProvider::class) params: TextReference) { + TangemThemePreview { + val uriHandler = LocalUriHandler.current + + Column( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors.background.primary) + .padding(4.dp), + ) { + Text( + text = params.resolveAnnotatedReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = styledResourceReference( + id = R.string.common_read_more, + spanStyleReference = { + TangemTheme.typography.body1.copy(TangemTheme.colors.text.accent).toSpanStyle() + }, + onClick = { + uriHandler.openUri("https://tangem.com") + }, + ).resolveAnnotatedReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = stringReference("To be masked").orMaskWithStars(true).resolveAnnotatedReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + } + } +} + +private class TextReferencePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + stringReference("Simple string"), + resourceReference(R.string.common_tangem), + pluralReference( + id = R.plurals.common_days, + count = 5, + formatArgs = wrappedList(5), + ), + styledStringReference( + value = "Styled string", + spanStyleReference = { + TangemTheme.typography.subtitle2.copy(TangemTheme.colors.text.accent).toSpanStyle() + }, + ), + styledResourceReference( + id = R.string.common_tangem, + spanStyleReference = { + TangemTheme.typography.caption1.copy(TangemTheme.colors.text.accent).toSpanStyle() + }, + ), + combinedReference( + stringReference("Simple string"), + resourceReference(R.string.common_tangem), + pluralReference( + id = R.plurals.common_days, + count = 5, + formatArgs = wrappedList(5), + ), + styledStringReference( + value = "Styled string", + spanStyleReference = { + TangemTheme.typography.subtitle2.copy(TangemTheme.colors.text.accent).toSpanStyle() + }, + ), + styledResourceReference( + id = R.string.common_tangem, + spanStyleReference = { + TangemTheme.typography.caption1.copy(TangemTheme.colors.text.warning).toSpanStyle() + }, + ), + ), + ) +} +// endregion \ No newline at end of file From 55bb4e767c2794ee778f120b21bfa9f3dad5b1eb Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Dec 2025 15:43:39 +0000 Subject: [PATCH 018/172] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 462e99b59c..36965eb1e1 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.32-1329" +tangemBlockchainSdk = "develop-1330" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.32-574" +tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 1f0d1e43aba68a0f76c27c24b9bac2ac3383c7ae Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Dec 2025 17:05:52 +0700 Subject: [PATCH 019/172] Updated on 2026-08-14 --- .../details/model/UserWalletListModel.kt | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt index badebd605e..d06b8f5e19 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt @@ -23,10 +23,7 @@ import com.tangem.features.wallet.utils.UserWalletsFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -64,13 +61,18 @@ internal class UserWalletListModel @Inject constructor( ) init { - combine( - flow = userWalletsFetcher.userWallets, - flow2 = shouldSaveUserWalletsUseCase(), - flow3 = isWalletSavingInProgress, - ) { userWallets, shouldSaveUserWallets, isWalletSavingInProgress -> - updateState(userWallets, shouldSaveUserWallets, isWalletSavingInProgress) - }.launchIn(modelScope) + modelScope.launch { + val userWalletsFlow = userWalletsFetcher.userWallets.stateIn(this) + state.update { value -> value.copy(userWallets = userWalletsFlow.value) } + + combine( + flow = userWalletsFlow, + flow2 = shouldSaveUserWalletsUseCase(), + flow3 = isWalletSavingInProgress, + ) { userWallets, shouldSaveUserWallets, isWalletSavingInProgress -> + updateState(userWallets, shouldSaveUserWallets, isWalletSavingInProgress) + }.collect() + } } private fun updateState( From 6f095384c12d62c541691c68a7bd747597a60ecc Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Dec 2025 15:12:48 +0500 Subject: [PATCH 020/172] Updated on 2026-08-14 --- .../tangem/core/ui/extensions/ModifierExt.kt | 15 + .../tangem/core/ui/res/TangemColorPalette.kt | 24 ++ .../com/tangem/core/ui/res/TangemColors2.kt | 4 + .../com/tangem/core/ui/res/TangemDimens2.kt | 39 ++ .../com/tangem/core/ui/res/TangemTheme.kt | 18 + .../tangem/core/ui/res/TangemThemePreview.kt | 26 ++ .../tangem/core/ui/res/TangemThemeRedesign.kt | 9 +- .../tangem/core/ui/res/TangemTypography.kt | 6 - .../tangem/core/ui/res/TangemTypography2.kt | 348 ++++++++++++++++++ tangem-android-tools | 2 +- 10 files changed, 481 insertions(+), 10 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens2.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography2.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt index cab83ed0d6..7b85a6004d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt @@ -48,6 +48,21 @@ fun Modifier.conditional(condition: Boolean, modifier: Modifier.() -> Modifier): } } +/** + * Conditionally applies a modifier based on a boolean condition. + */ +@Composable +fun Modifier.conditionalCompose( + condition: Boolean, + modifier: @Composable Modifier.() -> Modifier = { Modifier }, +): Modifier { + return if (condition) { + then(modifier(Modifier)) + } else { + this + } +} + /** * Conditionally applies a modifier based on a boolean condition. */ diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt index b3bfdd9a48..04801d50d7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt @@ -18,6 +18,18 @@ object TangemColorPalette { val Dark6 = Color(0xFF1E1E1E) // endregion Dark + // region Dark Alpha + val Dark_10 = Color(0x1A1E1E1E) + val Dark_20 = Color(0x331E1E1E) + val Dark_30 = Color(0x4D1E1E1E) + val Dark_40 = Color(0x661E1E1E) + val Dark_50 = Color(0x801E1E1E) + val Dark_60 = Color(0x991E1E1E) + val Dark_70 = Color(0xB31E1E1E) + val Dark_80 = Color(0xCC1E1E1E) + val Dark_90 = Color(0xE61E1E1E) + // endregion Dark Alpha + // region Light val Light1 = Color(0xFFF5F5F5) val Light1V2 = Color(0xFFF4F4F4) @@ -27,6 +39,18 @@ object TangemColorPalette { val Light5 = Color(0xFFB0B0B0) // endregion Light + // region Light Alpha + val Light_10 = Color(0x1AFFFFFF) + val Light_20 = Color(0x33FFFFFF) + val Light_30 = Color(0x4DFFFFFF) + val Light_40 = Color(0x66FFFFFF) + val Light_50 = Color(0x80FFFFFF) + val Light_60 = Color(0x99FFFFFF) + val Light_70 = Color(0xB3FFFFFF) + val Light_80 = Color(0xCCFFFFFF) + val Light_90 = Color(0xE6FFFFFF) + // endregion Light Alpha + // region Green val Green = Color(0xFF0C9F3D) val Meadow = Color(0xFF1ACE80) diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt index 68ce8e0a85..7ba4a56c58 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt @@ -162,6 +162,7 @@ class TangemColors2 internal constructor( backgroundSecondary: Color, backgroundDisabled: Color, backgroundPositive: Color, + backgroundPrimaryInverse: Color, textPrimary: Color, textSecondary: Color, textDisabled: Color, @@ -178,6 +179,8 @@ class TangemColors2 internal constructor( private set var backgroundPositive by mutableStateOf(backgroundPositive) private set + var backgroundPrimaryInverse by mutableStateOf(backgroundPrimaryInverse) + private set var textPrimary by mutableStateOf(textPrimary) private set var textSecondary by mutableStateOf(textSecondary) @@ -198,6 +201,7 @@ class TangemColors2 internal constructor( backgroundSecondary = other.backgroundSecondary backgroundDisabled = other.backgroundDisabled backgroundPositive = other.backgroundPositive + backgroundPrimaryInverse = other.backgroundPrimaryInverse textPrimary = other.textPrimary textSecondary = other.textSecondary textDisabled = other.textDisabled diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens2.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens2.kt new file mode 100644 index 0000000000..292a6555e3 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens2.kt @@ -0,0 +1,39 @@ +package com.tangem.core.ui.res + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Suppress("ConstructorParameterNaming") +@ConsistentCopyVisibility +@Immutable +data class TangemDimens2 internal constructor( + val x0: Dp = 0.dp, + val x0_5: Dp = 2.dp, + val x1: Dp = 4.dp, + val x2: Dp = 8.dp, + val x2_5: Dp = 10.dp, + val x3: Dp = 12.dp, + val x4: Dp = 16.dp, + val x5: Dp = 20.dp, + val x6: Dp = 24.dp, + val x7: Dp = 28.dp, + val x8: Dp = 32.dp, + val x9: Dp = 36.dp, + val x10: Dp = 40.dp, + val x11: Dp = 44.dp, + val x12: Dp = 48.dp, + val x13: Dp = 52.dp, + val x14: Dp = 56.dp, + val x15: Dp = 60.dp, + val x16: Dp = 64.dp, + val x17: Dp = 68.dp, + val x18: Dp = 72.dp, + val x19: Dp = 76.dp, + val x20: Dp = 80.dp, + val x21: Dp = 84.dp, + val x22: Dp = 88.dp, + val x23: Dp = 92.dp, + val x24: Dp = 96.dp, + val x25: Dp = 100.dp, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index 38b1726319..834910220f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -154,11 +154,21 @@ object TangemTheme { @ReadOnlyComposable get() = LocalTangemTypography.current + val typography2: TangemTypography2 + @Composable + @ReadOnlyComposable + get() = TangemTypography2(InterFamily) + val dimens: TangemDimens @Composable @ReadOnlyComposable get() = LocalTangemDimens.current + val dimens2: TangemDimens2 + @Composable + @ReadOnlyComposable + get() = LocalTangemDimens2.current + val shapes: TangemShapes @Composable @ReadOnlyComposable @@ -343,10 +353,18 @@ internal val LocalTangemTypography = staticCompositionLocalOf { TangemTypography(RobotoFamily) } +internal val LocalTangemTypography2 = staticCompositionLocalOf { + TangemTypography2(InterFamily) +} + private val LocalTangemDimens = staticCompositionLocalOf { TangemDimens() } +private val LocalTangemDimens2 = staticCompositionLocalOf { + TangemDimens2() +} + private val LocalTangemShapes = staticCompositionLocalOf { error("No TangemShapes provided") } diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt index dd15fd2212..6b72e745b6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt @@ -37,6 +37,32 @@ fun TangemThemePreview( } } +@Composable +fun TangemThemePreviewRedesign( + isDark: Boolean? = null, + alwaysShowBottomSheets: Boolean = true, + rtl: Boolean = false, + content: @Composable () -> Unit, +) { + val isDarkTheme = isDark ?: isSystemInDarkTheme() + + CompositionLocalProvider( + LocalBottomSheetAlwaysVisible provides alwaysShowBottomSheets, + LocalLayoutDirection provides if (rtl) LayoutDirection.Rtl else LayoutDirection.Ltr, + ) { + BoxWithConstraints { + TangemTheme( + isDark = isDarkTheme, + windowSize = rememberWindowSizePreview(maxWidth, maxHeight), + ) { + TangemThemeRedesign( + content = content, + ) + } + } + } +} + /** * This is used to make the bottom sheet always visible in the Preview and should be `true` only in the Preview. * */ 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 476a412a20..ce425e93f7 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 @@ -1,4 +1,5 @@ @file:Suppress("LongMethod") + package com.tangem.core.ui.res import androidx.compose.material3.MaterialTheme @@ -22,7 +23,7 @@ fun TangemThemeRedesign(content: @Composable () -> Unit) { CompositionLocalProvider( LocalTangemColors provides themeColors, LocalTangemColors2 provides if (LocalIsInDarkTheme.current) darkThemeColors2() else lightThemeColors2(), - LocalTangemTypography provides TangemTypography(InterFamily), + LocalTangemTypography2 provides TangemTypography2(InterFamily), LocalRootBackgroundColor provides remember(rootBackgroundColor) { mutableStateOf(rootBackgroundColor) }, ) { content() @@ -97,9 +98,10 @@ private fun lightThemeColors2(): TangemColors2 { ) val button = TangemColors2.Button( backgroundPrimary = TangemColorPalette.Dark6, - backgroundSecondary = TangemColorPalette.Dark6.copy(alpha = 0.1f), + backgroundSecondary = TangemColorPalette.Dark_10, backgroundDisabled = TangemColorPalette.Light3, backgroundPositive = TangemColorPalette.Azure, + backgroundPrimaryInverse = TangemColorPalette.White, textSecondary = TangemColorPalette.Dark6, textPrimary = TangemColorPalette.Light2, textDisabled = text.neutral.tertiary, @@ -236,9 +238,10 @@ private fun darkThemeColors2(): TangemColors2 { ) val button = TangemColors2.Button( backgroundPrimary = TangemColorPalette.Light1V2, - backgroundSecondary = TangemColorPalette.White.copy(alpha = 0.1f), + backgroundSecondary = TangemColorPalette.Light_10, backgroundDisabled = TangemColorPalette.Dark5, backgroundPositive = TangemColorPalette.Azure, + backgroundPrimaryInverse = TangemColorPalette.Light_10, textSecondary = TangemColorPalette.Light4, textPrimary = TangemColorPalette.Dark4, textDisabled = text.neutral.secondary, diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt index d50a43f6ed..a86e82124c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.TextUnit @@ -17,11 +16,6 @@ internal val RobotoFamily = FontFamily( Font(R.font.roboto_medium, FontWeight.Medium), ) -internal val InterFamily = FontFamily( - Font(R.font.inter_regular), - Font(R.font.inter_italic, style = FontStyle.Italic), -) - @Immutable class TangemTypography internal constructor( fontFamily: FontFamily, diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography2.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography2.kt new file mode 100644 index 0000000000..4973fefe6f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography2.kt @@ -0,0 +1,348 @@ +package com.tangem.core.ui.res + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tangem.core.ui.R + +internal val InterFamily = FontFamily( + Font(R.font.inter_regular), + Font(R.font.inter_italic, style = FontStyle.Italic), +) + +@Stable +class TangemTypography2 internal constructor( + fontFamily: FontFamily, +) { + val titleRegular44: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 44.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TextUnit(value = 0.37f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 48f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingRegular34: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 34.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = 0.37f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 40f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingBold34: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 34.sp, + fontWeight = FontWeight.Bold, + letterSpacing = TextUnit(value = 0.37f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 40f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingRegular28: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 28.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TextUnit(value = 0.36f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 36f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingBold28: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + letterSpacing = TextUnit(value = 0.36f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 36f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingRegular22: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 22.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = 0.35f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 28f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingBold22: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + letterSpacing = TextUnit(value = 0.35f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 28f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingRegular20: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 20.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = 0.38f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 24f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingSemibold20: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TextUnit(value = 0.38f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 24f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingRegular17: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 17.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = -0.41f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingSemibold17: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TextUnit(value = -0.2f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val bodyRegular16: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = -0.32f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val bodySemibold16: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TextUnit(value = -0.32f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val bodyRegular15: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 15.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = -0.24f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val bodySemibold15: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + letterSpacing = TextUnit(value = -0.1f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val bodyRegular14: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + letterSpacing = TextUnit(value = -0.1f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 16f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val captionRegular13: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = -0.08f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 16f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val captionSemibold13: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TextUnit(value = 0.1f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 16f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val captionRegular12: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = 0f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 16f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val captionSemibold12: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + letterSpacing = TextUnit(value = 0.1f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 16f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val captionRegular11: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = 0.07f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 12f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val captionSemibold11: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TextUnit(value = 0.15f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 12f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360, heightDp = 1500) +@Preview(showBackground = true, widthDp = 360, heightDp = 1500, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemTypography2_Preview() { + TangemThemePreviewRedesign { + val typographyList = sequenceOf( + TangemTheme.typography2.titleRegular44, + TangemTheme.typography2.headingRegular34, + TangemTheme.typography2.headingBold34, + TangemTheme.typography2.headingRegular28, + TangemTheme.typography2.headingBold28, + TangemTheme.typography2.headingRegular22, + TangemTheme.typography2.headingBold22, + TangemTheme.typography2.headingRegular20, + TangemTheme.typography2.headingSemibold20, + TangemTheme.typography2.headingRegular17, + TangemTheme.typography2.headingSemibold17, + TangemTheme.typography2.bodyRegular16, + TangemTheme.typography2.bodySemibold16, + TangemTheme.typography2.bodyRegular15, + TangemTheme.typography2.bodySemibold15, + TangemTheme.typography2.bodyRegular14, + TangemTheme.typography2.captionRegular13, + TangemTheme.typography2.captionSemibold13, + TangemTheme.typography2.captionRegular12, + TangemTheme.typography2.captionSemibold12, + TangemTheme.typography2.captionRegular11, + TangemTheme.typography2.captionSemibold11, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(4.dp), + ) { + typographyList.forEach { textStyle -> + Box(modifier = Modifier.heightIn(min = 60.dp)) { + Text( + text = "Lorem ipsum", + style = textStyle, + color = TangemTheme.colors2.text.neutral.primary, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } +} +// endregion \ No newline at end of file diff --git a/tangem-android-tools b/tangem-android-tools index d7950d60bb..fa10299aec 160000 --- a/tangem-android-tools +++ b/tangem-android-tools @@ -1 +1 @@ -Subproject commit d7950d60bb4c6353f2aa3364073c6ec72167c666 +Subproject commit fa10299aec0b3a06fda9bf6d050e0aa79609d33b From c3c587406069012b9d6e2fbc9d008258aa56bf92 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Dec 2025 18:00:59 +0700 Subject: [PATCH 021/172] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 11 +++++--- core/res/src/main/res/values-es/strings.xml | 10 ++++--- core/res/src/main/res/values-fr/strings.xml | 8 +++--- core/res/src/main/res/values-it/strings.xml | 8 +++--- core/res/src/main/res/values-ja/strings.xml | 28 +++++++++++++------ core/res/src/main/res/values-ru/strings.xml | 12 ++++---- .../src/main/res/values-uk-rUA/strings.xml | 5 +++- .../src/main/res/values-zh-rTW/strings.xml | 8 +++--- core/res/src/main/res/values/strings.xml | 28 ++++++++++++------- .../archived/ArchivedAccountListModel.kt | 7 ++--- .../createedit/AccountCreateEditModel.kt | 2 +- .../account/details/AccountDetailsModel.kt | 16 +++++++---- 12 files changed, 86 insertions(+), 57 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index af3e4fd8d7..edaac6a969 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -1072,6 +1072,8 @@ Bitte setze das nächste Gerät zurück, um fortzufahren. Ringbesitzer erhalten bis zum 15.11. 3 provisionsfreie Swaps auf Changelly! Jetzt mit 0 % Gebühren tauschen! + Geräte mit Root-Zugriff gelten als weniger sicher. Deine Daten können zusätzlichen Risiken ausgesetzt sein. + Root-Zugriff erkannt Melde dich bei der App an und überprüfe dein Guthaben, ohne die Karte oder Ring zu scannen Zugriff auf die App Nutzung biometrischer Daten zulassen @@ -1461,7 +1463,7 @@ Kartenausstellung fehlgeschlagen Ein technischer Fehler ist aufgetreten, bitte versuchen Sie es erneut, indem Sie auf die Schaltfläche unten klicken Ein technischer Fehler ist aufgetreten, bitte kontaktieren Sie den Support - Nutzen Sie Ihre Kryptowährungen für Einkäufe im Alltag. \nEine Zahlungskarte, die ihresgleichen sucht. + Holen Sie sich Ihre kostenlose virtuelle Tangem Visa-Karte Tangem Pay erhalten Zum Support Es dauert in der Regel bis zu 15 Minuten. @@ -1474,8 +1476,8 @@ KYC in Bearbeitung Status anzeigen KYC für Tangem Pay in Arbeit - Nutzen Sie Ihre Kryptowährungen für Einkäufe im echten Leben. \nEs ist eine Zahlungskarte, die ihresgleichen sucht. - Tangem Visa Card + Holen Sie sich Ihre kostenlose virtuelle Tangem Visa-Karte + Nutzen Sie USDC für alltägliche Zahlungen Karte erhalten Mit digitaler Karte, die mit Apple Pay und Google Pay funktioniert Geben Sie Ihre Vermögenswerte überall aus @@ -1490,7 +1492,7 @@ Service vorübergehend nicht verfügbar Daten können derzeit nicht angezeigt werden, Kartenzahlungen funktionieren jedoch weiterhin. Synchronisation erforderlich - Tangem Visa Card + Nutzen Sie USDC für alltägliche Zahlungen Tangem Pay ist vorübergehend nicht verfügbar Tangem Pay Verwenden Sie Ihre Karte oder Ihren Ring, um den Zugriff auf Ihr Zahlungskonto wiederherzustellen @@ -1990,6 +1992,7 @@ Gebührenpolitik Tangem erhebt außerdem eine Servicegebühr von 15% auf den erzielten Ertrag. Deine Gelder werden automatisch an Aave überwiesen, sobald die Netzwerkgebühren niedriger sind oder Dein Guthaben den erforderlichen Mindestbetrag erreicht. + Die Gebühren sind aufgrund der hohen Marktaktivität derzeit höher als üblich. Du kannst jetzt fortfahren oder später noch einmal vorbeischauen, wenn die Gebühren niedriger sind. Hohe Netzwerkgebühren Historische Renditen Aktiviere %1$s%% Jahreszins auf Dein Guthaben diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index e99920eeb5..61e6f5eefa 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -916,6 +916,8 @@ El reseteo a valores de fábrica eliminará completamente la billetera de la tarjeta/anillo seleccionado y lo eliminará de la app. No podrá restaurar la billetera actual. Si tiene un Anillo Tangem, ¡3 swaps sin comisión en Changelly hasta el 15/11! ¡Intercambia con 0% de comisión! + Los dispositivos con jailbreak se consideran menos seguros. Sus datos podrían estar expuestos a riesgos adicionales. + Acceso root detectado Inicie sesión en la app y comprueba su saldo sin escanear la tarjeta o el anillo Acceder a la app Permitir el uso de biometría @@ -1297,7 +1299,7 @@ Error al emitir la tarjeta Ha ocurrido un error técnico, por favor inténtalo de nuevo haciendo clic en el botón de abajo Ha ocurrido un error técnico, por favor contacta con el soporte - Usa tus criptomonedas para compras en el mundo real. \nEs una tarjeta de pago única en su tipo. + Obtén tu tarjeta virtual Tangem Visa gratuita Obtener Tangem Pay Ir a Soporte Suele tardar hasta 15 minutos @@ -1310,8 +1312,8 @@ KYC en curso Ver estado KYC en progreso para Tangem Pay - Usa tus criptomonedas para compras en el mundo real. \nEs una tarjeta de pago única en su tipo. - Tangem Visa Card + Obtén tu tarjeta virtual Tangem Visa gratuita + Usa USDC para pagos cotidianos Obtener tarjeta Con tarjeta digital que funciona con Apple Pay y Google Pay Gasta tus activos en cualquier lugar @@ -1326,7 +1328,7 @@ Servicio temporalmente no disponible No es posible mostrar los datos en este momento, pero los pagos con tarjeta siguen funcionando. Sincronización necesaria - Tangem Visa Card + Usa USDC para pagos cotidianos Tangem Pay temporalmente no disponible Tangem Pay Usa tu tarjeta o anillo para restaurar el acceso a tu cuenta de pago diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index dea8c07645..f612094144 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1280,7 +1280,7 @@ Échec de l\'émission de la carte Une erreur technique s\'est produite, veuillez réessayer en cliquant sur le bouton ci-dessous Une erreur technique s\'est produite, veuillez contacter le support - Utilisez vos cryptomonnaies pour vos dépenses quotidiennes. \nC\'est une carte de paiement unique en son genre. + Obtenez votre carte virtuelle Tangem Visa gratuite Obtenir Tangem Pay Contacter le support Cela prend généralement jusqu\'à 15 minutes @@ -1293,8 +1293,8 @@ KYC en cours Voir le statut KYC en cours pour Tangem Pay - Utilisez vos cryptomonnaies pour vos dépenses du quotidien. \nC\'est une carte de paiement unique en son genre. - Tangem Visa Card + Obtenez votre carte virtuelle Tangem Visa gratuite + Utilisez USDC pour les paiements quotidiens Obtenir la carte Avec carte numérique compatible Apple Pay et Google Pay Dépensez vos actifs partout @@ -1309,7 +1309,7 @@ Service temporairement indisponible Les données ne peuvent pas être affichées pour le moment, mais les paiements par carte fonctionnent toujours. Synchronisation requise - Tangem Visa Card + Utilisez USDC pour les paiements quotidiens Tangem Pay est temporairement indisponible Tangem Pay Utilisez votre carte ou votre bague pour restaurer l\'accès à votre compte de paiement diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index b7536b13ca..a38c395035 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -139,7 +139,7 @@ Impossibile emettere la carta Si è verificato un errore tecnico, riprova cliccando il pulsante qui sotto Si è verificato un errore tecnico, contatta il supporto - Utilizza le tue attività per fare acquisti nel mondo reale. \nÈ una carta di pagamento diversa da qualsiasi altra. + Ottieni la tua carta virtuale Tangem Visa gratuita Ottieni Tangem Pay Vai al supporto Di solito richiede fino a 15 minuti @@ -152,8 +152,8 @@ KYC in corso Visualizza stato KYC in corso per Tangem Pay - Utilizza le tue attività per fare acquisti nel mondo reale. \nÈ una carta di pagamento diversa da qualsiasi altra. - Tangem Visa Card + Ottieni la tua carta virtuale Tangem Visa gratuita + Usa USDC per i pagamenti quotidiani Ottieni carta Con carta digitale che funziona con Apple Pay e Google Pay Spendi i tuoi asset ovunque @@ -168,7 +168,7 @@ Servizio temporaneamente non disponibile Al momento non è possibile visualizzare i dati, ma i pagamenti con carta continuano a funzionare. Sincronizzazione necessaria - Tangem Visa Card + Usa USDC per i pagamenti quotidiani Tangem Pay è temporaneamente non disponibile Tangem Pay Usa la tua carta o il tuo anello per ripristinare l\'accesso al tuo conto di pagamento diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 70f1b95349..d8a7ec5d6d 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -23,7 +23,7 @@ 回復する 「 %1$s 」を回復しようとしています。 アカウントを回復する - すでにアクティブアカウントの上限(20件)に達しています。復元するには、1つをアーカイブしてください。 + 有効なアカウントは最大20件までです。1つのアカウントをアーカイブすると、このアカウントを復元できます。 アカウントを復元できません アーカイブ済み アカウントをアーカイブできませんでした。しばらくしてからもう一度お試しください。 @@ -43,12 +43,13 @@ アカウントを追加 保存 アカウント名 - アカウント名はすでに存在しています + この名前のアカウントはすでに存在します。別の名前を選択してください。 + このアカウント名はすでに使用されています アカウント 新しいアカウント アカウントを追加 アカウントを編集 - 少し時間をおいて再度お試しください。問題が続く場合はサポートへご連絡ください。こちらで解決をお手伝いします。 + しばらく時間をおいてから、もう一度お試しください。問題が解決しない場合は、サポートにお問い合わせください。こちらで解決をお手伝いします。 %1$s ( %2$s内) メインアカウント すでにアクティブアカウントの上限%1$s件を超えています。復元するには、1つをアーカイブしてください。 @@ -593,11 +594,13 @@ アクセスコードを作成する前にウォレットをバックアップしてください。 先にバックアップを完了してください まずバックアップを完了する + 未完了 その他の方法 資金を保護するため、リカバリーフレーズは安全な場所に保管し、他人に知られないようにしてください。 リカバリーフレーズ アクセスコードでウォレットを保護するには、バックアップの手続きを完了してください。 ハードウェアウォレットにアップグレードするには、バックアップの手続きを完了してください。 + 秘密鍵は安全に暗号化され、スマートフォン上に保存されています 秘密鍵はデバイス上に保持されます リカバリーフレーズを使ってウォレットを作成または復元してください。 シードフレーズのバックアップ @@ -618,6 +621,7 @@ このウォレットを削除してもよろしいですか? ウォレットを削除する前にバックアップを行っていない場合、ウォレットへのアクセスを失うことを理解しています。 ウォレットを削除しても中身自体が消えるわけではなく、このデバイスから表示が消えるだけであることを理解しています。 + アップグレード シードフレーズは不要です。Tangemカードまたはリングが安全なバックアップとなります。 Tangemでバックアップ アップグレードできません。このデバイスにはすでにウォレットが存在します。 @@ -790,12 +794,16 @@ モバイルウォレットを作成するには、%1$sにアップデートする必要があります モバイルウォレットを使用するには、%1$s以降が必要です すべてのニュース + いいね %d時間前 %d分前 + クイックまとめ + 関連ニュース + 情報源 最新情報を入手 お使いのデバイスではNFCが使用できません NFTについて @@ -942,7 +950,7 @@ 通知 バックアップデバイスが1つ追加されました カードまたはリングを用意してください - バックアップデバイス2つが追加されました + バックアップデバイスが2つ追加されました 始めるには、ウォレットに任意の金額を入金するだけです 始めるには、ウォレットに%1$s %2$s以上入金するだけです 暗号資産を購入する @@ -1469,7 +1477,7 @@ カードの発行に失敗しました 技術的なエラーが発生しました。下のボタンをクリックして、もう一度お試しください。 技術的なエラーが発生しました。サポートへお問い合わせください。 - 暗号資産を日常の支払いに使おう。\n\nこれまでにないタイプの決済カード。 + 無料のTangem Visaバーチャルカードを入手 Tangem Payを入手 サポートへ移動 通常は最大で15分ほどかかります @@ -1477,14 +1485,16 @@ カードを発行しています カードを準備しています。少し時間がかかる場合があります。 Tangem Pay + 中止を確定する + KYC手続きを中止しますか?いつでも再開できます。 プロフィールを確認できませんでした。ご不明な点があればサポートまでお問い合わせください。 申し訳ございませんが、本人確認を行うことができませんでした KYC進行中 ステータスを表示 Tangem PayのKYC手続き進行中 以下のボタンから、現在のKYCステータスを確認するか、KYCをキャンセルできます。 - 暗号資産を、リアルな支払いに。\n他とはまったく違う、新しいタイプの決済カード。 - Tangem Visaカード + 無料のTangem Visaバーチャルカードを入手 + 日常の支払いにUSDCを利用 カードをGET Apple PayとGoogle Payに対応したデジタルカード付き どこでも暗号資産を使える @@ -1499,7 +1509,7 @@ サービスは一時的に利用できません 現在、データを表示できませんが、カードでのお支払いは引き続きご利用いただけます。 同期が必要です - Tangem Visaカード + 日常の支払いにUSDCを利用 Tangem Payは現在一時的に利用できません。 Tangem Pay カードまたはリングを使用して、支払いアカウントへのアクセスを復元してください。 @@ -1617,7 +1627,7 @@ ロック解除 カードをスキャンしてアクセスロックを解除する ロック解除が必要 - ウォレットの追加方法を選択してください + ウォレットの種類を選択します Tangemカードまたはリングをスキャンして復元するか、別のウォレットからインポートしてください。 ハードウェアウォレットを作成 Tangemウォレットを購入しますか? diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 036b504b45..d1cbd557be 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -54,7 +54,7 @@ Нажмите и удерживайте аккаунт, чтобы изменить порядок аккаунтов. Продолжить Отменить - Вы уверены, что хотите создание нового аккаунта? + Вы уверены, что хотите отменить создание нового аккаунта? Вы уверены, что хотите отменить изменения? Несохраненные изменения Некоторые пользовательские токены были перемещены из «%1$s» в «%2$s», поскольку их путь деривации относится к этой учётной записи. @@ -1502,7 +1502,7 @@ Не удалось выпустить карту Техническая ошибка, попробуйте ещё раз, нажав кнопку ниже Техническая ошибка, свяжитесь с поддержкой - Используйте криптовалюту в реальной жизни. \nКарта, не похожая ни на одну другую. + Откройте бесплатную виртуальную карту Tangem Visa Получить Tangem Pay Написать в поддержку Обычно это занимает до 15 минут @@ -1515,8 +1515,8 @@ KYC в процессе Посмотреть статус KYC в процессе для Tangem Pay - Используйте криптовалюту в реальной жизни. \nКарта, не похожая ни на одну другую. - Tangem Visa Card + Откройте бесплатную виртуальную карту Tangem Visa + Оплачивайте ежедневные покупки в USDC Открыть карту Виртуальную карту можно добавить в Apple Pay и Google Pay Покупайте где угодно @@ -1531,7 +1531,7 @@ Сервис временно недоступен Не можем показать данные карты, но оплаты продолжают работать. Требуется синхронизация - Tangem Visa Card + Оплачивайте ежедневные покупки в USDC Tangem Pay временно недоступен Tangem Pay Используйте вашу карту или кольцо для восстановления доступа к платежному аккаунту @@ -1634,7 +1634,7 @@ ПИН не принят. Попробуйте ещё раз или введите другой код. Слабый ПИН: не используйте повторы или последовательности. Разблокировать - Выберите способ добавления кошелька + Выберите тип кошелька Отсканируйте вашу карту или кольцо Tangem, чтобы восстановить её или импортировать из другого кошелька. Создать аппаратный кошелёк Хотите приобрести кошелек Tangem? diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 92eff0a832..ffe733b8c5 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1141,9 +1141,12 @@ Показати деталі Обміняйте будь-який актив у вашому портфелі на картку Розморозити картку - Tangem Visa Card + Отримайте безкоштовну віртуальну картку Tangem Visa + Отримайте безкоштовну віртуальну картку Tangem Visa + Використовуйте USDC для щоденних платежів Ми усуваємо технічну проблему. Будь ласка, спробуйте пізніше. Сервіс тимчасово недоступний + Використовуйте USDC для щоденних платежів Це мій гаманець Баланси приховано Баланси показано diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index 6fa2c8b69c..1dd6969ad7 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -382,7 +382,7 @@ 无法发行卡片 出现技术错误,请点击下方按钮重试 出现技术错误,请联系客服 - 使用您的加密货币进行真实世界消费。\n这是一张与众不同的支付卡。 + 獲取您的免費 Tangem Visa 虛擬卡 获取Tangem Pay 前往客服中心 通常需要最多15分钟 @@ -395,8 +395,8 @@ KYC进行中 查看状态 Tangem Pay 的 KYC 正在進行中 - 使用您的加密货币进行真实世界消费。这是一张与众不同的支付卡。 - Tangem Visa Card + 獲取您的免費 Tangem Visa 虛擬卡 + 使用 USDC 進行日常支付 获取卡片 使用支援 Apple Pay 和 Google Pay 的數位卡 在任何地方花费您的资产 @@ -411,7 +411,7 @@ 服務暫時無法使用 目前無法顯示資料,但卡片支付仍可正常使用。 需要同步 - Tangem Visa Card + 使用 USDC 進行日常支付 Tangem Pay暂时不可用 Tangem Pay 使用您的卡片或戒指恢复对支付账户的访问 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index eaddf5b46e..12311681da 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -23,12 +23,12 @@ Recover You’re about to recover “%1$s”. Recover account - You have already exceeded the limit of 20 active accounts. Archive one to recover. + You’ve reached the limit of 20 active accounts. Archive one account to recover this one. Can\'t recover account Archived We couldn’t archive account. Please try again later. - This account participates in the referral program. - This account cannot be archived. + This account is participating in the referral program. + This account can’t be archived We couldn’t create account. Please try again later. Account created Archive account @@ -43,12 +43,13 @@ Add account Save Account name - Account name already exists + An account with this name already exists. Please choose a different name. + Account name already in use Account New account Add account Edit account - Please try again later. If it keeps happening, get in touch with support and we’ll help you resolve it. + Please try again later. If the problem persists, contact support and we’ll help you resolve it. %1$s in %2$s Main account You have already exceeded the limit of %1$s active accounts. Archive one to recover @@ -628,6 +629,7 @@ Are you sure you want to forget this wallet? I understand that if I haven\'t backed up my wallet before removing it, I will lose access to it. I understand that removing my wallet does not delete it, only removes it from my device. + Upgrade Seed phrase not required. Your Tangem card or ring becomes your secure backup. Backup with Tangem Can\'t upgrade. A wallet already exists on this device. @@ -803,6 +805,7 @@ You must update to %1$s before creating a mobile wallet Mobile Wallet requires %1$s or later All news + Like %dh ago %dh ago @@ -811,6 +814,9 @@ %d minute ago %d minutes ago + Quick recap + Related News + Sources Stay in the loop NFC is not available on your device About NFT @@ -1493,7 +1499,7 @@ Failed to issue card A technical error has occurred, please try again by clicking the button below. A technical error has occurred, please contact support. - Use your crypto for real world spending. \nIt\'s a payment card unlike any other. + Get your free Tangem Visa virtual card Get Tangem Pay Go to Support It usually takes up to 15 minutes @@ -1501,14 +1507,16 @@ Issuing your card We’re getting your card ready. This may take a little time. Tangem Pay + Confirm Cancellation + Are you sure you want to stop the KYC process? You can return to it anytime. We could not verify your profile. If you have any questions, please contact support. Unfortunately, we couldn\'t verify your identity KYC in progress View Status KYC in progress for Tangem Pay Use the buttons below to view your current KYC status or cancel it. - Use your crypto for real world spending. \nIt’s a payment card unlike any other. - Tangem Visa Card + Get your free Tangem Visa virtual card + Use USDC for everyday payments Get card With digital card that works with Apple Pay and Google Pay Spend your assets anywhere @@ -1523,7 +1531,7 @@ Service temporarily unavailable Unable to display details. However, card payments are still working. Sync needed - Tangem Visa Card + Use USDC for everyday payments Tangem Pay is temporarily unreachable Tangem Pay Use your card or ring to restore access to your payment account @@ -1686,7 +1694,7 @@ Unlock Scan your card to unlock access Needed unlock - Choose how to add your wallet + Choose your wallet type Scan your Tangem card or ring to restore it or import from another wallet. Create Hardware Wallet Want to purchase a Tangem Wallet? diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt index 2b6963e2b6..d0ef646fb1 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt @@ -124,11 +124,8 @@ internal class ArchivedAccountListModel @Inject constructor( messageSender.send( DialogMessage( - title = resourceReference(R.string.common_something_went_wrong), - message = resourceReference( - id = R.string.account_recover_limit_dialog_description, - formatArgs = wrappedList(AccountList.MAX_ACCOUNTS_COUNT.toString()), - ), + title = resourceReference(R.string.account_recover_limit_dialog_title), + message = resourceReference(R.string.account_archived_recover_error_message), firstActionBuilder = { firstAction }, ), ) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt index 8720ae4650..e0783453da 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt @@ -314,7 +314,7 @@ internal class AccountCreateEditModel @Inject constructor( private fun showAccountNameExist() { val dialogMessage = DialogMessage( - title = resourceReference(R.string.common_something_went_wrong), + title = resourceReference(R.string.account_form_name_already_exist_error_title), message = resourceReference(R.string.account_form_name_already_exist_error_description), ) messageSender.send(dialogMessage) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt index c1bdf53342..cb61bf3e8f 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt @@ -120,15 +120,21 @@ internal class AccountDetailsModel @Inject constructor( error = error.tag, ) analyticsEventHandler.send(event) - val titleRes = R.string.common_something_went_wrong - val messageRes = when (error) { + val titleRes: Int + val messageRes: Int + when (error) { is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountListRequirementsNotMet, is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountNotFound, is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountsNotCreated, is ArchiveCryptoPortfolioUseCase.Error.DataOperationFailed, - -> R.string.account_generic_error_dialog_message - is ArchiveCryptoPortfolioUseCase.Error.ActiveReferralStatus, - -> R.string.account_could_not_archive_referral_program_message + -> { + titleRes = R.string.common_something_went_wrong + messageRes = R.string.account_generic_error_dialog_message + } + is ArchiveCryptoPortfolioUseCase.Error.ActiveReferralStatus -> { + titleRes = R.string.account_could_not_archive_referral_program_title + messageRes = R.string.account_could_not_archive_referral_program_message + } } val dialogMessage = DialogMessage( From 016b1e18d2085092ef1dbf22a722f9d928ea7b84 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Dec 2025 14:55:04 +0200 Subject: [PATCH 022/172] Updated on 2026-08-14 --- .../staking/DefaultP2PEthPoolRepository.kt | 2 +- .../staking/model/StakingIntegration.kt | 2 +- .../repositories/P2PEthPoolRepository.kt | 10 + features/staking/impl/build.gradle.kts | 1 + .../impl/presentation/model/StakingModel.kt | 111 +++--- .../state/helpers/P2PEthPoolFeeLoader.kt | 55 +++ .../helpers/P2PEthPoolTransactionCreator.kt | 95 ++++++ .../helpers/P2PEthPoolTransactionSender.kt | 128 +++++++ ...nsactionLoader.kt => StakeKitFeeLoader.kt} | 172 ++++++---- .../helpers/StakeKitTransactionSender.kt | 322 ++++++++++++++++++ .../state/helpers/StakingFeeLoader.kt | 16 + .../state/helpers/StakingOperationsFactory.kt | 55 +++ .../state/helpers/StakingTransactionSender.kt | 310 +---------------- 13 files changed, 858 insertions(+), 421 deletions(-) create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolFeeLoader.kt create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionSender.kt rename features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/{StakingFeeTransactionLoader.kt => StakeKitFeeLoader.kt} (63%) create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeLoader.kt create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingOperationsFactory.kt diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt index 4a7f708311..46eae8ecb3 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -248,7 +248,7 @@ internal class DefaultP2PEthPoolRepository( } } - private suspend fun getVaultsSync(): List { + override suspend fun getVaultsSync(): List { return p2pEthPoolVaultsStore.getSync() } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt index 37f76ede7f..48f78ec663 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt @@ -11,7 +11,7 @@ import java.math.BigDecimal * Strategy interface for staking integrations. * Abstracts over StakeKit and P2PEthPool staking providers. */ -interface StakingIntegration { +sealed interface StakingIntegration { // Basic diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt index 2b3811a26a..04892f5414 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt @@ -132,6 +132,16 @@ interface P2PEthPoolRepository { */ fun getVaultsFlow(): Flow> + /** + * Get cached vaults synchronously from local store. + * + * This returns vaults from the local cache/store without network call. + * Call [fetchVaults] first to populate the cache from the network. + * + * @return List of cached vaults (empty if cache is not populated) + */ + suspend fun getVaultsSync(): List + /** * Check P2PEthPool staking availability by finding public vault * diff --git a/features/staking/impl/build.gradle.kts b/features/staking/impl/build.gradle.kts index c647e6e760..92180a4384 100644 --- a/features/staking/impl/build.gradle.kts +++ b/features/staking/impl/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(deps.lifecycle.compose) implementation(deps.jodatime) implementation(deps.timber) + implementation(deps.moshi) /** Compose */ implementation(deps.compose.accompanist.systemUiController) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index 30806bf543..07da487fa9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -69,7 +69,8 @@ import com.tangem.features.staking.impl.presentation.state.events.StakingAlertUM import com.tangem.features.staking.impl.presentation.state.events.StakingEvent import com.tangem.features.staking.impl.presentation.state.events.StakingEventFactory import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater -import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeTransactionLoader +import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeLoader +import com.tangem.features.staking.impl.presentation.state.helpers.StakingOperationsFactory import com.tangem.features.staking.impl.presentation.state.helpers.StakingTransactionSender import com.tangem.features.staking.impl.presentation.state.transformers.* import com.tangem.features.staking.impl.presentation.state.transformers.amount.* @@ -128,8 +129,7 @@ internal class StakingModel @Inject constructor( private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase, private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase, - private val stakingTransactionLoader: StakingTransactionSender.Factory, - private val stakingFeeTransactionLoader: StakingFeeTransactionLoader.Factory, + private val stakingOperationsFactory: StakingOperationsFactory, private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, private val analyticsEventHandler: AnalyticsEventHandler, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, @@ -175,8 +175,7 @@ internal class StakingModel @Inject constructor( StakeKitIntegration(integrationId, yield) } StakingIntegrationID.P2PEthPool -> { - // TODO p2p avoid network call - val vaults = p2pEthPoolRepository.getVaults().getOrElse { emptyList() } + val vaults = p2pEthPoolRepository.getVaultsSync() P2PEthPoolIntegration(integrationId, vaults) } } @@ -221,16 +220,16 @@ internal class StakingModel @Inject constructor( ) } - private val feeLoader by lazy(LazyThreadSafetyMode.NONE) { - stakingFeeTransactionLoader.create( + private val feeLoader: StakingFeeLoader by lazy(LazyThreadSafetyMode.NONE) { + stakingOperationsFactory.createFeeLoader( cryptoCurrencyStatus = cryptoCurrencyStatus, userWallet = userWallet, integration = integration, ) } - private val transactionSender by lazy(LazyThreadSafetyMode.NONE) { - stakingTransactionLoader.create( + private val transactionSender: StakingTransactionSender by lazy(LazyThreadSafetyMode.NONE) { + stakingOperationsFactory.createTransactionSender( cryptoCurrencyStatus = cryptoCurrencyStatus, userWallet = userWallet, integration = integration, @@ -391,49 +390,57 @@ internal class StakingModel @Inject constructor( modelScope.launch { stakingAnalyticSender.sendTransactionStakingClickedAnalytics(value) stateController.update(SetConfirmationStateInProgressTransformer()) - transactionSender.constructAndSendTransactions( - onConstructSuccess = { constructedTransactions -> - transactionsInProgress.addAll(constructedTransactions) - }, - onConstructError = { error -> - stakingEventFactory.createStakingErrorAlert(error) - stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) - }, - onSendSuccess = { txUrl -> - stakingAnalyticSender.sendTransactionStakingAnalytics( - stateController.value, - cryptoCurrencyStatus, - ) - transactionsInProgress.clear() - stateController.update(SetConfirmationStateCompletedTransformer(txUrl, cryptoCurrencyStatus)) - }, - onSendError = { error -> - analyticsEventHandler.send( - StakingAnalyticsEvent.TransactionError( - errorCode = error.getAnalyticsDescription(), - ), - ) - stakingEventFactory.createSendTransactionErrorAlert(error) - stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) - }, - onFeeIncreased = { increasedFee, isFeeApproximate -> - stateController.updateAll( - SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus), - SetConfirmationStateAssentTransformer( - appCurrencyProvider = Provider { appCurrency }, - feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, - fee = increasedFee, - isFeeApproximate = isFeeApproximate, - cryptoCurrencyStatus = cryptoCurrencyStatus, - ), - ) - stateController.updateEvent( - StakingEvent.ShowAlert( - StakingAlertUM.FeeIncreased(stateController::dismissAlert), - ), - ) - updateNotifications() - }, + transactionSender.send( + StakingTransactionSender.Callbacks( + onConstructSuccess = { constructedTransactions -> + transactionsInProgress.addAll(constructedTransactions) + }, + onConstructError = { error -> + stakingEventFactory.createStakingErrorAlert(error) + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + }, + onSendSuccess = { txUrl -> + stakingAnalyticSender.sendTransactionStakingAnalytics( + stateController.value, + cryptoCurrencyStatus, + ) + transactionsInProgress.clear() + stateController.update( + SetConfirmationStateCompletedTransformer(txUrl, cryptoCurrencyStatus), + ) + }, + onSendError = { error -> + analyticsEventHandler.send( + StakingAnalyticsEvent.TransactionError( + errorCode = error.getAnalyticsDescription(), + ), + ) + stakingEventFactory.createSendTransactionErrorAlert(error) + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + }, + onFeeIncreased = { increasedFee, isFeeApproximate -> + stateController.updateAll( + SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus), + SetConfirmationStateAssentTransformer( + appCurrencyProvider = Provider { appCurrency }, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fee = increasedFee, + isFeeApproximate = isFeeApproximate, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ), + ) + stateController.updateEvent( + StakingEvent.ShowAlert( + StakingAlertUM.FeeIncreased(stateController::dismissAlert), + ), + ) + updateNotifications() + }, + onTransactionExpired = { + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + getFee() + }, + ), ) }.saveIn(sendTransactionJobHolder) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolFeeLoader.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolFeeLoader.kt new file mode 100644 index 0000000000..0f728d1ce5 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolFeeLoader.kt @@ -0,0 +1,55 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.staking.model.P2PEthPoolIntegration +import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.transaction.error.GetFeeError +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class P2PEthPoolFeeLoader @AssistedInject constructor( + private val transactionCreator: P2PEthPoolTransactionCreator, + @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, + @Assisted private val integration: P2PEthPoolIntegration, +) : StakingFeeLoader { + + override suspend fun getFee( + onStakingFee: (Fee, Boolean) -> Unit, + onStakingFeeError: (StakingError) -> Unit, + onApprovalFee: (TransactionFee) -> Unit, + onFeeError: (GetFeeError) -> Unit, + ) { + transactionCreator.createTransaction(cryptoCurrencyStatus).fold( + ifLeft = onStakingFeeError, + ifRight = { unsignedTx -> + val fee = convertUnsignedTxToFee(unsignedTx) + onStakingFee(fee, false) + }, + ) + } + + private fun convertUnsignedTxToFee(unsignedTx: P2PEthPoolUnsignedTx): Fee { + val blockchain = integration.integrationId.blockchain + val decimals = blockchain.decimals() + val feeInWei = unsignedTx.gasLimit * unsignedTx.maxFeePerGas + val feeValue = feeInWei.movePointLeft(decimals) + return Fee.Common( + Amount( + currencySymbol = blockchain.currency, + value = feeValue, + decimals = decimals, + ), + ) + } + + @AssistedFactory + interface Factory { + + fun create(cryptoCurrencyStatus: CryptoCurrencyStatus, integration: P2PEthPoolIntegration): P2PEthPoolFeeLoader + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt new file mode 100644 index 0000000000..ed2e4f77df --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt @@ -0,0 +1,95 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import arrow.core.Either +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig +import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.features.staking.impl.presentation.state.StakingStateController +import com.tangem.features.staking.impl.presentation.state.StakingStates +import java.math.BigDecimal +import javax.inject.Inject + +@ModelScoped +internal class P2PEthPoolTransactionCreator @Inject constructor( + private val stateController: StakingStateController, + private val p2pEthPoolRepository: P2PEthPoolRepository, +) { + + suspend fun createTransaction( + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): Either { + val params = extractParams(cryptoCurrencyStatus) + ?: return Either.Left(StakingError.DomainError("Invalid state for transaction creation")) + + return createTransaction( + actionType = params.actionType, + amount = params.amount, + vaultAddress = params.vaultAddress, + sourceAddress = params.sourceAddress, + ) + } + + fun extractParams(cryptoCurrencyStatus: CryptoCurrencyStatus): TransactionParams? { + val state = stateController.value + val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data + ?: return null + + val sourceAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value + ?: return null + + val vaultAddress = (state.validatorState as? StakingStates.ValidatorState.Data)?.chosenTarget?.address + ?: state.balanceState?.targetAddress + ?: return null + + val amount = (state.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value + ?: return null + + return TransactionParams( + actionType = state.actionType, + amount = amount, + vaultAddress = vaultAddress, + sourceAddress = sourceAddress, + ) + } + + private suspend fun createTransaction( + actionType: StakingActionCommonType, + amount: BigDecimal, + vaultAddress: String, + sourceAddress: String, + ): Either { + val network = P2PEthPoolStakingConfig.activeNetwork + + return when (actionType) { + is StakingActionCommonType.Enter -> { + p2pEthPoolRepository.createDepositTransaction( + network = network, + delegatorAddress = sourceAddress, + vaultAddress = vaultAddress, + amount = amount.toPlainString(), + ) + } + is StakingActionCommonType.Exit -> { + p2pEthPoolRepository.createWithdrawTransaction( + network = network, + stakerAddress = sourceAddress, + ) + } + is StakingActionCommonType.Pending -> { + Either.Left(StakingError.DomainError("Pending actions not supported for P2PEthPool")) + } + } + } + + data class TransactionParams( + val actionType: StakingActionCommonType, + val amount: BigDecimal, + val vaultAddress: String, + val sourceAddress: String, + ) +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionSender.kt new file mode 100644 index 0000000000..672f9ea61c --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionSender.kt @@ -0,0 +1,128 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import arrow.core.getOrElse +import com.squareup.moshi.Moshi +import com.tangem.blockchain.blockchains.ethereum.models.EthereumCompiledTransaction +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.extensions.formatHex +import com.tangem.common.extensions.toHexString +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.staking.model.P2PEthPoolIntegration +import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig +import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.transaction.usecase.PrepareForSendUseCase +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import java.math.BigInteger + +@Suppress("LongParameterList") +internal class P2PEthPoolTransactionSender @AssistedInject constructor( + private val transactionCreator: P2PEthPoolTransactionCreator, + private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, + private val prepareForSendUseCase: PrepareForSendUseCase, + private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, + private val p2pEthPoolRepository: P2PEthPoolRepository, + @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, + @Assisted private val userWallet: UserWallet, + @Assisted private val integration: P2PEthPoolIntegration, +) : StakingTransactionSender { + + private val balanceUpdater: StakingBalanceUpdater + get() = stakingBalanceUpdater.create(cryptoCurrencyStatus, userWallet, integration) + + override suspend fun send(callbacks: StakingTransactionSender.Callbacks) { + val params = transactionCreator.extractParams(cryptoCurrencyStatus) + ?: run { + callbacks.onConstructError( + StakingError.DomainError("Invalid state for transaction"), + ) + return + } + + val unsignedTx = transactionCreator.createTransaction(cryptoCurrencyStatus).getOrElse { error -> + callbacks.onConstructError(error) + return + } + + val compiledTxJson = createCompiledTransactionJson(unsignedTx, params.sourceAddress) + + val transactionData = TransactionData.Compiled( + value = TransactionData.Compiled.Data.RawString(compiledTxJson), + ) + + val signedTxBytes = prepareForSendUseCase( + transactionData = transactionData, + userWallet = userWallet, + network = cryptoCurrencyStatus.currency.network, + ).getOrElse { error -> + callbacks.onSendError(error) + return + } + + val signedTxHex = signedTxBytes.toHexString().lowercase().formatHex() + + p2pEthPoolRepository.broadcastTransaction( + network = P2PEthPoolStakingConfig.activeNetwork, + signedTransaction = signedTxHex, + ).fold( + ifLeft = { error -> + callbacks.onConstructError(error) + }, + ifRight = { broadcastResult -> + val txUrl = getExplorerTransactionUrlUseCase( + txHash = broadcastResult.hash, + networkId = cryptoCurrencyStatus.currency.network.id, + ).getOrNull().orEmpty() + + balanceUpdater.updateAfterTransaction() + callbacks.onSendSuccess(txUrl) + }, + ) + } + + private fun createCompiledTransactionJson(unsignedTx: P2PEthPoolUnsignedTx, fromAddress: String): String { + val compiledTx = EthereumCompiledTransaction( + from = fromAddress, + to = unsignedTx.to, + data = unsignedTx.data, + value = unsignedTx.value.toBigInteger().toHexString(), + nonce = unsignedTx.nonce, + chainId = unsignedTx.chainId, + gasLimit = unsignedTx.gasLimit.toBigInteger().toHexString(), + gasPrice = null, // EIP-1559: gasPrice is null + maxFeePerGas = unsignedTx.maxFeePerGas.toBigInteger().toHexString(), + maxPriorityFeePerGas = unsignedTx.maxPriorityFeePerGas.toBigInteger().toHexString(), + type = EIP_1559_TX_TYPE, + ) + return ethereumCompiledTxAdapter.toJson(compiledTx) + } + + private fun BigInteger.toHexString(): String { + val hex = toString(HEX_RADIX) + val paddedHex = if (hex.length % 2 != 0) "0$hex" else hex + return HEX_PREFIX + paddedHex + } + + @AssistedFactory + interface Factory { + fun create( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWallet: UserWallet, + integration: P2PEthPoolIntegration, + ): P2PEthPoolTransactionSender + } + + private companion object { + const val HEX_PREFIX = "0x" + const val HEX_RADIX = 16 + const val EIP_1559_TX_TYPE = 2 + + val ethereumCompiledTxAdapter: com.squareup.moshi.JsonAdapter = + Moshi.Builder().build().adapter(EthereumCompiledTransaction::class.java) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitFeeLoader.kt similarity index 63% rename from features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt rename to features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitFeeLoader.kt index 9e5ad3188c..f41f4e4772 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitFeeLoader.kt @@ -12,7 +12,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.staking.PendingAction import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.staking.EstimateGasUseCase -import com.tangem.domain.staking.model.StakingIntegration +import com.tangem.domain.staking.model.StakeKitIntegration import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.staking.model.stakekit.transaction.ActionParams @@ -36,7 +36,7 @@ import kotlinx.coroutines.delay import java.math.BigDecimal @Suppress("LongParameterList") -internal class StakingFeeTransactionLoader @AssistedInject constructor( +internal class StakeKitFeeLoader @AssistedInject constructor( private val stateController: StakingStateController, private val getFeeUseCase: GetFeeUseCase, private val estimateGasUseCase: EstimateGasUseCase, @@ -44,10 +44,10 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, @Assisted private val userWallet: UserWallet, - @Assisted private val integration: StakingIntegration, -) { + @Assisted private val integration: StakeKitIntegration, +) : StakingFeeLoader { - suspend fun getFee( + override suspend fun getFee( onStakingFee: (Fee, Boolean) -> Unit, onStakingFeeError: (StakingError) -> Unit, onApprovalFee: (TransactionFee) -> Unit, @@ -57,21 +57,41 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data ?: error("Illegal state") - val validatorAddress = (state.validatorState as? StakingStates.ValidatorState.Data)?.chosenTarget?.address + val targetAddress = (state.validatorState as? StakingStates.ValidatorState.Data)?.chosenTarget?.address ?: state.balanceState?.targetAddress ?: error("No target address provided") val amount = (state.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value ?: error("No amount provided") - val pendingAction = confirmationState.pendingAction - val pendingActions = confirmationState.pendingActions + getStakeKitFee( + confirmationState = confirmationState, + actionType = state.actionType, + amount = amount, + validatorAddress = targetAddress, + onStakingFee = onStakingFee, + onStakingFeeError = onStakingFeeError, + onApprovalFee = onApprovalFee, + onFeeError = onFeeError, + ) + } - val isEnter = state.actionType is StakingActionCommonType.Enter + private suspend fun getStakeKitFee( + confirmationState: StakingStates.ConfirmationState.Data, + actionType: StakingActionCommonType, + amount: BigDecimal, + validatorAddress: String, + onStakingFee: (Fee, Boolean) -> Unit, + onStakingFeeError: (StakingError) -> Unit, + onApprovalFee: (TransactionFee) -> Unit, + onFeeError: (GetFeeError) -> Unit, + ) { + val isEnter = actionType is StakingActionCommonType.Enter val isApprovalNeeded = confirmationState.isApprovalNeeded val isAllowanceNotEnough = confirmationState.allowance < amount + if (isEnter && isApprovalNeeded && isAllowanceNotEnough) { - getApproveFee( + getApprovalFee( amount = amount, validatorAddress = validatorAddress, onApprovalFee = onApprovalFee, @@ -79,8 +99,8 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( ) } else { estimateGas( - pendingAction = pendingAction, - pendingActions = pendingActions, + pendingAction = confirmationState.pendingAction, + pendingActions = confirmationState.pendingActions, amount = amount, validatorAddress = validatorAddress, onStakingFeeError = onStakingFeeError, @@ -105,61 +125,80 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( pendingActions = pendingActions, ) ) { - val result = coroutineScope { - pendingActions?.map { action -> - async { - // Simultaneous or quick api calls can sometimes return ZERO fee - estimateFeeRetry { - estimateFee( - amount = amount, - sourceAddress = sourceAddress, - validatorAddress = validatorAddress, - action = action, - ) - }.getOrElse { - onStakingFeeError(it) - null - } - } - }?.awaitAll()?.filterNotNull() - } - - if (result.isNullOrEmpty()) { - onStakingFeeError(StakingError.DomainError("Error estimating fee")) - return - } - - val totalAmount = result.sumOf { it.amount } - val totalGasLimit = result.sumOf { it.gasLimit?.toBigDecimalOrNull().orZero() } - StakingGasEstimate( - amount = totalAmount, - token = result.first().token, - gasLimit = totalGasLimit.toPlainString().orEmpty(), - ) + estimateCompositeGas( + pendingActions = pendingActions, + amount = amount, + sourceAddress = sourceAddress, + validatorAddress = validatorAddress, + onStakingFeeError = onStakingFeeError, + ) ?: return } else { - estimateFee( + estimateSingleGas( amount = amount, sourceAddress = sourceAddress, validatorAddress = validatorAddress, action = pendingAction, - ).getOrElse { - onStakingFeeError(it) + ).getOrElse { error -> + onStakingFeeError(error) return } } - val amount = Amount( + val feeAmount = Amount( currencySymbol = gasEstimate.token.symbol, value = gasEstimate.amount, decimals = gasEstimate.token.decimals, ) onStakingFee( - Fee.Common(amount), - isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), + Fee.Common(feeAmount), + isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = feeAmount.type), ) } - private suspend fun estimateFee( + /** + * Estimates gas for several staking transactions. + */ + private suspend fun estimateCompositeGas( + pendingActions: ImmutableList?, + amount: BigDecimal, + sourceAddress: String, + validatorAddress: String, + onStakingFeeError: (StakingError) -> Unit, + ): StakingGasEstimate? { + val result = coroutineScope { + pendingActions?.map { action -> + async { + // Simultaneous or quick API calls can sometimes return ZERO fee + estimateGasRetry { + estimateSingleGas( + amount = amount, + sourceAddress = sourceAddress, + validatorAddress = validatorAddress, + action = action, + ) + }.getOrElse { gasError -> + onStakingFeeError(gasError) + null + } + } + }?.awaitAll()?.filterNotNull() + } + + if (result.isNullOrEmpty()) { + onStakingFeeError(StakingError.DomainError("Error estimating fee")) + return null + } + + val totalAmount = result.sumOf { it.amount } + val totalGasLimit = result.sumOf { it.gasLimit?.toBigDecimalOrNull().orZero() } + return StakingGasEstimate( + amount = totalAmount, + token = result.first().token, + gasLimit = totalGasLimit.toPlainString().orEmpty(), + ) + } + + private suspend fun estimateSingleGas( amount: BigDecimal, sourceAddress: String, validatorAddress: String, @@ -179,7 +218,7 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( ), ) - private suspend fun getApproveFee( + private suspend fun getApprovalFee( amount: BigDecimal, validatorAddress: String, onApprovalFee: (TransactionFee) -> Unit, @@ -187,6 +226,7 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( ) { val tokenCurrency = cryptoCurrencyStatus.currency as? CryptoCurrency.Token ?: return onApprovalFeeError(GetFeeError.UnknownError) + val approvalTransactionData = createApprovalTransactionUseCase( cryptoCurrencyStatus = cryptoCurrencyStatus, userWalletId = userWallet.walletId, @@ -196,34 +236,29 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( ).getOrElse { return onApprovalFeeError(GetFeeError.DataError(it)) } + getFeeUseCase( userWallet = userWallet, network = tokenCurrency.network, transactionData = approvalTransactionData, ).fold( - ifRight = { fee -> - onApprovalFee(fee) - }, - ifLeft = { error -> - onApprovalFeeError(error) - }, + ifRight = onApprovalFee, + ifLeft = onApprovalFeeError, ) } - private suspend fun estimateFeeRetry( - times: Int = 3, - delay: Long = 1000, + private suspend fun estimateGasRetry( + times: Int = RETRY_COUNT, + delayMs: Long = RETRY_DELAY_MS, block: suspend () -> Either, ): Either { repeat(times - 1) { val feeResult = block() feeResult.fold( ifLeft = { return feeResult }, - ifRight = { - if (!it.amount.isZero()) return feeResult - }, + ifRight = { estimate -> if (!estimate.amount.isZero()) return feeResult }, ) - delay(delay) + delay(delayMs) } return block() } @@ -233,7 +268,12 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( fun create( cryptoCurrencyStatus: CryptoCurrencyStatus, userWallet: UserWallet, - integration: StakingIntegration, - ): StakingFeeTransactionLoader + integration: StakeKitIntegration, + ): StakeKitFeeLoader + } + + private companion object { + const val RETRY_COUNT = 3 + const val RETRY_DELAY_MS = 1000L } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt new file mode 100644 index 0000000000..f1b004afe8 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt @@ -0,0 +1,322 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import arrow.core.getOrElse +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionSender +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.staking.NetworkType +import com.tangem.domain.models.staking.PendingAction +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.staking.GetConstructedStakingTransactionUseCase +import com.tangem.domain.staking.GetStakingTransactionsUseCase +import com.tangem.domain.staking.SaveUnsubmittedHashUseCase +import com.tangem.domain.staking.SubmitHashUseCase +import com.tangem.domain.staking.model.StakeKitIntegration +import com.tangem.domain.staking.model.SubmitHashData +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.staking.model.stakekit.transaction.ActionParams +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionStatus +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionType +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.features.staking.impl.presentation.state.FeeState +import com.tangem.features.staking.impl.presentation.state.StakingStateController +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.utils.checkAndCalculateSubtractedAmount +import com.tangem.features.staking.impl.presentation.state.utils.isCompositePendingActions +import com.tangem.utils.extensions.orZero +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import timber.log.Timber +import java.math.BigDecimal + +@Suppress("LongParameterList") +internal class StakeKitTransactionSender @AssistedInject constructor( + private val stateController: StakingStateController, + private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, + private val getStakingTransactionsUseCase: GetStakingTransactionsUseCase, + private val getConstructedStakingTransactionUseCase: GetConstructedStakingTransactionUseCase, + private val sendTransactionUseCase: SendTransactionUseCase, + private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, + private val submitHashUseCase: SubmitHashUseCase, + private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase, + private val isFeeApproximateUseCase: IsFeeApproximateUseCase, + @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, + @Assisted private val userWallet: UserWallet, + @Assisted private val integration: StakeKitIntegration, + @Assisted private val isAmountSubtractAvailable: Boolean, +) : StakingTransactionSender { + + private val balanceUpdater: StakingBalanceUpdater + get() = stakingBalanceUpdater.create(cryptoCurrencyStatus, userWallet, integration) + + override suspend fun send(callbacks: StakingTransactionSender.Callbacks) { + constructAndSendTransactions( + onConstructSuccess = callbacks.onConstructSuccess, + onConstructError = callbacks.onConstructError, + onSendSuccess = callbacks.onSendSuccess, + onSendError = callbacks.onSendError, + onFeeIncreased = callbacks.onFeeIncreased, + ) + } + + private suspend fun constructAndSendTransactions( + onConstructSuccess: (List) -> Unit, + onConstructError: (StakingError) -> Unit, + onSendSuccess: (String) -> Unit, + onSendError: (SendTransactionError) -> Unit, + onFeeIncreased: (Fee, Boolean) -> Unit, + ) { + val state = stateController.value + + val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data + ?: error("No confirmation state") + val fee = (confirmationState.feeState as? FeeState.Content)?.fee + ?: error("No fee provided") + val amountState = state.amountState as? AmountState.Data ?: error("No amount state") + + val stakingTransactions = getStakingTransactions( + state = state, + confirmationState = confirmationState, + onConstructError = onConstructError, + ) + + val fullTransactionsData = getConstructedTransactions( + stakingTransactions = stakingTransactions, + fee = fee, + amount = amountState.amountTextField.cryptoAmount.value.orZero(), + onConstructError = onConstructError, + ) + + if (fullTransactionsData.isNullOrEmpty()) { + onConstructError(StakingError.DomainError("fullTransactionsData is null or empty")) + return + } + + val totalFee = fullTransactionsData.sumOf { it.stakeKitTransaction.gasEstimate?.amount.orZero() } + + if (fee.amount.value.orZero() >= totalFee) { + onConstructSuccess(fullTransactionsData.map { it.stakeKitTransaction }) + sendTransaction( + fullTransactionsData = fullTransactionsData, + onSendSuccess = onSendSuccess, + onSendError = onSendError, + ) + } else { + val amount = fee.amount.copy(value = totalFee) + onFeeIncreased( + Fee.Common(amount), + isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), + ) + } + } + + private suspend fun getStakingTransactions( + state: StakingUiState, + confirmationState: StakingStates.ConfirmationState.Data, + onConstructError: (StakingError) -> Unit, + ) = coroutineScope { + val isComposePendingActions = isCompositePendingActions( + networkId = cryptoCurrencyStatus.currency.network.rawId, + pendingActions = confirmationState.pendingActions, + ) + if (isComposePendingActions) { + confirmationState.pendingActions?.map { action -> + async { + getStakingTransaction( + state = state, + action = action, + confirmationState = confirmationState, + onConstructError = onConstructError, + ) + } + }?.awaitAll()?.flatten() + } else { + getStakingTransaction( + state = state, + confirmationState = confirmationState, + onConstructError = onConstructError, + ) + } + } + + private suspend fun getConstructedTransactions( + stakingTransactions: List?, + fee: Fee, + amount: BigDecimal, + onConstructError: (StakingError) -> Unit, + ) = coroutineScope { + stakingTransactions + ?.filterNot { + it.type == StakingTransactionType.APPROVAL || it.status == StakingTransactionStatus.SKIPPED + } + ?.map { transaction -> + async { + getConstructedStakingTransactionUseCase( + networkId = cryptoCurrencyStatus.currency.network.rawId, + fee = fee, + amount = amount.convertToSdkAmount(cryptoCurrencyStatus), + transactionId = transaction.id, + ).fold( + ifRight = { (constructedTransaction, transactionData) -> + FullTransactionData( + stakeKitTransaction = constructedTransaction, + tangemTransaction = transactionData, + ) + }, + ifLeft = { error -> + onConstructError(error) + null + }, + ) + } + } + ?.awaitAll() + ?.filterNotNull() + } + + private suspend fun getStakingTransaction( + state: StakingUiState, + confirmationState: StakingStates.ConfirmationState.Data, + action: PendingAction? = confirmationState.pendingAction, + onConstructError: (StakingError) -> Unit, + ): List { + val validatorState = state.validatorState as? StakingStates.ValidatorState.Data + ?: error("No validator provided") + val fee = (confirmationState.feeState as? FeeState.Content)?.fee + ?: error("No fee provided") + val defaultAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value + ?: error("No available address") + val amountState = state.amountState as? AmountState.Data + ?: error("No amount provided") + + val validatorAddress = validatorState.chosenTarget.address + val amount = getAmount(amountState, fee, confirmationState.reduceAmountBy) + + return getStakingTransactionsUseCase( + userWalletId = userWallet.walletId, + network = cryptoCurrencyStatus.currency.network, + params = ActionParams( + actionCommonType = state.actionType, + integrationId = integration.integrationId.value, + amount = amount, + address = defaultAddress, + validatorAddress = validatorAddress, + token = integration.getCurrentToken(cryptoCurrencyStatus.currency.id.rawCurrencyId), + passthrough = action?.passthrough, + type = action?.type, + ), + ).getOrElse { error -> + onConstructError(error) + return emptyList() + } + } + + private suspend fun sendTransaction( + fullTransactionsData: List, + onSendSuccess: (txUrl: String) -> Unit, + onSendError: (SendTransactionError) -> Unit, + ) { + if (fullTransactionsData.isEmpty()) return + + val sortedTransactions = fullTransactionsData.sortedBy { it.stakeKitTransaction.stepIndex } + + val firstTransaction = sortedTransactions.first() + val network = firstTransaction.stakeKitTransaction.network + + val sendMode = if (network == NetworkType.SOLANA && + firstTransaction.stakeKitTransaction.type == StakingTransactionType.SPLIT + ) { + TransactionSender.MultipleTransactionSendMode.WAIT_AFTER_FIRST + } else { + TransactionSender.MultipleTransactionSendMode.DEFAULT + } + + sendTransactionUseCase( + txsData = sortedTransactions.map { it.tangemTransaction }, + userWallet = userWallet, + network = cryptoCurrencyStatus.currency.network, + sendMode = sendMode, + ).fold( + ifLeft = { error -> + onSendError(error) + }, + ifRight = { transactionHashes -> + submitHash( + transactions = sortedTransactions.map { it.stakeKitTransaction }, + transactionHashes = transactionHashes, + ) + + val txUrl = getExplorerTransactionUrlUseCase( + txHash = transactionHashes.last(), + networkId = cryptoCurrencyStatus.currency.network.id, + ).getOrNull().orEmpty() + + balanceUpdater.updateAfterTransaction() + onSendSuccess(txUrl) + }, + ) + } + + private suspend fun submitHash(transactions: List, transactionHashes: List) { + transactions + .zip(transactionHashes) + .forEach { (transaction, transactionHash) -> + submitHashUseCase( + SubmitHashData( + transactionId = transaction.id, + transactionHash = transactionHash, + ), + ) + .onLeft { + saveUnsubmittedHashUseCase.invoke( + transactionId = transaction.id, + transactionHash = transactionHash, + ) + }.onRight { + Timber.d("Successful hash submission") + } + } + } + + private fun getAmount(amountState: AmountState.Data, fee: Fee, reduceAmountBy: BigDecimal?): BigDecimal { + val amountValue = amountState.amountTextField.cryptoAmount.value ?: error("No amount value") + val feeValue = fee.amount.value ?: error("No fee value") + val isEnterAction = stateController.value.actionType is StakingActionCommonType.Enter + + return checkAndCalculateSubtractedAmount( + isAmountSubtractAvailable = isAmountSubtractAvailable && isEnterAction, + cryptoCurrencyStatus = cryptoCurrencyStatus, + amountValue = amountValue, + feeValue = feeValue, + reduceAmountBy = reduceAmountBy.orZero(), + ) + } + + private data class FullTransactionData( + val stakeKitTransaction: StakingTransaction, + val tangemTransaction: TransactionData.Compiled, + ) + + @AssistedFactory + interface Factory { + fun create( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWallet: UserWallet, + integration: StakeKitIntegration, + isAmountSubtractAvailable: Boolean, + ): StakeKitTransactionSender + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeLoader.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeLoader.kt new file mode 100644 index 0000000000..f4e8d449bb --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeLoader.kt @@ -0,0 +1,16 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.transaction.error.GetFeeError + +internal interface StakingFeeLoader { + + suspend fun getFee( + onStakingFee: (Fee, Boolean) -> Unit, + onStakingFeeError: (StakingError) -> Unit, + onApprovalFee: (TransactionFee) -> Unit, + onFeeError: (GetFeeError) -> Unit, + ) +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingOperationsFactory.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingOperationsFactory.kt new file mode 100644 index 0000000000..b1da82c9c5 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingOperationsFactory.kt @@ -0,0 +1,55 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.staking.model.P2PEthPoolIntegration +import com.tangem.domain.staking.model.StakeKitIntegration +import com.tangem.domain.staking.model.StakingIntegration +import javax.inject.Inject + +internal class StakingOperationsFactory @Inject constructor( + private val stakeKitFeeLoaderFactory: StakeKitFeeLoader.Factory, + private val p2pEthPoolFeeLoaderFactory: P2PEthPoolFeeLoader.Factory, + private val stakeKitTransactionSenderFactory: StakeKitTransactionSender.Factory, + private val p2pEthPoolTransactionSenderFactory: P2PEthPoolTransactionSender.Factory, +) { + + fun createFeeLoader( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWallet: UserWallet, + integration: StakingIntegration, + ): StakingFeeLoader { + return when (integration) { + is StakeKitIntegration -> stakeKitFeeLoaderFactory.create( + cryptoCurrencyStatus = cryptoCurrencyStatus, + userWallet = userWallet, + integration = integration, + ) + is P2PEthPoolIntegration -> p2pEthPoolFeeLoaderFactory.create( + cryptoCurrencyStatus = cryptoCurrencyStatus, + integration = integration, + ) + } + } + + fun createTransactionSender( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWallet: UserWallet, + integration: StakingIntegration, + isAmountSubtractAvailable: Boolean, + ): StakingTransactionSender { + return when (integration) { + is StakeKitIntegration -> stakeKitTransactionSenderFactory.create( + cryptoCurrencyStatus = cryptoCurrencyStatus, + userWallet = userWallet, + integration = integration, + isAmountSubtractAvailable = isAmountSubtractAvailable, + ) + is P2PEthPoolIntegration -> p2pEthPoolTransactionSenderFactory.create( + cryptoCurrencyStatus = cryptoCurrencyStatus, + userWallet = userWallet, + integration = integration, + ) + } + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt index f54ed73203..968bf1c310 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt @@ -1,312 +1,20 @@ package com.tangem.features.staking.impl.presentation.state.helpers -import arrow.core.getOrElse -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.TransactionSender import com.tangem.blockchain.common.transaction.Fee -import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.staking.NetworkType -import com.tangem.domain.models.staking.PendingAction -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.staking.GetConstructedStakingTransactionUseCase -import com.tangem.domain.staking.GetStakingTransactionsUseCase -import com.tangem.domain.staking.SaveUnsubmittedHashUseCase -import com.tangem.domain.staking.SubmitHashUseCase -import com.tangem.domain.staking.model.StakingIntegration -import com.tangem.domain.staking.model.SubmitHashData import com.tangem.domain.staking.model.stakekit.StakingError -import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType -import com.tangem.domain.staking.model.stakekit.transaction.ActionParams import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction -import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionStatus -import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionType import com.tangem.domain.transaction.error.SendTransactionError -import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase -import com.tangem.domain.transaction.usecase.SendTransactionUseCase -import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase -import com.tangem.domain.utils.convertToSdkAmount -import com.tangem.features.staking.impl.presentation.state.FeeState -import com.tangem.features.staking.impl.presentation.state.StakingStateController -import com.tangem.features.staking.impl.presentation.state.StakingStates -import com.tangem.features.staking.impl.presentation.state.StakingUiState -import com.tangem.features.staking.impl.presentation.state.utils.checkAndCalculateSubtractedAmount -import com.tangem.features.staking.impl.presentation.state.utils.isCompositePendingActions -import com.tangem.utils.extensions.orZero -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import timber.log.Timber -import java.math.BigDecimal -@Suppress("LongParameterList") -internal class StakingTransactionSender @AssistedInject constructor( - private val stateController: StakingStateController, - private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, - private val getStakingTransactionsUseCase: GetStakingTransactionsUseCase, - private val getConstructedStakingTransactionUseCase: GetConstructedStakingTransactionUseCase, - private val sendTransactionUseCase: SendTransactionUseCase, - private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, - private val submitHashUseCase: SubmitHashUseCase, - private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase, - private val isFeeApproximateUseCase: IsFeeApproximateUseCase, - @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, - @Assisted private val userWallet: UserWallet, - @Assisted private val integration: StakingIntegration, - @Assisted private val isAmountSubtractAvailable: Boolean, -) { +internal interface StakingTransactionSender { - private val balanceUpdater: StakingBalanceUpdater - get() = stakingBalanceUpdater.create(cryptoCurrencyStatus, userWallet, integration) + suspend fun send(callbacks: Callbacks) - suspend fun constructAndSendTransactions( - onConstructSuccess: (List) -> Unit, - onConstructError: (StakingError) -> Unit, - onSendSuccess: (String) -> Unit, - onSendError: (SendTransactionError) -> Unit, - onFeeIncreased: (Fee, Boolean) -> Unit, - ) { - val state = stateController.value - - val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data - ?: error("No confirmation state") - val fee = (confirmationState.feeState as? FeeState.Content)?.fee - ?: error("No fee provided") - val amountState = state.amountState as? AmountState.Data ?: error("No amount state") - - val stakingTransactions = getStakingTransactions( - state = state, - confirmationState = confirmationState, - onConstructError = onConstructError, - ) - - val fullTransactionsData = getConstructedTransactions( - stakingTransactions = stakingTransactions, - fee = fee, - amount = amountState.amountTextField.cryptoAmount.value.orZero(), - onConstructError = onConstructError, - ) - - if (fullTransactionsData.isNullOrEmpty()) { - onConstructError(StakingError.DomainError("fullTransactionsData is null or empty")) - return - } - - val totalFee = fullTransactionsData.sumOf { it.stakeKitTransaction.gasEstimate?.amount.orZero() } - - if (fee.amount.value.orZero() >= totalFee) { - onConstructSuccess(fullTransactionsData.map { it.stakeKitTransaction }) - sendStakingTransaction( - fullTransactionsData = fullTransactionsData, - onSendSuccess = onSendSuccess, - onSendError = onSendError, - ) - } else { - val amount = fee.amount.copy(value = totalFee) - onFeeIncreased( - Fee.Common(amount), - isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), - ) - } - } - - private suspend fun getStakingTransactions( - state: StakingUiState, - confirmationState: StakingStates.ConfirmationState.Data, - onConstructError: (StakingError) -> Unit, - ) = coroutineScope { - val isComposePendingActions = isCompositePendingActions( - networkId = cryptoCurrencyStatus.currency.network.rawId, - pendingActions = confirmationState.pendingActions, - ) - if (isComposePendingActions) { - confirmationState.pendingActions?.map { action -> - async { - getStakingTransaction( - state = state, - action = action, - confirmationState = confirmationState, - onConstructError = onConstructError, - ) - } - }?.awaitAll()?.flatten() - } else { - getStakingTransaction( - state = state, - confirmationState = confirmationState, - onConstructError = onConstructError, - ) - } - } - - private suspend fun getConstructedTransactions( - stakingTransactions: List?, - fee: Fee, - amount: BigDecimal, - onConstructError: (StakingError) -> Unit, - ) = coroutineScope { - stakingTransactions - ?.filterNot { - it.type == StakingTransactionType.APPROVAL || it.status == StakingTransactionStatus.SKIPPED - } - ?.map { transaction -> - async { - getConstructedStakingTransactionUseCase( - networkId = cryptoCurrencyStatus.currency.network.rawId, - fee = fee, - amount = amount.convertToSdkAmount(cryptoCurrencyStatus), - transactionId = transaction.id, - ).fold( - ifRight = { (constructedTransaction, transactionData) -> - FullTransactionData( - stakeKitTransaction = constructedTransaction, - tangemTransaction = transactionData, - ) - }, - ifLeft = { - onConstructError(it) - null - }, - ) - } - } - ?.awaitAll() - ?.filterNotNull() - } - - private suspend fun getStakingTransaction( - state: StakingUiState, - confirmationState: StakingStates.ConfirmationState.Data, - action: PendingAction? = confirmationState.pendingAction, - onConstructError: (StakingError) -> Unit, - ): List { - val validatorState = state.validatorState as? StakingStates.ValidatorState.Data - ?: error("No validator provided") - val fee = (confirmationState.feeState as? FeeState.Content)?.fee - ?: error("No fee provided") - val defaultAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value - ?: error("No available address") - val amountState = state.amountState as? AmountState.Data - ?: error("No amount provided") - - val validatorAddress = validatorState.chosenTarget.address - val amount = getAmount(amountState, fee, confirmationState.reduceAmountBy) - - return getStakingTransactionsUseCase( - userWalletId = userWallet.walletId, - network = cryptoCurrencyStatus.currency.network, - params = ActionParams( - actionCommonType = state.actionType, - integrationId = integration.integrationId.value, - amount = amount, - address = defaultAddress, - validatorAddress = validatorAddress, - token = integration.getCurrentToken(cryptoCurrencyStatus.currency.id.rawCurrencyId), - passthrough = action?.passthrough, - type = action?.type, - ), - ).getOrElse { - onConstructError(it) - return emptyList() - } - } - - private suspend fun sendStakingTransaction( - fullTransactionsData: List, - onSendSuccess: (txUrl: String) -> Unit, - onSendError: (SendTransactionError) -> Unit, - ) { - if (fullTransactionsData.isEmpty()) return - - val sortedTransactions = fullTransactionsData.sortedBy { it.stakeKitTransaction.stepIndex } - - val firstTransaction = sortedTransactions.first() - val network = firstTransaction.stakeKitTransaction.network - - val sendMode = if (network == NetworkType.SOLANA && - firstTransaction.stakeKitTransaction.type == StakingTransactionType.SPLIT - ) { - TransactionSender.MultipleTransactionSendMode.WAIT_AFTER_FIRST - } else { - TransactionSender.MultipleTransactionSendMode.DEFAULT - } - - sendTransactionUseCase( - txsData = sortedTransactions.map { it.tangemTransaction }, - userWallet = userWallet, - network = cryptoCurrencyStatus.currency.network, - sendMode = sendMode, - ).fold( - ifLeft = { error -> - onSendError(error) - }, - ifRight = { transactionHashes -> - submitHash( - transactions = sortedTransactions.map { it.stakeKitTransaction }, - transactionHashes = transactionHashes, - ) - - val txUrl = getExplorerTransactionUrlUseCase( - txHash = transactionHashes.last(), - networkId = cryptoCurrencyStatus.currency.network.id, - ).getOrNull() ?: "" - - balanceUpdater.updateAfterTransaction() - onSendSuccess(txUrl) - }, - ) - } - - private suspend fun submitHash(transactions: List, transactionHashes: List) { - transactions - .zip(transactionHashes) - .forEach { (transaction, transactionHash) -> - submitHashUseCase( - SubmitHashData( - transactionId = transaction.id, - transactionHash = transactionHash, - ), - ) - .onLeft { - saveUnsubmittedHashUseCase.invoke( - transactionId = transaction.id, - transactionHash = transactionHash, - ) - }.onRight { - Timber.d("Successful hash submission") - } - } - } - - private fun getAmount(amountState: AmountState.Data, fee: Fee, reduceAmountBy: BigDecimal?): BigDecimal { - val amountValue = amountState.amountTextField.cryptoAmount.value ?: error("No amount value") - val feeValue = fee.amount.value ?: error("No fee value") - val isEnterAction = stateController.value.actionType is StakingActionCommonType.Enter - - return checkAndCalculateSubtractedAmount( - isAmountSubtractAvailable = isAmountSubtractAvailable && isEnterAction, - cryptoCurrencyStatus = cryptoCurrencyStatus, - amountValue = amountValue, - feeValue = feeValue, - reduceAmountBy = reduceAmountBy.orZero(), - ) - } - - private data class FullTransactionData( - val stakeKitTransaction: StakingTransaction, - val tangemTransaction: TransactionData.Compiled, + class Callbacks( + val onConstructSuccess: (List) -> Unit, + val onConstructError: (StakingError) -> Unit, + val onSendSuccess: (String) -> Unit, + val onSendError: (SendTransactionError) -> Unit, + val onFeeIncreased: (Fee, Boolean) -> Unit, + val onTransactionExpired: () -> Unit, ) - - @AssistedFactory - interface Factory { - fun create( - cryptoCurrencyStatus: CryptoCurrencyStatus, - userWallet: UserWallet, - integration: StakingIntegration, - isAmountSubtractAvailable: Boolean, - ): StakingTransactionSender - } } \ No newline at end of file From 4b07ed004f0dadddc6b1fc8d22afaa4f10c4128f Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Dec 2025 15:19:26 +0500 Subject: [PATCH 023/172] Updated on 2026-08-14 --- .../core/ui/ds/button/AccentTangemButton.kt | 125 +++++++++ .../core/ui/ds/button/GhostTangemButton.kt | 111 ++++++++ .../core/ui/ds/button/OutlineTangemButton.kt | 132 +++++++++ .../ds/button/PrimaryInverseTangemButton.kt | 127 +++++++++ .../core/ui/ds/button/PrimaryTangemButton.kt | 122 +++++++++ .../ui/ds/button/SecondaryTangemButton.kt | 125 +++++++++ .../core/ui/ds/button/TangemButtonInternal.kt | 256 ++++++++++++++++++ .../tangem/core/ui/extensions/ModifierExt.kt | 17 +- 8 files changed, 999 insertions(+), 16 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/button/AccentTangemButton.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/button/OutlineTangemButton.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryInverseTangemButton.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryTangemButton.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/AccentTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/AccentTangemButton.kt new file mode 100644 index 0000000000..da0e951bfa --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/AccentTangemButton.kt @@ -0,0 +1,125 @@ +package com.tangem.core.ui.ds.button + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * [Accent Tangem button](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8004-26798) + * + * @param onClick Lambda to be invoked when the button is clicked. + * @param modifier Modifier to be applied to the button. + * @param text TextReference for the button label. + * @param iconRes Drawable resource ID for the icon to be displayed in the button. + * @param iconPosition Position of the icon (Start or End). + * @param enabled Boolean indicating whether the button is enabled. + * @param size TangemButtonSize defining the size of the button. + * @param state TangemButtonState defining the current state of the button. + * @param shape TangemButtonShape defining the shape of the button. + * +[REDACTED_AUTHOR] + */ +@Composable +fun AccentTangemButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: TextReference? = null, + @DrawableRes iconRes: Int? = null, + iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, + enabled: Boolean = true, + size: TangemButtonSize = TangemButtonSize.X15, + state: TangemButtonState = TangemButtonState.Default, + shape: TangemButtonShape = TangemButtonShape.Default, +) { + TangemButtonInternal( + onClick = onClick, + modifier = modifier + .clip(shape.toShape(size)) + .then( + when (state) { + TangemButtonState.Disabled, + TangemButtonState.Default, + -> Modifier.background(TangemTheme.colors2.button.backgroundPositive) + TangemButtonState.Loading, + TangemButtonState.Pressed, + -> Modifier + .background(TangemTheme.colors2.button.backgroundPositive) + .background(TangemTheme.colors2.overlay.overlaySecondary) + }, + ), + text = text, + contentColor = TangemTheme.colors2.text.neutral.primaryInvertedConstant, + iconRes = iconRes, + enabled = enabled, + size = size, + state = state, + iconPosition = iconPosition, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 480) +@Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun AccentTangemButton_Preview( + @PreviewParameter(AccentTangemButtonPreviewProvider::class) params: TangemButtonState, +) { + TangemThemePreviewRedesign { + Row( + horizontalArrangement = Arrangement.spacedBy(21.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + repeat(4) { yIndex -> + val shape = if (yIndex < 2) TangemButtonShape.Default else TangemButtonShape.Rounded + val text = if (yIndex % 2 == 1) null else stringReference("Button") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(2) { xIndex -> + val iconPosition = if (xIndex == 1) { + TangemButtonIconPosition.Start + } else { + TangemButtonIconPosition.End + } + AccentTangemButton( + onClick = {}, + text = text, + size = TangemButtonSize.X15, + shape = shape, + iconPosition = iconPosition, + iconRes = R.drawable.ic_tangem_24, + state = params, + ) + } + } + } + } + } +} + +private class AccentTangemButtonPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemButtonState.Default, + TangemButtonState.Pressed, + TangemButtonState.Loading, + TangemButtonState.Disabled, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt new file mode 100644 index 0000000000..16b1f51876 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt @@ -0,0 +1,111 @@ +package com.tangem.core.ui.ds.button + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * [Ghost Tangem button](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=5854-4804) + * + * @param onClick Lambda to be invoked when the button is clicked. + * @param modifier Modifier to be applied to the button. + * @param text TextReference for the button label. + * @param iconRes Drawable resource ID for the icon to be displayed in the button. + * @param iconPosition Position of the icon (Start or End). + * @param enabled Boolean indicating whether the button is enabled. + * @param size TangemButtonSize defining the size of the button. + * @param state TangemButtonState defining the current state of the button. + * +[REDACTED_AUTHOR] + */ +@Composable +fun GhostTangemButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: TextReference? = null, + @DrawableRes iconRes: Int? = null, + enabled: Boolean = true, + size: TangemButtonSize = TangemButtonSize.X15, + state: TangemButtonState = TangemButtonState.Default, + iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, +) { + val contentColor = when (state) { + TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled + else -> TangemTheme.colors2.text.neutral.primary + } + TangemButtonInternal( + onClick = onClick, + modifier = modifier, + text = text, + contentColor = contentColor, + enabled = enabled, + size = size, + state = state, + iconPosition = iconPosition, + iconRes = iconRes, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 480) +@Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun GhostTangemButton_Preview( + @PreviewParameter(GhostTangemButtonPreviewProvider::class) params: TangemButtonState, +) { + TangemThemePreviewRedesign { + Row( + horizontalArrangement = Arrangement.spacedBy(21.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + repeat(4) { yIndex -> + val text = if (yIndex % 2 == 1) null else stringReference("Button") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(2) { xIndex -> + val iconPosition = if (xIndex == 1) { + TangemButtonIconPosition.Start + } else { + TangemButtonIconPosition.End + } + GhostTangemButton( + onClick = {}, + text = text, + size = TangemButtonSize.X15, + iconPosition = iconPosition, + iconRes = R.drawable.ic_tangem_24, + state = params, + ) + } + } + } + } + } +} + +private class GhostTangemButtonPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemButtonState.Default, + TangemButtonState.Pressed, + TangemButtonState.Loading, + TangemButtonState.Disabled, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/OutlineTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/OutlineTangemButton.kt new file mode 100644 index 0000000000..e86db3a8b8 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/OutlineTangemButton.kt @@ -0,0 +1,132 @@ +package com.tangem.core.ui.ds.button + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * [Outline Tangem button](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=5854-4800) + * + * @param onClick Lambda to be invoked when the button is clicked. + * @param modifier Modifier to be applied to the button. + * @param text TextReference for the button label. + * @param iconRes Drawable resource ID for the icon to be displayed in the button. + * @param iconPosition Position of the icon (Start or End). + * @param enabled Boolean indicating whether the button is enabled. + * @param size TangemButtonSize defining the size of the button. + * @param state TangemButtonState defining the current state of the button. + * @param shape TangemButtonShape defining the shape of the button. + * +[REDACTED_AUTHOR] + */ +@Composable +fun OutlineTangemButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: TextReference? = null, + @DrawableRes iconRes: Int? = null, + iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, + enabled: Boolean = true, + size: TangemButtonSize = TangemButtonSize.X15, + state: TangemButtonState = TangemButtonState.Default, + shape: TangemButtonShape = TangemButtonShape.Default, +) { + val backgroundModifier = when (state) { + TangemButtonState.Loading, + TangemButtonState.Pressed, + TangemButtonState.Disabled, + TangemButtonState.Default, + -> Modifier + .background(TangemTheme.colors2.surface.level1) + .border( + width = 1.dp, + color = TangemTheme.colors2.border.neutral.primary, + shape = shape.toShape(size), + ) + } + val contentColor = when (state) { + TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled + else -> TangemTheme.colors2.text.neutral.primary + } + TangemButtonInternal( + onClick = onClick, + modifier = modifier + .clip(shape.toShape(size)) + .then(backgroundModifier), + text = text, + contentColor = contentColor, + iconRes = iconRes, + enabled = enabled, + size = size, + state = state, + iconPosition = iconPosition, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 480) +@Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun OutlineTangemButton_Preview( + @PreviewParameter(OutlineTangemButtonPreviewProvider::class) params: TangemButtonState, +) { + TangemThemePreviewRedesign { + Row( + horizontalArrangement = Arrangement.spacedBy(21.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + repeat(4) { yIndex -> + val shape = if (yIndex < 2) TangemButtonShape.Default else TangemButtonShape.Rounded + val text = if (yIndex % 2 == 1) null else stringReference("Button") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(2) { xIndex -> + val iconPosition = if (xIndex == 0) { + TangemButtonIconPosition.Start + } else { + TangemButtonIconPosition.End + } + OutlineTangemButton( + onClick = {}, + text = text, + size = TangemButtonSize.X15, + shape = shape, + iconPosition = iconPosition, + iconRes = R.drawable.ic_tangem_24, + state = params, + ) + } + } + } + } + } +} + +private class OutlineTangemButtonPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemButtonState.Default, + TangemButtonState.Pressed, + TangemButtonState.Loading, + TangemButtonState.Disabled, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryInverseTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryInverseTangemButton.kt new file mode 100644 index 0000000000..8fc4f8644e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryInverseTangemButton.kt @@ -0,0 +1,127 @@ +package com.tangem.core.ui.ds.button + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * [Primary Inverse Tangem button](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=7545-78314) + * + * @param onClick Lambda to be invoked when the button is clicked. + * @param modifier Modifier to be applied to the button. + * @param text TextReference for the button label. + * @param iconRes Drawable resource ID for the icon to be displayed in the button. + * @param iconPosition Position of the icon (Start or End). + * @param enabled Boolean indicating whether the button is enabled. + * @param size TangemButtonSize defining the size of the button. + * @param state TangemButtonState defining the current state of the button. + * @param shape TangemButtonShape defining the shape of the button. + * +[REDACTED_AUTHOR] + */ +@Composable +fun PrimaryInverseTangemButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: TextReference? = null, + @DrawableRes iconRes: Int? = null, + iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, + enabled: Boolean = true, + size: TangemButtonSize = TangemButtonSize.X15, + state: TangemButtonState = TangemButtonState.Default, + shape: TangemButtonShape = TangemButtonShape.Default, +) { + val backgroundModifier = when (state) { + TangemButtonState.Default -> Modifier.background(TangemTheme.colors2.button.backgroundPrimaryInverse) + TangemButtonState.Disabled -> Modifier.background(TangemTheme.colors2.button.backgroundDisabled) + TangemButtonState.Loading, + TangemButtonState.Pressed, + -> Modifier + .background(TangemTheme.colors2.button.backgroundPrimaryInverse) + .background(TangemTheme.colors2.overlay.overlayPrimary) + } + val contentColor = when (state) { + TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled + else -> TangemTheme.colors2.text.neutral.primary + } + TangemButtonInternal( + onClick = onClick, + modifier = modifier + .clip(shape.toShape(size)) + .then(backgroundModifier), + text = text, + contentColor = contentColor, + enabled = enabled, + size = size, + state = state, + iconPosition = iconPosition, + iconRes = iconRes, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 480) +@Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PrimaryInverseTangemButton_Preview( + @PreviewParameter(PrimaryInverseTangemButtonPreviewProvider::class) params: TangemButtonState, +) { + TangemThemePreviewRedesign { + Row( + horizontalArrangement = Arrangement.spacedBy(21.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level2) + .padding(8.dp), + ) { + repeat(4) { yIndex -> + val shape = if (yIndex < 2) TangemButtonShape.Default else TangemButtonShape.Rounded + val text = if (yIndex % 2 == 1) null else stringReference("Button") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(2) { xIndex -> + val iconPosition = if (xIndex == 0) { + TangemButtonIconPosition.Start + } else { + TangemButtonIconPosition.End + } + PrimaryInverseTangemButton( + onClick = {}, + text = text, + size = TangemButtonSize.X15, + shape = shape, + iconPosition = iconPosition, + iconRes = R.drawable.ic_tangem_24, + state = params, + ) + } + } + } + } + } +} + +private class PrimaryInverseTangemButtonPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemButtonState.Default, + TangemButtonState.Pressed, + TangemButtonState.Loading, + TangemButtonState.Disabled, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryTangemButton.kt new file mode 100644 index 0000000000..74487c813b --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryTangemButton.kt @@ -0,0 +1,122 @@ +package com.tangem.core.ui.ds.button + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * [Primary Tangem button](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=5854-4732&t=euYo1qCxPlQl3Fa6-4) + * + * @param onClick Lambda to be invoked when the button is clicked. + * @param modifier Modifier to be applied to the button. + * @param text TextReference for the button label. + * @param iconRes Drawable resource ID for the icon to be displayed in the button. + * @param iconPosition Position of the icon (Start or End). + * @param enabled Boolean indicating whether the button is enabled. + * @param size TangemButtonSize defining the size of the button. + * @param state TangemButtonState defining the current state of the button. + * @param shape TangemButtonShape defining the shape of the button. + * +[REDACTED_AUTHOR] + */ +@Composable +fun PrimaryTangemButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: TextReference? = null, + @DrawableRes iconRes: Int? = null, + iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, + enabled: Boolean = true, + size: TangemButtonSize = TangemButtonSize.X15, + state: TangemButtonState = TangemButtonState.Default, + shape: TangemButtonShape = TangemButtonShape.Default, +) { + val backgroundModifier = when (state) { + TangemButtonState.Loading, + TangemButtonState.Default, + -> Modifier.background(TangemTheme.colors2.button.backgroundPrimary) + TangemButtonState.Disabled -> Modifier.background(TangemTheme.colors2.button.backgroundDisabled) + TangemButtonState.Pressed -> Modifier + .background(TangemTheme.colors2.button.backgroundPrimary) + .background(TangemTheme.colors2.overlay.overlaySecondary) + } + val contentColor = when (state) { + TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled + else -> TangemTheme.colors2.text.neutral.primaryInverted + } + TangemButtonInternal( + onClick = onClick, + modifier = modifier + .clip(shape.toShape(size)) + .then(backgroundModifier), + text = text, + contentColor = contentColor, + enabled = enabled, + size = size, + state = state, + iconPosition = iconPosition, + iconRes = iconRes, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 480) +@Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PrimaryTangemButton_Preview( + @PreviewParameter(PrimaryTangemButtonPreviewProvider::class) params: TangemButtonState, +) { + TangemThemePreviewRedesign { + Row( + horizontalArrangement = Arrangement.spacedBy(21.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + repeat(4) { yIndex -> + val shape = if (yIndex < 2) TangemButtonShape.Default else TangemButtonShape.Rounded + val text = if (yIndex % 2 == 1) null else stringReference("Button") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(TangemButtonIconPosition.entries.size) { xIndex -> + PrimaryTangemButton( + onClick = {}, + text = text, + size = TangemButtonSize.X15, + shape = shape, + iconPosition = TangemButtonIconPosition.entries[xIndex], + iconRes = R.drawable.ic_tangem_24, + state = params, + ) + } + } + } + } + } +} + +private class PrimaryTangemButtonPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemButtonState.Default, + TangemButtonState.Pressed, + TangemButtonState.Loading, + TangemButtonState.Disabled, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt new file mode 100644 index 0000000000..8c43685df8 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt @@ -0,0 +1,125 @@ +package com.tangem.core.ui.ds.button + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * [Secondary Tangem button](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=5854-4796) + * + * @param onClick Lambda to be invoked when the button is clicked. + * @param modifier Modifier to be applied to the button. + * @param text TextReference for the button label. + * @param iconRes Drawable resource ID for the icon to be displayed in the button. + * @param iconPosition Position of the icon (Start or End). + * @param enabled Boolean indicating whether the button is enabled. + * @param size TangemButtonSize defining the size of the button. + * @param state TangemButtonState defining the current state of the button. + * @param shape TangemButtonShape defining the shape of the button. + * +[REDACTED_AUTHOR] + */ +@Composable +fun SecondaryTangemButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: TextReference? = null, + @DrawableRes iconRes: Int? = null, + iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, + enabled: Boolean = true, + size: TangemButtonSize = TangemButtonSize.X15, + state: TangemButtonState = TangemButtonState.Default, + shape: TangemButtonShape = TangemButtonShape.Default, +) { + val backgroundModifier = when (state) { + TangemButtonState.Loading, + TangemButtonState.Default, + -> Modifier.background(TangemTheme.colors2.button.backgroundSecondary) + TangemButtonState.Disabled -> Modifier.background(TangemTheme.colors2.button.backgroundDisabled) + TangemButtonState.Pressed -> Modifier.background(TangemTheme.colors2.overlay.overlayPrimary) + } + val contentColor = when (state) { + TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled + else -> TangemTheme.colors2.text.neutral.primary + } + TangemButtonInternal( + onClick = onClick, + modifier = modifier + .clip(shape.toShape(size)) + .then(backgroundModifier), + text = text, + contentColor = contentColor, + iconRes = iconRes, + enabled = enabled, + size = size, + state = state, + iconPosition = iconPosition, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 480) +@Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun SecondaryTangemButton_Preview( + @PreviewParameter(SecondaryTangemButtonPreviewProvider::class) params: TangemButtonState, +) { + TangemThemePreviewRedesign { + Row( + horizontalArrangement = Arrangement.spacedBy(21.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + repeat(4) { yIndex -> + val shape = if (yIndex < 2) TangemButtonShape.Default else TangemButtonShape.Rounded + val text = if (yIndex % 2 == 1) null else stringReference("Button") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(2) { xIndex -> + val iconPosition = if (xIndex == 0) { + TangemButtonIconPosition.Start + } else { + TangemButtonIconPosition.End + } + SecondaryTangemButton( + onClick = {}, + text = text, + size = TangemButtonSize.X15, + shape = shape, + iconPosition = iconPosition, + iconRes = R.drawable.ic_tangem_24, + state = params, + ) + } + } + } + } + } +} + +private class SecondaryTangemButtonPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemButtonState.Default, + TangemButtonState.Pressed, + TangemButtonState.Loading, + TangemButtonState.Disabled, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt new file mode 100644 index 0000000000..abc8dcf003 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt @@ -0,0 +1,256 @@ +package com.tangem.core.ui.ds.button + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.conditionalCompose +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.BaseButtonTestTags + +/** + * A customizable button component that supports text, icons, and different states. + * + * @param onClick Lambda to be invoked when the button is clicked. + * @param modifier Modifier to be applied to the button. + * @param text TextReference for the button label. + * @param iconRes Drawable resource ID for the icon to be displayed in the button. + * @param iconPosition Position of the icon (Start or End). + * @param enabled Boolean indicating whether the button is enabled. + * @param contentColor Color of the button content (text and icon). + * @param size TangemButtonSize defining the size of the button. + * @param state TangemButtonState defining the current state of the button. + * +[REDACTED_AUTHOR] + */ +@Composable +internal fun TangemButtonInternal( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: TextReference? = null, + @DrawableRes iconRes: Int? = null, + iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, + enabled: Boolean = true, + contentColor: Color = TangemTheme.colors2.text.neutral.primary, + size: TangemButtonSize = TangemButtonSize.X15, + state: TangemButtonState = TangemButtonState.Default, +) { + Row( + modifier = modifier + .testTag(BaseButtonTestTags.BUTTON) + .height(size.toHeightDp()) + .conditionalCompose(text == null) { + width(size.toHeightDp()) + } + .clickableSingle(enabled = enabled, onClick = onClick, role = Role.Button) + .conditionalCompose(text != null) { + padding(horizontal = size.toPaddingDp()) + } + .animateContentSize(), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedVisibility( + visible = iconRes != null && iconPosition == TangemButtonIconPosition.Start, + modifier = Modifier.size(size = size.toContentSize()), + ) { + val wrappedIconRes = remember(this) { requireNotNull(iconRes) } + TangemButtonIcon(iconRes = wrappedIconRes, state = state, iconColor = contentColor, size = size) + } + + AnimatedVisibility(text != null && state != TangemButtonState.Loading) { + val wrappedText = remember(this) { requireNotNull(text) } + val textStyle = size.toTextStyle() + Text( + text = wrappedText.resolveReference(), + style = textStyle, + color = contentColor, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = 12.sp, + maxFontSize = textStyle.fontSize, + ), + modifier = Modifier.testTag(BaseButtonTestTags.TEXT), + ) + } + + AnimatedVisibility( + visible = iconRes != null && iconPosition == TangemButtonIconPosition.End, + modifier = Modifier.size(size = size.toContentSize()), + ) { + val wrappedIconRes = remember(this) { requireNotNull(iconRes) } + TangemButtonIcon(iconRes = wrappedIconRes, state = state, iconColor = contentColor, size = size) + } + } +} + +@Composable +private fun TangemButtonIcon( + @DrawableRes iconRes: Int, + iconColor: Color, + state: TangemButtonState, + size: TangemButtonSize, +) { + AnimatedContent(state) { targetState -> + when (targetState) { + TangemButtonState.Loading -> CircularProgressIndicator( + color = iconColor, + strokeWidth = 2.dp, + strokeCap = StrokeCap.Round, + modifier = Modifier.padding( + when (size) { + TangemButtonSize.X7, + TangemButtonSize.X8, + TangemButtonSize.X9, + TangemButtonSize.X10, + -> 0.5.dp + TangemButtonSize.X12, + TangemButtonSize.X15, + -> 4.5.dp + }, + ), + ) + else -> Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = iconColor, + ) + } + } +} + +/** + * Defines the shape of the Tangem button. + */ +enum class TangemButtonShape { + Default, + Rounded, + ; + + @ReadOnlyComposable + @Composable + internal fun toShape(size: TangemButtonSize) = RoundedCornerShape( + when (this) { + Default -> size.toShapeRadius() + Rounded -> 100.dp + }, + ) +} + +/** + * Defines the size of the Tangem button. + */ +enum class TangemButtonSize { + X7, + X8, + X9, + X10, + X12, + X15, + ; + + @ReadOnlyComposable + @Composable + internal fun toHeightDp() = when (this) { + X7 -> TangemTheme.dimens2.x7 + X8 -> TangemTheme.dimens2.x8 + X9 -> TangemTheme.dimens2.x9 + X10 -> TangemTheme.dimens2.x10 + X12 -> TangemTheme.dimens2.x12 + X15 -> TangemTheme.dimens2.x15 + } + + @ReadOnlyComposable + @Composable + internal fun toPaddingDp() = when (this) { + X7 -> TangemTheme.dimens2.x2 + X8, + X9, + X10, + -> TangemTheme.dimens2.x3 + X12, + X15, + -> TangemTheme.dimens2.x6 + } + + @ReadOnlyComposable + @Composable + internal fun toContentSize() = when (this) { + X7, + X8, + X9, + X10, + -> TangemTheme.dimens2.x5 + X12, + X15, + -> TangemTheme.dimens2.x7 + } + + @ReadOnlyComposable + @Composable + internal fun toShapeRadius() = when (this) { + X7, + X8, + X9, + X10, + -> TangemTheme.dimens2.x2 + X12 -> TangemTheme.dimens2.x3 + X15 -> TangemTheme.dimens2.x4 + } + + @ReadOnlyComposable + @Composable + internal fun toTextStyle(): TextStyle = when (this) { + X7 -> TangemTheme.typography2.bodyRegular14 + X8, + X9, + X10, + X12, + X15, + -> TangemTheme.typography2.bodySemibold16 + } +} + +/** + * Defines the state of the Tangem button. + */ +enum class TangemButtonState { + Default, + Disabled, + Pressed, + Loading, +} + +/** + * Defines the position of the icon in the Tangem button. + */ +enum class TangemButtonIconPosition { + Start, + End, +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt index 7b85a6004d..1f0a7b1208 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt @@ -54,23 +54,8 @@ fun Modifier.conditional(condition: Boolean, modifier: Modifier.() -> Modifier): @Composable fun Modifier.conditionalCompose( condition: Boolean, - modifier: @Composable Modifier.() -> Modifier = { Modifier }, -): Modifier { - return if (condition) { - then(modifier(Modifier)) - } else { - this - } -} - -/** - * Conditionally applies a modifier based on a boolean condition. - */ -@Composable -fun Modifier.conditionalCompose( - condition: Boolean, - modifier: @Composable Modifier.() -> Modifier = { Modifier }, otherModifier: @Composable Modifier.() -> Modifier = { this }, + modifier: @Composable Modifier.() -> Modifier = { Modifier }, ): Modifier { return if (condition) { then(modifier(Modifier)) From 1f233c091b09083b54e5de0aceb375c54a626826 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Dec 2025 13:12:23 +0300 Subject: [PATCH 024/172] Updated on 2026-08-14 --- .../models/response/ExchangeProvider.kt | 3 ++ .../converter/ExpressProviderConverter.kt | 1 + .../data/swap/DefaultSwapRepositoryV2.kt | 28 +++++++++++++++---- .../domain/express/models/ExpressProvider.kt | 3 ++ .../features/swap/v2/impl/common/SwapUtils.kt | 5 +++- 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt index 2ef036c212..4712dadadc 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt @@ -32,6 +32,9 @@ data class ExchangeProvider( @Json(name = "slippage") val slippage: BigDecimal?, + + @Json(name = "exchangeOnlyWithinSingleAddress") + val isExchangeOnlyWithinSingleAddress: Boolean = false, ) @JsonClass(generateAdapter = false) diff --git a/data/express/src/main/java/com/tangem/data/express/converter/ExpressProviderConverter.kt b/data/express/src/main/java/com/tangem/data/express/converter/ExpressProviderConverter.kt index 36c5e88460..09a2f55b15 100644 --- a/data/express/src/main/java/com/tangem/data/express/converter/ExpressProviderConverter.kt +++ b/data/express/src/main/java/com/tangem/data/express/converter/ExpressProviderConverter.kt @@ -19,6 +19,7 @@ internal class ExpressProviderConverter : Converter async { @@ -125,11 +125,11 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( swapTxType = swapTxType, ) - val providers = expressRepository.getProviders( + val mappedProviders = expressRepository.getFilteredProviders( userWallet = userWallet, filterProviderTypes = filterProviderTypes, - ) - val mappedProviders = providers.associateBy(ExpressProvider::providerId) + swapTxType = swapTxType, + ).associateBy(ExpressProvider::providerId) allPairs.map { pair -> async { @@ -434,4 +434,20 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( true } } +} + +private suspend fun ExpressRepository.getFilteredProviders( + userWallet: UserWallet, + filterProviderTypes: List, + swapTxType: SwapTxType, +): List { + return getProviders( + userWallet = userWallet, + filterProviderTypes = filterProviderTypes, + ).let { allProviders -> + when (swapTxType) { + SwapTxType.SendWithSwap -> allProviders.filterNot { it.isExchangeOnlyWithinSingleAddress } + SwapTxType.Swap -> allProviders + } + } } \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProvider.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProvider.kt index 9a26b89580..49b5797e0d 100644 --- a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProvider.kt +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProvider.kt @@ -16,6 +16,7 @@ import java.math.BigDecimal * @property privacyPolicy privacy policy link * @property isRecommended flag that indicates if this provider is recommended * @property slippage provider slippage + * @property isExchangeOnlyWithinSingleAddress flag that indicates if exchange is only allowed within a single address * * Uses to store transaction data in datastore, when extends - should always add default value * to support backward compatibility @@ -40,4 +41,6 @@ data class ExpressProvider( val isRecommended: Boolean = false, @Json(name = "slippage") val slippage: BigDecimal?, + @Json(name = "exchangeOnlyWithinSingleAddress") + val isExchangeOnlyWithinSingleAddress: Boolean = false, ) \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapUtils.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapUtils.kt index a8a7a45905..fee7ec431c 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapUtils.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapUtils.kt @@ -14,7 +14,10 @@ internal object SwapUtils { const val INCREASE_GAS_LIMIT_FOR_CEX = 105 // 5% /** List of supported provider types in Send with Swap */ - internal val SEND_WITH_SWAP_PROVIDER_TYPES = listOf(ExpressProviderType.CEX) + internal val SEND_WITH_SWAP_PROVIDER_TYPES = listOf( + ExpressProviderType.CEX, + ExpressProviderType.DEX, + ) fun getExpressErrorMessage(expressError: ExpressError): TextReference { return when (expressError) { From 3e0e8753d478d4a79768933ee0383b39dd73a94b Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Dec 2025 11:12:44 +0100 Subject: [PATCH 025/172] Updated on 2026-08-14 --- .../tangem/tap/di/domain/NewsDomainModule.kt | 16 +- .../tangem/datasource/di/NewsStoreModule.kt | 8 + .../news/viewed/DefaultNewsViewedStore.kt | 32 ++ .../local/news/viewed/NewsViewedStore.kt | 24 + .../com/tangem/data/news/di/NewsDataModule.kt | 3 + .../news/repository/DefaultNewsRepository.kt | 95 ++-- .../domain/news/model/NewsListConfig.kt | 2 +- .../domain/news/repository/NewsRepository.kt | 19 +- .../domain/news/usecase/GetNewsUseCase.kt | 17 + .../news/usecase/ManageTrendingNewsUseCase.kt | 14 - .../usecase/MarkArticleAsViewedUseCase.kt | 20 + .../components/DefaultFeedEntryComponent.kt | 6 - .../feed/components/FeedEntryChildFactory.kt | 10 +- .../DefaultMarketsTokenDetailsComponent.kt | 36 +- .../add/api/AddToPortfolioComponent.kt | 18 + .../add/api/AddToPortfolioManager.kt | 38 ++ .../portfolio/add/api/AvailableToAddData.kt | 58 +++ .../portfolio/add/impl/AddTokenComponent.kt | 55 +++ .../add/impl/ChooseNetworkComponent.kt | 45 ++ .../impl/DefaultAddToPortfolioComponent.kt | 212 +++++++++ .../add/impl/TokenActionsComponent.kt | 79 ++++ .../converter/AvailableToAddDataConverter.kt | 119 +++++ .../impl/di/AddToPortfolioComponentModule.kt | 21 + .../add/impl/di/AddToPortfolioModelModule.kt | 38 ++ .../add/impl/model/AddToPortfolioModel.kt | 382 +++++++++++++++ .../add/impl/model/AddToPortfolioRoutes.kt | 28 ++ .../portfolio/add/impl/model/AddTokenModel.kt | 118 +++++ .../add/impl/model/AddTokenUiBuilder.kt | 105 +++++ .../add/impl/model/ChooseNetworkModel.kt | 126 +++++ .../add/impl/model/TokenActionsModel.kt | 76 +++ .../add/impl/model/TokenActionsUiBuilder.kt | 44 ++ .../add/impl/ui/ChooseNetworkContent.kt | 110 +++++ .../impl/ui/DefaultAddToPortfolioManager.kt | 72 +++ .../add/impl/ui/TokenActionsContent.kt | 204 ++++++++ .../add/impl/ui/state/ChooseNetworkUM.kt | 9 + .../add/impl/ui/state/TokenActionsUM.kt | 10 + .../api/MarketsPortfolioComponent.kt | 29 ++ .../impl/DefaultMarketsPortfolioComponent.kt | 89 ++++ .../impl/analytics/PortfolioAnalyticsEvent.kt | 74 +++ .../portfolio/impl/di/ComponentModule.kt | 20 + .../details/portfolio/impl/di/ModelModule.kt | 20 + .../portfolio/impl/loader/PortfolioData.kt | 33 ++ .../impl/loader/PortfolioDataLoader.kt | 135 ++++++ .../model/AddToPortfolioBSContentUMFactory.kt | 161 +++++++ .../impl/model/AddToPortfolioManager.kt | 193 ++++++++ .../impl/model/BlockchainRowUMConverter.kt | 64 +++ .../impl/model/MarketsPortfolioModel.kt | 440 ++++++++++++++++++ .../impl/model/MarketsPortfolioRoute.kt | 17 + .../impl/model/MyPortfolioUMFactory.kt | 153 ++++++ .../impl/model/NewMarketsPortfolioDelegate.kt | 332 +++++++++++++ .../impl/model/PortfolioBSVisibilityModel.kt | 14 + .../impl/model/PortfolioTokenUMConverter.kt | 124 +++++ .../portfolio/impl/model/PortfolioUIData.kt | 20 + .../impl/model/SelectNetworkUMConverter.kt | 37 ++ .../impl/model/TokenActionsHandler.kt | 221 +++++++++ .../impl/model/TokensPortfolioUMConverter.kt | 116 +++++ .../impl/ui/AddToPortfolioBottomSheet.kt | 383 +++++++++++++++ .../details/portfolio/impl/ui/MyPortfolio.kt | 341 ++++++++++++++ .../portfolio/impl/ui/PortfolioItem.kt | 153 ++++++ .../impl/ui/PortfolioQuickActions.kt | 268 +++++++++++ .../impl/ui/TokenActionsBottomSheet.kt | 86 ++++ .../impl/ui/WalletSelectorBottomSheet.kt | 145 ++++++ .../PreviewAddToPortfolioBSContentProvider.kt | 86 ++++ .../preview/PreviewMyPortfolioUMProvider.kt | 150 ++++++ .../ui/state/AddToPortfolioBSContentUM.kt | 15 + .../portfolio/impl/ui/state/MyPortfolioUM.kt | 52 +++ .../impl/ui/state/PortfolioTokenUM.kt | 39 ++ .../portfolio/impl/ui/state/QuickActionUM.kt | 51 ++ .../impl/ui/state/SelectNetworkUM.kt | 13 + .../impl/ui/state/TokenActionsBSContentUM.kt | 58 +++ .../ui/state/WalletSelectorBSContentUM.kt | 10 + .../feed/model/feed/FeedComponentModel.kt | 27 +- .../details/MarketsTokenDetailsModel.kt | 38 ++ .../details/converter/RelatedNewsConverter.kt | 84 ++++ .../model/market/list/MarketsListModel.kt | 2 +- .../tangem/features/feed/ui/EntryContent.kt | 14 +- .../tangem/features/feed/ui/feed/FeedList.kt | 26 +- .../preview/FeedListPreviewDataProvider.kt | 8 +- .../features/feed/ui/feed/state/FeedListUM.kt | 15 +- .../ui/feed/state/TrendingNewsStateFactory.kt | 32 +- .../detailed/MarketsTokenDetailsContent.kt | 1 + .../components/TokenMarketDetailsBody.kt | 56 ++- .../preview/MarketsTokenDetailsPreview.kt | 16 +- .../detailed/state/MarketsTokenDetailsUM.kt | 8 + 84 files changed, 6595 insertions(+), 143 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/DefaultNewsViewedStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/NewsViewedStore.kt create mode 100644 domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt create mode 100644 domain/news/src/main/java/com/tangem/domain/news/usecase/MarkArticleAsViewedUseCase.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioComponent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioManager.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AvailableToAddData.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/api/MarketsPortfolioComponent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ComponentModule.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ModelModule.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioDataLoader.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioManager.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioRoute.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MyPortfolioUMFactory.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/NewMarketsPortfolioDelegate.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioBSVisibilityModel.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioUIData.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/SelectNetworkUMConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokensPortfolioUMConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/AddToPortfolioBottomSheet.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/MyPortfolio.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioItem.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/TokenActionsBottomSheet.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/WalletSelectorBottomSheet.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/AddToPortfolioBSContentUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/MyPortfolioUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/PortfolioTokenUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/SelectNetworkUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/WalletSelectorBSContentUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt index 55b1457a55..a49eceb64c 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt @@ -6,39 +6,43 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) internal object NewsDomainModule { @Provides - @Singleton fun provideGetNewsCategoriesUseCase(repository: NewsRepository): GetNewsCategoriesUseCase { return GetNewsCategoriesUseCase(repository) } @Provides - @Singleton fun provideObserveNewsDetailsUseCase(repository: NewsRepository): ObserveNewsDetailsUseCase { return ObserveNewsDetailsUseCase(repository) } @Provides - @Singleton fun provideObserveTrendingNewsUseCase(repository: NewsRepository): ManageTrendingNewsUseCase { return ManageTrendingNewsUseCase(repository) } @Provides - @Singleton fun provideGetNewsListBatchFlowUseCase(repository: NewsRepository): GetNewsListBatchFlowUseCase { return GetNewsListBatchFlowUseCase(repository) } @Provides - @Singleton fun provideFetchTrendingNewsUseCase(repository: NewsRepository): FetchTrendingNewsUseCase { return FetchTrendingNewsUseCase(repository) } + + @Provides + fun provideMarkArticleAsViewedUseCase(repository: NewsRepository): MarkArticleAsViewedUseCase { + return MarkArticleAsViewedUseCase(repository) + } + + @Provides + fun provideGetNewsUseCase(repository: NewsRepository): GetNewsUseCase { + return GetNewsUseCase(repository) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt index fa1dbd3792..33bd6b280a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt @@ -5,6 +5,8 @@ import com.tangem.datasource.local.news.details.DefaultNewsDetailsStore import com.tangem.datasource.local.news.details.NewsDetailsStore import com.tangem.datasource.local.news.trending.DefaultTrendingNewsStore import com.tangem.datasource.local.news.trending.TrendingNewsStore +import com.tangem.datasource.local.news.viewed.DefaultNewsViewedStore +import com.tangem.datasource.local.news.viewed.NewsViewedStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -26,4 +28,10 @@ internal object NewsStoreModule { fun provideTrendingNewsStore(): TrendingNewsStore { return DefaultTrendingNewsStore(store = RuntimeSharedStore()) } + + @Provides + @Singleton + fun provideNewsViewedStore(): NewsViewedStore { + return DefaultNewsViewedStore(store = RuntimeSharedStore()) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/DefaultNewsViewedStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/DefaultNewsViewedStore.kt new file mode 100644 index 0000000000..f365d8a09e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/DefaultNewsViewedStore.kt @@ -0,0 +1,32 @@ +package com.tangem.datasource.local.news.viewed + +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onStart + +private typealias NewsViewedCache = Map + +internal class DefaultNewsViewedStore( + private val store: RuntimeSharedStore, +) : NewsViewedStore { + + override fun getAll(): Flow> { + return store.get().onStart { emit(emptyMap()) } + } + + override suspend fun getSync(): Map { + return store.getSyncOrNull().orEmpty() + } + + override suspend fun updateViewed(articleIds: Collection, viewed: Boolean) { + if (articleIds.isEmpty()) return + + store.update(emptyMap()) { current -> + val updated = current.toMutableMap() + articleIds.forEach { id -> + updated[id] = viewed + } + updated + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/NewsViewedStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/NewsViewedStore.kt new file mode 100644 index 0000000000..24a2fdc39b --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/NewsViewedStore.kt @@ -0,0 +1,24 @@ +package com.tangem.datasource.local.news.viewed + +import kotlinx.coroutines.flow.Flow + +/** + * Store for news viewed flags (runtime only). + */ +interface NewsViewedStore { + + /** + * Observes all viewed flags. + */ + fun getAll(): Flow> + + /** + * Gets viewed flags synchronously (returns empty map if no data). + */ + suspend fun getSync(): Map + + /** + * Updates viewed flags for provided article ids. + */ + suspend fun updateViewed(articleIds: Collection, viewed: Boolean) +} \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt b/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt index 9a5aa274f1..cb558a2283 100644 --- a/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt +++ b/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt @@ -4,6 +4,7 @@ import com.tangem.data.news.repository.DefaultNewsRepository import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.local.news.details.NewsDetailsStore import com.tangem.datasource.local.news.trending.TrendingNewsStore +import com.tangem.datasource.local.news.viewed.NewsViewedStore import com.tangem.domain.news.repository.NewsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -23,12 +24,14 @@ internal object NewsDataModule { dispatchers: CoroutineDispatcherProvider, newsDetailsStore: NewsDetailsStore, trendingNewsStore: TrendingNewsStore, + newsViewedStore: NewsViewedStore, ): NewsRepository { return DefaultNewsRepository( newsApi = newsApi, dispatchers = dispatchers, newsDetailsStore = newsDetailsStore, trendingNewsStore = trendingNewsStore, + newsViewedStore = newsViewedStore, ) } } \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt index 09cc6015b6..96fe7bd6d8 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -7,6 +7,7 @@ import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.api.news.models.response.NewsTrendingResponse import com.tangem.datasource.local.news.details.NewsDetailsStore import com.tangem.datasource.local.news.trending.TrendingNewsStore +import com.tangem.datasource.local.news.viewed.NewsViewedStore import com.tangem.domain.models.news.* import com.tangem.domain.news.model.NewsListBatchFlow import com.tangem.domain.news.model.NewsListBatchingContext @@ -23,6 +24,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import timber.log.Timber @@ -36,6 +38,7 @@ internal class DefaultNewsRepository( private val dispatchers: CoroutineDispatcherProvider, private val newsDetailsStore: NewsDetailsStore, private val trendingNewsStore: TrendingNewsStore, + private val newsViewedStore: NewsViewedStore, ) : NewsRepository { override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow { @@ -47,6 +50,27 @@ internal class DefaultNewsRepository( ).toBatchFlow() } + override suspend fun getNews(config: NewsListConfig, limit: Int): List { + return withContext(dispatchers.io) { + val response = newsApi.getNews( + page = FIRST_PAGE, + limit = limit, + language = config.language, + snapshot = config.snapshot, + tokenIds = config.tokenIds.takeIf { it.isNotEmpty() }, + categoryIds = config.categoryIds.takeIf { it.isNotEmpty() }, + ).getOrThrow() + + val articles = response.items.map { it.toDomainShortArticle() } + val viewedFlags = newsViewedStore.getSync() + + articles.map { article -> + val isViewed = viewedFlags[article.id] == true + article.copy(viewed = isViewed) + } + } + } + override suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle { val cached = newsDetailsStore.getSyncOrNull(newsId) if (cached != null) return cached @@ -73,29 +97,21 @@ internal class DefaultNewsRepository( } override fun observeTrendingNews(): Flow { - return trendingNewsStore.get(TRENDING_NEWS_KEY) - } - - override suspend fun updateTrendingNewsViewed(articleIds: Collection, viewed: Boolean) { - if (articleIds.isEmpty()) return - - val currentResult = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY) ?: return - val currentArticles = when (currentResult) { - is TrendingNews.Data -> currentResult.articles - is TrendingNews.Error -> return - } - if (currentArticles.isEmpty()) return - - val ids = articleIds.toSet() - val updated = currentArticles.map { article -> - if (article.id in ids) { - article.copy(viewed = viewed) - } else { - article + return combine( + trendingNewsStore.get(TRENDING_NEWS_KEY), + newsViewedStore.getAll(), + ) { trendingNews, viewedFlags -> + when (trendingNews) { + is TrendingNews.Data -> { + val articlesWithViewedFlags = trendingNews.articles.map { article -> + val isViewed = viewedFlags[article.id] == true + article.copy(viewed = isViewed) + } + TrendingNews.Data(articlesWithViewedFlags) + } + is TrendingNews.Error -> trendingNews } } - - trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(updated)) } override suspend fun getCategories(): List { @@ -107,6 +123,10 @@ internal class DefaultNewsRepository( } } + override suspend fun updateNewsViewed(articleIds: Collection, viewed: Boolean) { + newsViewedStore.updateViewed(articleIds, viewed) + } + private suspend fun fetchDetailedArticlesInternal(newsIds: Collection, language: String?) = withContext(dispatchers.io) { if (newsIds.isEmpty()) return@withContext @@ -167,41 +187,26 @@ internal class DefaultNewsRepository( } is ApiResponse.Success -> { val freshArticles = result.data.items.map { it.toDomainShortArticle() } - val cachedArticles = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY) - val currentArticles = when (cachedArticles) { - is TrendingNews.Data -> cachedArticles.articles - is TrendingNews.Error -> emptyList() - null -> emptyList() - } - val merged = mergeTrendingArticles(current = currentArticles, fresh = freshArticles).take(limit) - trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(merged)) - TrendingNews.Data(merged) + val articles = freshArticles.take(limit) + trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(articles)) + TrendingNews.Data(articles) } } } } - private fun mergeTrendingArticles(current: List, fresh: List): List { - if (current.isEmpty()) return fresh - - val currentById = current.associateBy(ShortArticle::id) - - return fresh.map { article -> - val stored = currentById[article.id] ?: return@map article - article.copy(viewed = stored.viewed) - } - } - private fun createBatchFetcher(batchSize: Int): BatchFetcher> { return NewsBatchFetcher( newsApi = newsApi, batchSize = batchSize, + newsViewedStore = newsViewedStore, ) } private class NewsBatchFetcher( private val newsApi: NewsApi, private val batchSize: Int, + private val newsViewedStore: NewsViewedStore, ) : BatchFetcher> { private var state: NewsPaginationState? = null @@ -271,7 +276,13 @@ internal class DefaultNewsRepository( categoryIds = params.categoryIds.takeIf { it.isNotEmpty() }, ).getOrThrow() - val items = response.items.map { it.toDomainShortArticle() } + val articles = response.items.map { it.toDomainShortArticle() } + val viewedFlags = newsViewedStore.getSync() + + val items = articles.map { article -> + val isViewed = viewedFlags[article.id] == true + article.copy(viewed = isViewed) + } val batchResult = BatchFetchResult.Success( data = items, diff --git a/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt b/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt index f9f69c5b22..67423a773a 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt @@ -15,7 +15,7 @@ import kotlinx.serialization.Serializable @Serializable data class NewsListConfig( val language: String, - val snapshot: String, + val snapshot: String?, val tokenIds: List = emptyList(), val categoryIds: List = emptyList(), ) \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt index 3b9e06ce82..2d2e5043a0 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt @@ -2,9 +2,11 @@ package com.tangem.domain.news.repository import com.tangem.domain.models.news.ArticleCategory import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.models.news.ShortArticle import com.tangem.domain.models.news.TrendingNews import com.tangem.domain.news.model.NewsListBatchFlow import com.tangem.domain.news.model.NewsListBatchingContext +import com.tangem.domain.news.model.NewsListConfig import kotlinx.coroutines.flow.Flow /** @@ -20,6 +22,13 @@ interface NewsRepository { */ fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow + /** + * Returns list of short article by config. + * + * @param config config for getting news list + */ + suspend fun getNews(config: NewsListConfig, limit: Int): List + /** * Returns detailed article by id with locale configuration. * @param newsId news identification @@ -50,13 +59,13 @@ interface NewsRepository { */ fun observeTrendingNews(): Flow - /** - * Updates viewed flag for provided trending articles. - */ - suspend fun updateTrendingNewsViewed(articleIds: Collection, viewed: Boolean) - /** * Returns available categories. */ suspend fun getCategories(): List + + /** + * Updates viewed flag for provided news articles (applies to both regular and trending news). + */ + suspend fun updateNewsViewed(articleIds: Collection, viewed: Boolean) } \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt new file mode 100644 index 0000000000..74d8533a59 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.news.usecase + +import arrow.core.Either +import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.news.model.NewsListConfig +import com.tangem.domain.news.repository.NewsRepository + +class GetNewsUseCase(private val repository: NewsRepository) { + + suspend fun getNews(limit: Int, newsListConfig: NewsListConfig): Either> = + Either.catch { + repository.getNews( + config = newsListConfig, + limit = limit, + ) + } +} \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt index 6d76aaa9f6..113bf347aa 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt @@ -20,18 +20,4 @@ class ManageTrendingNewsUseCase(private val repository: NewsRepository) { .observeTrendingNews() .distinctUntilChanged() } - - /** - * Marks a single article as viewed/unviewed. - */ - suspend fun markAsViewed(articleId: Int, viewed: Boolean = true) { - repository.updateTrendingNewsViewed(listOf(articleId), viewed) - } - - /** - * Marks multiple articles at once (useful for bulk updates). - */ - suspend fun markAsViewed(articleIds: Collection, viewed: Boolean = true) { - repository.updateTrendingNewsViewed(articleIds, viewed) - } } \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/MarkArticleAsViewedUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/MarkArticleAsViewedUseCase.kt new file mode 100644 index 0000000000..523694c2d5 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/MarkArticleAsViewedUseCase.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.news.usecase + +import com.tangem.domain.news.repository.NewsRepository + +class MarkArticleAsViewedUseCase(private val repository: NewsRepository) { + + /** + * Marks a single article as viewed/unviewed. + */ + suspend fun markAsViewed(articleId: Int, viewed: Boolean = true) { + repository.updateNewsViewed(listOf(articleId), viewed) + } + + /** + * Marks multiple articles at once (useful for bulk updates). + */ + suspend fun markAsViewed(articleIds: Collection, viewed: Boolean = true) { + repository.updateNewsViewed(articleIds, viewed) + } +} \ No newline at end of file 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 8b1cc33975..dead2e7970 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 @@ -10,13 +10,11 @@ import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.value.Value -import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent -import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent @@ -34,8 +32,6 @@ import dagger.assisted.AssistedInject internal class DefaultFeedEntryComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted entryRoute: FeedEntryRoute?, - analyticsEventHandler: AnalyticsEventHandler, - accountsFeatureToggles: AccountsFeatureToggles, private val feedEntryChildFactory: FeedEntryChildFactory, ) : FeedEntryComponent, AppComponentContext by context { @@ -101,8 +97,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( router = innerRouter, ), feedEntryClickIntents = clickIntents, - analyticsEventHandler = analyticsEventHandler, - accountsFeatureToggles = accountsFeatureToggles, ) }, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index 3cbe7851d2..da730fb1a2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -8,13 +8,18 @@ import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent import com.tangem.features.feed.components.news.list.DefaultNewsListComponent import kotlinx.serialization.Serializable import javax.inject.Inject -internal class FeedEntryChildFactory @Inject constructor() { +internal class FeedEntryChildFactory @Inject constructor( + private val analyticsEventHandler: AnalyticsEventHandler, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val portfolioComponentFactory: MarketsPortfolioComponent.Factory, +) { @Serializable @Immutable @@ -45,8 +50,6 @@ internal class FeedEntryChildFactory @Inject constructor() { child: Child, appComponentContext: AppComponentContext, feedEntryClickIntents: FeedEntryClickIntents, - analyticsEventHandler: AnalyticsEventHandler, - accountsFeatureToggles: AccountsFeatureToggles, ): ComposableModularBottomSheetContentComponent { return when (child) { is Child.TokenDetails -> { @@ -55,6 +58,7 @@ internal class FeedEntryChildFactory @Inject constructor() { params = child.params, analyticsEventHandler = analyticsEventHandler, accountsFeatureToggles = accountsFeatureToggles, + portfolioComponentFactory = portfolioComponentFactory, ) } is Child.TokenList -> { 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 a870fb007c..ed0f3f488a 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 @@ -10,6 +10,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent @@ -19,10 +20,14 @@ import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent 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.market.detailed.MarketsTokenDetailsContent import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable internal class DefaultMarketsTokenDetailsComponent( @@ -30,7 +35,7 @@ internal class DefaultMarketsTokenDetailsComponent( val params: Params, analyticsEventHandler: AnalyticsEventHandler, private val accountsFeatureToggles: AccountsFeatureToggles, - // TODO add portfolio in migrate [REDACTED_JIRA] + portfolioComponentFactory: MarketsPortfolioComponent.Factory, ) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { // applying l2 compatibility @@ -42,7 +47,30 @@ internal class DefaultMarketsTokenDetailsComponent( private val analyticsParams = params.analyticsParams private val model: MarketsTokenDetailsModel = getOrCreateModel(updatedParams) + private val portfolioComponent: MarketsPortfolioComponent? = if (updatedParams.shouldShowPortfolio) { + portfolioComponentFactory.create( + context = child("my_portfolio"), + params = MarketsPortfolioComponent.Params( + updatedParams.token, + analyticsParams = analyticsParams?.source?.let { MarketsPortfolioComponent.AnalyticsParams(it) }, + ), + ) + } else { + null + } + init { + + componentScope.launch(dispatchers.default) { + model.networksState.collectLatest { state -> + when (state) { + is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(state.networks) + TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable() + else -> {} + } + } + } + // === Analytics === if (analyticsParams != null) { analyticsEventHandler.send( @@ -88,8 +116,10 @@ internal class DefaultMarketsTokenDetailsComponent( backgroundColor = LocalMainBottomSheetColor.current.value, state = state, isAccountEnabled = accountsFeatureToggles.isFeatureEnabled, - portfolioBlock = { - // TODO add portfolio in migrate [REDACTED_JIRA] + portfolioBlock = portfolioComponent?.let { component -> + { blockModifier -> + component.Content(blockModifier) + } }, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioComponent.kt new file mode 100644 index 0000000000..38afdb8400 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioComponent.kt @@ -0,0 +1,18 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent + +internal interface AddToPortfolioComponent : ComposableBottomSheetComponent { + + data class Params( + val addToPortfolioManager: AddToPortfolioManager, + val callback: Callback, + ) + + interface Callback { + fun onDismiss() + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioManager.kt new file mode 100644 index 0000000000..bcad1f6cb9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioManager.kt @@ -0,0 +1,38 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.api + +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +internal interface AddToPortfolioManager { + + val token: TokenMarketParams + val analyticsParams: MarketsPortfolioComponent.AnalyticsParams? + val portfolioFetcher: PortfolioFetcher + + val state: StateFlow + + val allAvailableNetworks: Flow> + fun setTokenNetworks(networks: List) + + sealed interface State { + data object Init : State + data class AvailableToAdd( + val availableToAddData: AvailableToAddData, + ) : State + + data object NothingToAdd : State + } + + interface Factory { + fun create( + scope: CoroutineScope, + token: TokenMarketParams, + analyticsParams: MarketsPortfolioComponent.AnalyticsParams?, + ): AddToPortfolioManager + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AvailableToAddData.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AvailableToAddData.kt new file mode 100644 index 0000000000..94006d470e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AvailableToAddData.kt @@ -0,0 +1,58 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.api + +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.Serializable + +internal data class AvailableToAddData( + val availableToAddWallets: Map, +) { + val isAvailableToAdd: Boolean + get() = availableToAddWallets.isNotEmpty() + val isSinglePortfolio: Boolean + get() = availableToAddWallets.size == 1 && availableToAddWallets.values.first().accounts.size == 1 +} + +internal data class AvailableToAddWallet( + val userWallet: UserWallet, + val accounts: List, + val availableNetworks: Set, + val availableToAddAccounts: Map, +) + +@Serializable +internal data class AvailableToAddAccount( + val account: AccountStatus, + val availableNetworks: Set, + val addedNetworks: Set, +) { + val isSingleNetwork: Boolean + get() = availableNetworks.size == 1 + + val availableToAddNetworks: Set = availableNetworks + .filter { available -> addedNetworks.none { added -> added.backendId == available.networkId } } + .toSet() + + val addedMarketNetworks: Set = availableNetworks + .filter { available -> addedNetworks.any { added -> added.backendId == available.networkId } } + .toSet() +} + +@Serializable +internal data class SelectedPortfolio( + val userWallet: UserWallet, + val account: AvailableToAddAccount, + val isAccountMode: Boolean, + val isAvailableMorePortfolio: Boolean, +) + +internal data class SelectedNetwork( + val selectedNetwork: TokenMarketInfo.Network, + val cryptoCurrency: CryptoCurrency, + val isAvailableMoreNetwork: Boolean, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt new file mode 100644 index 0000000000..808dcceab5 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt @@ -0,0 +1,55 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.addtoken.AddTokenContent +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedNetwork +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenModel +import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow + +internal class AddTokenComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : AppComponentContext by context, ComposableContentComponent { + + private val model: AddTokenModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state = model.uiState.collectAsStateWithLifecycle() + val um = state.value ?: return + AddTokenContent( + modifier = modifier, + state = um, + ) + } + + data class Params( + val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + val selectedPortfolio: Flow, + val selectedNetwork: Flow, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onChangeNetworkClick() + fun onChangePortfolioClick() + fun onTokenAdded(status: CryptoCurrencyStatus) + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): AddTokenComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt new file mode 100644 index 0000000000..727f08f7cf --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt @@ -0,0 +1,45 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.ChooseNetworkModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.ChooseNetworkContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class ChooseNetworkComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : AppComponentContext by context, ComposableContentComponent { + + private val model: ChooseNetworkModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + ChooseNetworkContent(state) + } + + data class Params( + val selectedPortfolio: SelectedPortfolio, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onNetworkSelected(network: TokenMarketInfo.Network) + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): ChooseNetworkComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt new file mode 100644 index 0000000000..174bdcf21b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt @@ -0,0 +1,212 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.backStack +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.tangem.core.decompose.context.AppComponentContext +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.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.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes +import com.tangem.features.feed.impl.R +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAddToPortfolioComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: AddToPortfolioComponent.Params, + portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, + addTokenComponentFactory: AddTokenComponent.Factory, + tokenActionsComponentFactory: TokenActionsComponent.Factory, + private val chooseNetworkComponentFactory: ChooseNetworkComponent.Factory, +) : AppComponentContext by context, AddToPortfolioComponent { + + private val model: AddToPortfolioModel = getOrCreateModel(params) + + private val portfolioSelectorComponent: PortfolioSelectorComponent = portfolioSelectorComponentFactory.create( + context = child("portfolioSelectorComponent"), + params = PortfolioSelectorComponent.Params( + portfolioFetcher = model.portfolioFetcher, + controller = model.portfolioSelectorController, + ), + ) + + private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create( + context = child("addTokenComponent"), + params = AddTokenComponent.Params( + eventBuilder = model.eventBuilder, + callbacks = model, + selectedPortfolio = model.selectedPortfolio, + selectedNetwork = model.selectedNetwork, + ), + ) + + private val tokenActionsComponent: TokenActionsComponent = tokenActionsComponentFactory.create( + context = child("tokenActionsComponent"), + params = TokenActionsComponent.Params( + eventBuilder = model.eventBuilder, + callbacks = model, + data = model.tokenActionsData, + ), + ) + + private val childStack = childStack( + key = "addToPortfolioStack", + handleBackButton = true, + source = model.navigation, + serializer = AddToPortfolioRoutes.serializer(), + initialStack = { model.currentStack }, + childFactory = ::contentChild, + ) + + private fun onBack() { + if (childStack.backStack.isNotEmpty()) model.navigation.pop() else dismiss() + } + + override fun dismiss() { + params.callback.onDismiss() + } + + @Composable + override fun BottomSheet() { + val stack by childStack.subscribeAsState() + val contentStack = remember { mutableStateOf(stack) } + val currentRoute = stack.active.configuration + val isNotEmpty = currentRoute != AddToPortfolioRoutes.Empty + if (isNotEmpty) { + contentStack.value = stack + } + + TangemModalBottomSheet( + scrollableContent = false, + onBack = ::onBack, + config = TangemBottomSheetConfig( + isShown = isNotEmpty, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors.background.tertiary, + title = { state -> + AnimatedContent(targetState = contentStack.value) { stack -> + BottomSheetTitle( + stack = stack, + onBackClick = ::onBack, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + content = { state -> + AnimatedContent(targetState = contentStack.value) { stack -> + val paddingModifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ) + val isScrollableContent = when (stack.active.configuration) { + AddToPortfolioRoutes.PortfolioSelector -> false + AddToPortfolioRoutes.AddToken, + AddToPortfolioRoutes.Empty, + is AddToPortfolioRoutes.NetworkSelector, + AddToPortfolioRoutes.TokenActions, + -> true + } + if (isScrollableContent) { + Column( + modifier = paddingModifier.verticalScroll(rememberScrollState()), + ) { + stack.active.instance.Content(modifier = Modifier) + } + } else { + stack.active.instance.Content(modifier = paddingModifier) + } + } + }, + ) + } + + @Composable + private fun BottomSheetTitle( + stack: ChildStack, + onBackClick: (() -> Unit), + modifier: Modifier = Modifier, + ) { + val title: TextReference = when (stack.active.configuration) { + AddToPortfolioRoutes.AddToken -> resourceReference(R.string.common_add_token) + AddToPortfolioRoutes.Empty -> TextReference.EMPTY + is AddToPortfolioRoutes.NetworkSelector -> resourceReference(R.string.common_choose_network) + AddToPortfolioRoutes.TokenActions -> resourceReference(R.string.common_get_token) + AddToPortfolioRoutes.PortfolioSelector -> (stack.active.instance as PortfolioSelectorComponent) + .title.collectAsStateWithLifecycle().value + } + val startIconRes: Int? + val endIconRes: Int? + if (stack.backStack.isNotEmpty()) { + startIconRes = R.drawable.ic_back_24 + endIconRes = null + } else { + startIconRes = null + endIconRes = R.drawable.ic_close_24 + } + TangemModalBottomSheetTitle( + modifier = modifier, + title = title, + startIconRes = startIconRes, + endIconRes = endIconRes, + onStartClick = onBackClick, + onEndClick = onBackClick, + ) + } + + private fun contentChild( + config: AddToPortfolioRoutes, + componentContext: ComponentContext, + ): ComposableContentComponent = when (config) { + AddToPortfolioRoutes.AddToken -> addTokenComponent + AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent + AddToPortfolioRoutes.TokenActions -> tokenActionsComponent + AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY + is AddToPortfolioRoutes.NetworkSelector -> chooseNetworkComponentFactory.create( + context = childByContext(componentContext), + params = ChooseNetworkComponent.Params( + selectedPortfolio = config.selectedPortfolio, + callbacks = model, + ), + ) + } + + @AssistedFactory + interface Factory : AddToPortfolioComponent.Factory { + override fun create( + context: AppComponentContext, + params: AddToPortfolioComponent.Params, + ): DefaultAddToPortfolioComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt new file mode 100644 index 0000000000..9498c4041f --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt @@ -0,0 +1,79 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.TokenActionsModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.TokenActionsContent +import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.tokenreceive.TokenReceiveComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow + +internal class TokenActionsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, +) : AppComponentContext by context, ComposableContentComponent { + + private val model: TokenActionsModel = getOrCreateModel(params) + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = TokenReceiveConfig.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val state = model.uiState.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + val tokenActionsUM = state.value ?: return + TokenActionsContent( + modifier = modifier, + state = tokenActionsUM, + ) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: TokenReceiveConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create( + context = childByContext(componentContext), + params = TokenReceiveComponent.Params( + config = config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + + data class Params( + val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + val data: Flow, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onLaterClick() + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): TokenActionsComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt new file mode 100644 index 0000000000..d42d22f23c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt @@ -0,0 +1,119 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.converter + +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase +import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.feed.components.market.details.portfolio.add.api.AvailableToAddAccount +import com.tangem.features.feed.components.market.details.portfolio.add.api.AvailableToAddData +import com.tangem.features.feed.components.market.details.portfolio.add.api.AvailableToAddWallet +import javax.inject.Inject + +internal class AvailableToAddDataConverter @Inject constructor( + private val filterAvailableNetworksForWalletUseCase: FilterAvailableNetworksForWalletUseCase, + private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, +) { + + suspend fun convert( + balances: Map, + availableNetworks: Set, + marketParams: TokenMarketParams, + ): AvailableToAddData { + suspend fun AccountStatus.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount? { + val currencies = availableNetworks + .mapNotNull { network -> + createCryptoCurrency( + userWallet = wallet, + network = network, + marketParams = marketParams, + account = this.account, + ) + } + + if (currencies.isEmpty()) return null + + val addedNetworks = getAccountCurrencyStatusUseCase.invokeSync(wallet.walletId, currencies) + .fold( + ifEmpty = { emptySet() }, + ifSome = { map -> + map.values.flatMapTo(hashSetOf()) { statuses -> + statuses.map { it.currency.network } + } + }, + ) + + return AvailableToAddAccount( + account = this, + availableNetworks = availableNetworks, + addedNetworks = addedNetworks, + ) + } + + suspend fun getAvailableToAddWallet( + entry: Map.Entry, + ): AvailableToAddWallet { + val (_, balance) = entry + val wallet = balance.userWallet + val filteredNetworks = wallet.filteredAvailableNetworks(availableNetworks) + val accounts = balance.accountsBalance.accountStatuses + val availableToAddAccounts: Map = accounts + .mapNotNull { accountStatus -> + val availableToAddAccount = accountStatus.getAvailableToAddAccount(wallet) ?: return@mapNotNull null + accountStatus.account.accountId to availableToAddAccount + } + .filter { (_, account) -> account.availableToAddNetworks.isNotEmpty() } + .toMap() + return AvailableToAddWallet( + userWallet = wallet, + accounts = accounts, + availableNetworks = filteredNetworks, + availableToAddAccounts = availableToAddAccounts, + ) + } + + val availableToAddWallets: Map = balances + .map { entry -> + val (walletId, _) = entry + val availableToAddWallet = getAvailableToAddWallet(entry) + walletId to availableToAddWallet + } + .filter { (_, wallet) -> wallet.availableToAddAccounts.isNotEmpty() } + .toMap() + + return AvailableToAddData( + availableToAddWallets = availableToAddWallets, + ) + } + + private fun UserWallet.filteredAvailableNetworks(networks: Set) = + filterAvailableNetworksForWalletUseCase( + userWalletId = this.walletId, + networks = networks, + ) + + private suspend fun createCryptoCurrency( + userWallet: UserWallet, + network: TokenMarketInfo.Network, + marketParams: TokenMarketParams, + account: Account, + ): CryptoCurrency? { + val derivationIndex = when (account) { + is Account.CryptoPortfolio -> account.derivationIndex + } + return getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = marketParams, + network = network, + accountIndex = derivationIndex, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt new file mode 100644 index 0000000000..f074c0d167 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt @@ -0,0 +1,21 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.di + +import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioManager +import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.DefaultAddToPortfolioManager +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface AddToPortfolioComponentModule { + + @Binds + fun bindAddToPortfolioComponent(factory: DefaultAddToPortfolioComponent.Factory): AddToPortfolioComponent.Factory + + @Binds + fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt new file mode 100644 index 0000000000..b60d84f099 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt @@ -0,0 +1,38 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.ChooseNetworkModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.TokenActionsModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface AddToPortfolioModelModule { + + @Binds + @IntoMap + @ClassKey(AddTokenModel::class) + fun addTokenModel(model: AddTokenModel): Model + + @Binds + @IntoMap + @ClassKey(AddToPortfolioModel::class) + fun addToPortfolioModel(model: AddToPortfolioModel): Model + + @Binds + @IntoMap + @ClassKey(TokenActionsModel::class) + fun tokenActionsModel(model: TokenActionsModel): Model + + @Binds + @IntoMap + @ClassKey(ChooseNetworkModel::class) + fun chooseNetworkModel(model: ChooseNetworkModel): Model +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt new file mode 100644 index 0000000000..2a9b23cdd5 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt @@ -0,0 +1,382 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.popToFirst +import com.arkivanov.decompose.router.stack.pushNew +import com.arkivanov.decompose.router.stack.replaceAll +import com.tangem.core.analytics.api.AnalyticsEventHandler +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.ui.extensions.resourceReference +import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 +import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.TokenMarketInfo +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.wallet.UserWallet +import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.feed.components.market.details.portfolio.add.api.* +import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent +import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.model.PortfolioTokenUMConverter.Companion.toQuickActions +import com.tangem.features.feed.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +private const val TOKEN_ACTIONS_DELAY = 500L + +@ModelScoped +@Suppress("LongParameterList") +internal class AddToPortfolioModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val callbackDelegate: AddToPortfolioCallbackDelegate, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, + private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, + private val messageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, + val portfolioSelectorController: PortfolioSelectorController, +) : Model(), + ChooseNetworkComponent.Callbacks by callbackDelegate, + TokenActionsComponent.Callbacks by callbackDelegate, + AddTokenComponent.Callbacks by callbackDelegate { + + private val params = paramsContainer.require() + val navigation = StackNavigation() + var currentStack = listOf(AddToPortfolioRoutes.Empty) + + /* Flows that hold state and provide it to child models */ + val selectedNetwork: MutableSharedFlow = replayMutableSharedFlow() + val selectedPortfolio: MutableSharedFlow = replayMutableSharedFlow() + val tokenActionsData: MutableSharedFlow = replayMutableSharedFlow() + + private val addToPortfolioManager = params.addToPortfolioManager + val portfolioFetcher = addToPortfolioManager.portfolioFetcher + val eventBuilder = PortfolioAnalyticsEvent.EventBuilder( + token = addToPortfolioManager.token, + source = addToPortfolioManager.analyticsParams?.source, + ) + + val featureData: Flow = combineFeatureData() + + init { + navigation.subscribe { currentStack = it.transformer.invoke(currentStack) } + startAddToPortfolioFlow() + } + + private fun replayMutableSharedFlow() = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @Suppress("LongMethod") + private fun startAddToPortfolioFlow() { + channelFlow { + fun finishFlow() { + params.callback.onDismiss() + channel.close() + } + + val featureDataFlow: StateFlow = featureData + .filterIsInstance() + .map { it.availableToAddData } + .distinctUntilChanged() + .stateIn(this) + val isAccountMode = portfolioSelectorController.isAccountModeSync() + + // use snapshot data, looks like we don’t need to remap at runtime + val data = featureDataFlow.value + + // you must control it via [AddToPortfolioManager.state] + if (!data.isAvailableToAdd) { + finishFlow() + return@channelFlow + } + + // init data flows, emits on user/code selection, updates state holder + val firstSelectedPortfolio = setupPortfolioFlow(data) + .onEach { selectedPortfolio.emit(it) } + val firstSelectedNetwork = setupNetworkFlow(firstSelectedPortfolio) + .onEach { selectedNetwork.emit(it) } + + val isSinglePortfolio = data.isSinglePortfolio + if (isSinglePortfolio) { + val accountId = data.availableToAddWallets.values.first() + .availableToAddAccounts.values.first() + .account.account.accountId + // force select a portfolio, triggers [selectedPortfolio] + portfolioSelectorController.selectAccount(accountId) + } else { + logAccountSelector(isAccountMode) + navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector) + } + + val firstPartOfNavigation: Job = firstSelectedPortfolio + .onEach { portfolio -> + val isSingleAvailableNetwork = portfolio.account.isSingleNetwork + when { + // force select a network, triggers [selectedNetwork] + isSingleAvailableNetwork -> { + val singleNetwork = portfolio.account.availableToAddNetworks.first() + callbackDelegate.onNetworkSelected(singleNetwork) + } + // it's important to control root screen, UI depends on it(close/arrow icon) + isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio)) + else -> navigation.pushNew(routeToNetworkSelector(portfolio)) + } + } + .launchIn(this) + + // main flow that combine all require data + val allRequireForAdd = combine( + flow = firstSelectedNetwork, + flow2 = firstSelectedPortfolio, + transform = { a, b -> a to b }, + ) + + // suspend until all required data is selected + allRequireForAdd.first() + // line of navigation to AddToken screen is finished; cancel the job, select a new root screen + firstPartOfNavigation.cancel() + navigation.replaceAll(AddToPortfolioRoutes.AddToken) + + var middleNavigationJob: Job? = null + // handle actions from AddToken screen + callbackDelegate.onChangeNetworkClick.receiveAsFlow() + .onEach { + middleNavigationJob?.cancel() + middleNavigationJob = changeNetworkNavigationFlow() + .launchIn(this) + val route = routeToNetworkSelector(selectedPortfolio.first()) + navigation.pushNew(route) + } + .launchIn(this) + // handle actions from AddToken screen + callbackDelegate.onChangePortfolioClick.receiveAsFlow() + .onEach { + middleNavigationJob?.cancel() + setSelectedAccountToSelectorController() + middleNavigationJob = changePortfolioNavigationFlow(data).launchIn(this) + logAccountSelector(isAccountMode) + navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector) + } + .launchIn(this) + + // suspend until token is added + val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() + middleNavigationJob?.cancel() + val selectedPortfolio = selectedPortfolio.first() + + messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) + + setupTokenActionsFlow(selectedPortfolio, addedToken) + .onEach { cryptoCurrencyData -> + tokenActionsData.emit(cryptoCurrencyData) + navigation.replaceAll(AddToPortfolioRoutes.TokenActions) + } + .onEmpty { finishFlow() } + .launchIn(this) + + callbackDelegate.onLaterClick.receiveAsFlow().first() + finishFlow() + } + .catch { throwable -> + Timber.e(throwable) + params.callback.onDismiss() + } + .launchIn(modelScope) + } + + private fun logAccountSelector(isAccountMode: Boolean) { + if (isAccountMode) { + analyticsEventHandler.send(eventBuilder.popupToChooseAccount()) + } + } + + private fun changeNetworkNavigationFlow(): Flow { + return setupNetworkFlow(selectedPortfolio) + .onEach { newNetwork -> + selectedNetwork.emit(newNetwork) + navigation.popToFirst() + } + } + + private fun setSelectedAccountToSelectorController() { + modelScope.launch(dispatchers.default) { + val selectedAccount = selectedPortfolio + .first() + .account + .account + .account + .accountId + portfolioSelectorController.selectAccount(selectedAccount) + } + } + + private fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow { + val changedPortfolio = setupPortfolioFlow(data) + .drop(1) + .onEach { portfolio -> navigation.pushNew(routeToNetworkSelector(portfolio)) } + val changedNetwork = setupNetworkFlow(changedPortfolio) + return combine( + flow = changedPortfolio, + flow2 = changedNetwork, + transform = { newPortfolio, newNetwork -> + selectedPortfolio.tryEmit(newPortfolio) + selectedNetwork.tryEmit(newNetwork) + navigation.popToFirst() + }, + ) + } + + private fun setupTokenActionsFlow( + selectedPortfolio: SelectedPortfolio, + addedToken: CryptoCurrencyStatus, + ): Flow { + val timeFlow = channelFlow { + val timerJob = launch { delay(TOKEN_ACTIONS_DELAY) } + getCryptoCurrencyActionsUseCase( + currency = addedToken.currency, + accountId = selectedPortfolio.account.account.account.accountId, + ).onEach { state -> + val requestedQuickActions = toQuickActions(state.states) + when { + requestedQuickActions.isNotEmpty() -> { + timerJob.cancel() + send(state) + } + // wait any requestedQuickActions while timer active + timerJob.isActive -> Unit + else -> close() + } + }.collect() + } + return timeFlow.map { actionsState -> + PortfolioData.CryptoCurrencyData( + userWallet = selectedPortfolio.userWallet, + status = actionsState.cryptoCurrencyStatus, + actions = actionsState.states, + ) + } + } + + private fun setupPortfolioFlow(data: AvailableToAddData): Flow = combine( + flow = portfolioSelectorController.isAccountMode, + flow2 = portfolioSelectorController.selectedAccount, + transform = { isAccountMode, selectedAccountId -> + selectedAccountId ?: return@combine null + val availableToAddWallets = + data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null + val availableToAddAccount = + availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null + if (!isAccountMode) analyticsEventHandler.send(eventBuilder.addToPortfolioWalletChanged()) + SelectedPortfolio( + isAccountMode = isAccountMode, + userWallet = availableToAddWallets.userWallet, + account = availableToAddAccount, + isAvailableMorePortfolio = !data.isSinglePortfolio, + ) + }, + ) + .filterNotNull() + + private fun setupNetworkFlow(selectedPortfolioFlow: Flow): Flow = combine( + flow = selectedPortfolioFlow, + flow2 = callbackDelegate.onNetworkSelected.receiveAsFlow(), + transform = transform@{ selectedPortfolio, selectedNetwork -> + SelectedNetwork( + cryptoCurrency = createCryptoCurrency( + userWallet = selectedPortfolio.userWallet, + network = selectedNetwork, + account = selectedPortfolio.account, + ) ?: return@transform null, + selectedNetwork = selectedNetwork, + isAvailableMoreNetwork = !selectedPortfolio.account.isSingleNetwork, + ) + }, + ) + .filterNotNull() + + private suspend fun createCryptoCurrency( + userWallet: UserWallet, + network: TokenMarketInfo.Network, + account: AvailableToAddAccount, + ): CryptoCurrency? { + val accountIndex = when (account.account) { + is AccountStatus.CryptoPortfolio -> account.account.account.derivationIndex + } + return getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = addToPortfolioManager.token, + network = network, + accountIndex = accountIndex, + ) + } + + private fun routeToNetworkSelector(portfolio: SelectedPortfolio): AddToPortfolioRoutes.NetworkSelector { + return AddToPortfolioRoutes.NetworkSelector(selectedPortfolio = portfolio) + } + + private fun combineFeatureData() = addToPortfolioManager.state.onEach { state -> + when (state) { + is AddToPortfolioManager.State.AvailableToAdd -> + portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus -> + val availableWallet = state.availableToAddData.availableToAddWallets[userWallet.walletId] + ?: return@isEnabled false + val availableAccount = + availableWallet.availableToAddAccounts[accountStatus.account.accountId] + return@isEnabled availableAccount != null + } + AddToPortfolioManager.State.Init, + AddToPortfolioManager.State.NothingToAdd, + -> Unit + } + } +} + +@ModelScoped +internal class AddToPortfolioCallbackDelegate @Inject constructor() : + ChooseNetworkComponent.Callbacks, + TokenActionsComponent.Callbacks, + AddTokenComponent.Callbacks { + + val onNetworkSelected = Channel() + val onLaterClick = Channel() + val onChangeNetworkClick = Channel() + val onChangePortfolioClick = Channel() + val onTokenAdded = Channel() + + override fun onNetworkSelected(network: TokenMarketInfo.Network) { + onNetworkSelected.trySend(network) + } + + override fun onLaterClick() { + onLaterClick.trySend(Unit) + } + + override fun onChangeNetworkClick() { + onChangeNetworkClick.trySend(Unit) + } + + override fun onChangePortfolioClick() { + onChangePortfolioClick.trySend(Unit) + } + + override fun onTokenAdded(status: CryptoCurrencyStatus) { + onTokenAdded.trySend(status) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt new file mode 100644 index 0000000000..3d954d2239 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt @@ -0,0 +1,28 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import androidx.compose.runtime.Immutable +import com.tangem.core.decompose.navigation.Route +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +internal sealed interface AddToPortfolioRoutes : Route { + + @Serializable + data object Empty : AddToPortfolioRoutes + + @Serializable + data object PortfolioSelector : AddToPortfolioRoutes + + @Serializable + data class NetworkSelector( + val selectedPortfolio: SelectedPortfolio, + ) : AddToPortfolioRoutes + + @Serializable + data object AddToken : AddToPortfolioRoutes + + @Serializable + data object TokenActions : AddToPortfolioRoutes +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt new file mode 100644 index 0000000000..830e71e6de --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt @@ -0,0 +1,118 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.tangem.common.ui.addtoken.AddTokenUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +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.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase +import com.tangem.domain.models.account.Account +import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedNetwork +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenUiBuilder.Companion.toggleProgress +import com.tangem.features.feed.impl.R +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +@Suppress("LongParameterList") +internal class AddTokenModel @Inject constructor( + paramsContainer: ParamsContainer, + private val uiBuilder: AddTokenUiBuilder, + private val messageSender: UiMessageSender, + private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, + override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, +) : Model() { + + private val params = paramsContainer.require() + private val analyticsEventBuilder = params.eventBuilder + private val addTokenJob = JobHolder() + + val uiState: StateFlow + field = MutableStateFlow(value = null) + + init { + combine( + flow = params.selectedNetwork.distinctUntilChanged(), + flow2 = params.selectedPortfolio.distinctUntilChanged(), + transform = { selectedNetwork, selectedPortfolio -> + addTokenJob.cancel() + val isTangemIconVisible = needColdWalletInteraction(selectedNetwork, selectedPortfolio) + uiBuilder.updateContent( + selectedPortfolio = selectedPortfolio, + selectedNetwork = selectedNetwork, + isTangemIconVisible = isTangemIconVisible, + onConfirmClick = { onAddClick(selectedNetwork, selectedPortfolio).saveIn(addTokenJob) }, + ) + }, + ) + .onEach { newUI -> uiState.value = newUI } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun onAddClick(selectedNetwork: SelectedNetwork, selectedPortfolio: SelectedPortfolio) = + modelScope.launch(dispatchers.default) { + val um = uiState.value ?: return@launch + uiState.value = um.toggleProgress(true) + val blockchainNames = listOf(selectedNetwork.selectedNetwork) + .mapNotNull { BlockchainUtils.getNetworkInfo(it.networkId)?.name } + analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames)) + + val cryptoCurrency = selectedNetwork.cryptoCurrency + val account = selectedPortfolio.account.account.account + val accountId = account.accountId + manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency) + .onLeft { throwable -> + processError(error = throwable) + uiState.value = um.toggleProgress(false) + return@launch + } + + val status = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = accountId.userWalletId, + currencyId = cryptoCurrency.id, + network = cryptoCurrency.network, + ).getOrNull() + if (status == null) { + processError(error = null) + } else { + when (account) { + is Account.CryptoPortfolio -> if (!account.isMainAccount) { + analyticsEventHandler.send(analyticsEventBuilder.addToNotMainAccount()) + } + } + params.callbacks.onTokenAdded(status.status) + } + uiState.value = um.toggleProgress(false) + } + + private suspend fun needColdWalletInteraction( + selectedNetwork: SelectedNetwork, + selectedPortfolio: SelectedPortfolio, + ): Boolean = coldWalletAndHasMissedDerivationsUseCase.invoke( + userWalletId = selectedPortfolio.userWallet.walletId, + networksWithDerivationPath = mapOf(selectedNetwork.selectedNetwork.networkId to null), + ) + + private fun processError(error: Throwable?) { + val message = error?.message?.let { stringReference(it) } + ?: resourceReference(R.string.common_something_went_wrong) + messageSender.send(ToastMessage(message = message)) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt new file mode 100644 index 0000000000..24b4079240 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt @@ -0,0 +1,105 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.tangem.common.ui.account.CryptoPortfolioIconUM +import com.tangem.common.ui.account.PortfolioSelectUM +import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.addtoken.AddTokenUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.iconResId +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.AccountStatus +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedNetwork +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent +import com.tangem.features.feed.impl.R +import javax.inject.Inject + +@ModelScoped +internal class AddTokenUiBuilder @Inject constructor( + paramsContainer: ParamsContainer, +) { + private val params = paramsContainer.require() + + private fun createNetwork(selectedNetwork: SelectedNetwork): AddTokenUM.Network { + return AddTokenUM.Network( + icon = selectedNetwork.cryptoCurrency.network.iconResId, + name = stringReference(selectedNetwork.cryptoCurrency.network.name), + editable = selectedNetwork.isAvailableMoreNetwork, + onClick = { params.callbacks.onChangeNetworkClick() }, + ) + } + + private fun createPortfolio(selectedPortfolio: SelectedPortfolio): PortfolioSelectUM { + val accountIcon: CryptoPortfolioIconUM? + val portfolioName: TextReference + when (selectedPortfolio.isAccountMode) { + false -> { + accountIcon = null + portfolioName = stringReference(selectedPortfolio.userWallet.name) + } + true -> { + val accountStatus = selectedPortfolio.account.account + portfolioName = accountStatus.account.accountName.toUM().value + accountIcon = when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.account.icon.toUM() + } + } + } + return PortfolioSelectUM( + icon = accountIcon, + name = portfolioName, + isAccountMode = selectedPortfolio.isAccountMode, + isMultiChoice = selectedPortfolio.isAvailableMorePortfolio, + onClick = { params.callbacks.onChangePortfolioClick() }, + ) + } + + fun updateContent( + selectedPortfolio: SelectedPortfolio, + selectedNetwork: SelectedNetwork, + isTangemIconVisible: Boolean, + onConfirmClick: () -> Unit, + ): AddTokenUM { + // its may happens when change portfolio after selected both params in line navigation + val isAvailableNetwork = selectedPortfolio.account.availableToAddNetworks + .any { selectedNetwork.selectedNetwork.networkId == it.networkId } + val button = AddTokenUM.Button( + isEnabled = isAvailableNetwork, + showProgress = false, + isTangemIconVisible = isTangemIconVisible, + text = resourceReference(R.string.common_add), + onConfirmClick = onConfirmClick, + ) + val networkUM = createNetwork(selectedNetwork) + val portfolioUM = createPortfolio(selectedPortfolio) + val currency = selectedNetwork.cryptoCurrency + val tokenToAdd = TokenItemState.Content( + id = currency.id.value, + iconState = CryptoCurrencyToIconStateConverter().convert(currency), + titleState = TokenItemState.TitleState.Content(stringReference(currency.name)), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = ""), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = ""), + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(currency.symbol)), + onItemClick = null, + onItemLongClick = null, + ) + return AddTokenUM( + tokenToAdd = tokenToAdd, + network = networkUM, + portfolio = portfolioUM, + button = button, + ) + } + + companion object { + + fun AddTokenUM.toggleProgress(showProgress: Boolean) = this.copy( + button = this.button.copy(showProgress = showProgress), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt new file mode 100644 index 0000000000..4d51ae620c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt @@ -0,0 +1,126 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import arrow.core.getOrElse +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.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase +import com.tangem.domain.managetokens.model.CurrencyUnsupportedState +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM +import com.tangem.features.feed.components.market.details.portfolio.impl.model.BlockchainRowUMConverter +import com.tangem.features.feed.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@ModelScoped +@Suppress("LongParameterList") +internal class ChooseNetworkModel @Inject constructor( + paramsContainer: ParamsContainer, + private val messageSender: UiMessageSender, + private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params = paramsContainer.require() + + val uiState: StateFlow = MutableStateFlow(buildUI()) + + private fun buildUI(): ChooseNetworkUM { + val allAvailable = params.selectedPortfolio.account.availableNetworks + val alreadyAdded = allAvailable + .subtract(params.selectedPortfolio.account.availableToAddNetworks) + val converter = BlockchainRowUMConverter( + alreadyAddedNetworks = alreadyAdded.mapTo(mutableSetOf()) { it.networkId }, + ) + val allAvailableNetworks = allAvailable.map { it to true } + return ChooseNetworkUM( + networks = converter.convertList(allAvailableNetworks).toPersistentList(), + onNetworkClick = onNetworkClick@{ row -> + val network = allAvailable + .find { it.networkId == row.id } + ?: return@onNetworkClick + checkNetwork(row, network) + }, + ) + } + + private fun checkNetwork(row: BlockchainRowUM, network: TokenMarketInfo.Network) = modelScope.launch { + val selectedWalletId = params.selectedPortfolio.userWallet.walletId + val unsupportedState = checkCurrencyUnsupportedState( + userWalletId = selectedWalletId, + rawNetworkId = row.id, + isMainNetwork = row.isMainNetwork, + ) + if (unsupportedState != null) { + showUnsupportedWarning(unsupportedState) + } else { + params.callbacks.onNetworkSelected(network) + } + } + + private suspend fun checkCurrencyUnsupportedState( + userWalletId: UserWalletId, + rawNetworkId: String, + isMainNetwork: Boolean, + ): CurrencyUnsupportedState? { + return checkCurrencyUnsupportedUseCase( + userWalletId = userWalletId, + networkId = rawNetworkId, + isMainNetwork = isMainNetwork, + ).getOrElse { throwable -> + Timber.e( + throwable, + """ + Failed to check currency unsupported state + |- User wallet ID: $userWalletId + |- Network ID: $rawNetworkId + |- Is main network: $isMainNetwork + """.trimIndent(), + ) + + val message = SnackbarMessage( + message = throwable.localizedMessage?.let(::stringReference) + ?: resourceReference(R.string.common_error), + ) + messageSender.send(message) + + null + } + } + + private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) { + val message = DialogMessage( + message = when (unsupportedState) { + is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + }, + ) + + messageSender.send(message) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt new file mode 100644 index 0000000000..0bb9a30fd9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt @@ -0,0 +1,76 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory +import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM +import com.tangem.features.feed.components.market.details.portfolio.impl.model.TokenActionsHandler +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +@Suppress("LongParameterList") +internal class TokenActionsModel @Inject constructor( + paramsContainer: ParamsContainer, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + tokenActionsIntentsFactory: TokenActionsHandler.Factory, + override val dispatchers: CoroutineDispatcherProvider, + private val uiBuilder: TokenActionsUiBuilder, + private val analyticsEventHandler: AnalyticsEventHandler, + private val receiveAddressesFactory: ReceiveAddressesFactory, +) : Model() { + + private val params = paramsContainer.require() + private val analyticsEventBuilder get() = params.eventBuilder + private val currentAppCurrency = getSelectedAppCurrencyUseCase.invokeOrDefault() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + + private val tokenActionsHandler: TokenActionsHandler = + tokenActionsIntentsFactory.create( + currentAppCurrency = Provider { currentAppCurrency.value }, + updateTokenReceiveBSConfig = { }, + onHandleQuickAction = { handledAction -> handledQuickAction(handledAction) }, + ) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val uiState: StateFlow = params.data + .mapLatest { uiBuilder.build(it, tokenActionsHandler) } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) + + private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) { + val event = analyticsEventBuilder.getTokenActionClick(actionUM = handledAction.action) + analyticsEventHandler.send(event) + val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive + if (!isReceive) return + modelScope.launch { + val tokenConfig = receiveAddressesFactory.create( + status = handledAction.cryptoCurrencyData.status, + userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId, + ) ?: return@launch + bottomSheetNavigation.activate(tokenConfig) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt new file mode 100644 index 0000000000..8828010452 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt @@ -0,0 +1,44 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.model.PortfolioTokenUMConverter +import com.tangem.features.feed.components.market.details.portfolio.impl.model.TokenActionsHandler +import javax.inject.Inject + +@ModelScoped +internal class TokenActionsUiBuilder @Inject constructor( + paramsContainer: ParamsContainer, + private val analyticsEventHandler: AnalyticsEventHandler, +) { + private val params = paramsContainer.require() + + fun build(data: PortfolioData.CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): TokenActionsUM { + val status = data.status + val tokenUM = TokenItemState.Content( + id = status.currency.id.value, + iconState = CryptoCurrencyToIconStateConverter().convert(status.currency), + titleState = TokenItemState.TitleState.Content(stringReference(status.currency.name)), + fiatAmountState = null, + subtitle2State = null, + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(status.currency.symbol)), + onItemClick = null, + onItemLongClick = null, + ) + return TokenActionsUM( + token = tokenUM, + onLaterClick = { + analyticsEventHandler.send(params.eventBuilder.getTokenLater()) + params.callbacks.onLaterClick() + }, + quickActions = PortfolioTokenUMConverter.quickActions(data, tokenActionsHandler), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt new file mode 100644 index 0000000000..7b92e78e3b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt @@ -0,0 +1,110 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +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.util.fastForEachIndexed +import com.tangem.core.ui.components.label.Label +import com.tangem.core.ui.components.label.entity.LabelStyle +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.components.rows.BlockchainRow +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +private const val DISABLED_ALPHA = 0.4f + +@Composable +internal fun ChooseNetworkContent(state: ChooseNetworkUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(TangemTheme.colors.background.action), + ) { + state.networks.fastForEachIndexed { index, model -> + key(model.id) { + BlockchainRow( + model = model, + itemPadding = PaddingValues( + horizontal = TangemTheme.dimens.spacing12, + vertical = TangemTheme.dimens.spacing14, + ), + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = model.isEnabled, onClick = { state.onNetworkClick(model) }), + ) { + if (!model.isEnabled) { + Label( + modifier = Modifier.alpha(DISABLED_ALPHA), + state = LabelUM( + text = resourceReference(R.string.common_added), + style = LabelStyle.REGULAR, + ), + ) + } + } + } + } + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview(@PreviewParameter(ChooseNetworkContentProvider::class) content: ChooseNetworkUM) { + TangemThemePreview { + ChooseNetworkContent( + state = content, + ) + } +} + +internal class ChooseNetworkContentProvider : PreviewParameterProvider { + + private val blockchainRow = BlockchainRowUM( + id = UUID.randomUUID().toString(), + name = "Etherium 3", + type = "TEST", + iconResId = R.drawable.img_eth_22, + isMainNetwork = false, + isSelected = true, + isEnabled = true, + ) + + override val values: Sequence + get() = sequenceOf( + ChooseNetworkUM( + onNetworkClick = {}, + networks = persistentListOf( + blockchainRow.copy( + type = "MAIN", + isMainNetwork = true, + ), + blockchainRow.copy( + iconResId = R.drawable.ic_bsc_16, + isEnabled = false, + ), + blockchainRow.copy(iconResId = R.drawable.img_polygon_22), + blockchainRow.copy(iconResId = R.drawable.img_optimism_22), + ), + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt new file mode 100644 index 0000000000..f286f46e40 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt @@ -0,0 +1,72 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui + +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioManager +import com.tangem.features.feed.components.market.details.portfolio.add.impl.converter.AvailableToAddDataConverter +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +internal class DefaultAddToPortfolioManager @AssistedInject constructor( + private val availableToAddDataConverter: AvailableToAddDataConverter, + @Assisted override val token: TokenMarketParams, + @Assisted override val analyticsParams: MarketsPortfolioComponent.AnalyticsParams?, + @Assisted val scope: CoroutineScope, + dispatchers: CoroutineDispatcherProvider, + portfolioFetcherFactory: PortfolioFetcher.Factory, +) : AddToPortfolioManager { + + private val _allAvailableNetworks = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val allAvailableNetworks: Flow> = _allAvailableNetworks.asSharedFlow() + override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create( + mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true), + scope = scope, + ) + + override val state: StateFlow = + combine( + flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(), + flow2 = allAvailableNetworks.map { it.toSet() }.distinctUntilChanged(), + ) { balances, availableNetworks -> + val data = availableToAddDataConverter.convert( + balances = balances, + availableNetworks = availableNetworks, + marketParams = token, + ) + if (data.isAvailableToAdd) { + AddToPortfolioManager.State.AvailableToAdd(data) + } else { + AddToPortfolioManager.State.NothingToAdd + } + } + .flowOn(dispatchers.default) + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = AddToPortfolioManager.State.Init, + ) + + override fun setTokenNetworks(networks: List) { + _allAvailableNetworks.tryEmit(networks) + } + + @AssistedFactory + interface Factory : AddToPortfolioManager.Factory { + override fun create( + scope: CoroutineScope, + token: TokenMarketParams, + analyticsParams: MarketsPortfolioComponent.AnalyticsParams?, + ): DefaultAddToPortfolioManager + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt new file mode 100644 index 0000000000..d88388bb30 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt @@ -0,0 +1,204 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.vector.ImageVector +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.util.fastForEach +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.icons.badge.drawBadge +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +@Composable +internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + TokenItem( + modifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.action), + state = state.token, + isBalanceHidden = false, + ) + + SpacerH(TangemTheme.dimens.spacing14) + Column( + modifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(TangemTheme.colors.background.action), + ) { + state.quickActions.actions.fastForEach { actionUM -> + key(actionUM.title) { + ActionRow( + state = actionUM, + onClick = { state.quickActions.onQuickActionClick(actionUM) }, + onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) }, + ) + } + } + } + + SpacerH16() + + SecondaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_later), + onClick = state.onLaterClick, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ActionRow( + state: QuickActionUM, + onClick: () -> Unit, + onLongClick: (() -> Unit), + modifier: Modifier = Modifier, +) { + val hapticManager = LocalHapticManager.current + val onLongClickInternal = { + hapticManager.perform(TangemHapticEffect.View.LongPress) + onLongClick() + } + + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onLongClick = onLongClickInternal.takeIf { state.isLongClickAvailable }, + onClick = { + hapticManager.perform(TangemHapticEffect.View.SegmentTick) + onClick() + }, + ) + .padding(horizontal = TangemTheme.dimens.spacing12, vertical = TangemTheme.dimens.spacing15), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + val containerColor = TangemTheme.colors.background.action + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = CircleShape, + ) + .size(36.dp) + .drawWithContent { + drawContent() + if (state is QuickActionUM.Exchange && state.shouldShowBadge) { + drawBadge(containerColor = containerColor, offset = 4.dp) + } + }, + ) { + Icon( + modifier = Modifier.requiredSize(TangemTheme.dimens.size16), + imageVector = ImageVector.vectorResource(id = state.icon), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2), + ) { + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = state.description.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(TokenActionsContentPreviewProvider::class) state: TokenActionsUM) { + TangemThemePreview { + TokenActionsContent( + state = state, + ) + } +} + +private class TokenActionsContentPreviewProvider : PreviewParameterProvider { + private val tokenState + get() = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = R.drawable.img_eth_22, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + titleState = TokenItemState.TitleState.Content( + text = stringReference(value = "Tether"), + ), + fiatAmountState = null, + subtitle2State = null, + subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")), + onItemClick = {}, + onItemLongClick = {}, + ) + + override val values: Sequence + get() = sequenceOf( + TokenActionsUM( + quickActions = PortfolioTokenUM.QuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(shouldShowBadge = true), + QuickActionUM.Receive, + ), + onQuickActionClick = {}, + onQuickActionLongClick = {}, + ), + token = tokenState, + onLaterClick = {}, + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt new file mode 100644 index 0000000000..8f54e3cb3b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt @@ -0,0 +1,9 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import kotlinx.collections.immutable.ImmutableList + +data class ChooseNetworkUM( + val networks: ImmutableList, + val onNetworkClick: (BlockchainRowUM) -> Unit, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt new file mode 100644 index 0000000000..d90e3ca130 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state + +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM + +internal data class TokenActionsUM( + val token: TokenItemState, + val quickActions: PortfolioTokenUM.QuickActions, + val onLaterClick: () -> Unit, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/api/MarketsPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/api/MarketsPortfolioComponent.kt new file mode 100644 index 0000000000..18aa2ea032 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/api/MarketsPortfolioComponent.kt @@ -0,0 +1,29 @@ +package com.tangem.features.feed.components.market.details.portfolio.api + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import kotlinx.serialization.Serializable + +@Stable +interface MarketsPortfolioComponent : ComposableContentComponent { + + @Serializable + data class Params( + val token: TokenMarketParams, + val analyticsParams: AnalyticsParams?, + ) + + @Serializable + data class AnalyticsParams( + val source: String, + ) + + fun setTokenNetworks(networks: List) + + fun setNoNetworksAvailable() + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt new file mode 100644 index 0000000000..cd1eb2d9ee --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt @@ -0,0 +1,89 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioModel +import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioRoute +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.MyPortfolio +import com.tangem.features.tokenreceive.TokenReceiveComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Stable +internal class DefaultMarketsPortfolioComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: MarketsPortfolioComponent.Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, +) : AppComponentContext by context, MarketsPortfolioComponent { + + private val model: MarketsPortfolioModel = getOrCreateModel(params) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = MarketsPortfolioRoute.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + override fun setTokenNetworks(networks: List) { + model.setTokenNetworks(networks) + } + + override fun setNoNetworksAvailable() { + model.setNoNetworksAvailable() + } + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + + MyPortfolio(modifier = modifier, state = state) + bottomSheet.child?.instance?.BottomSheet() + } + + @Suppress("UnsafeCallOnNullableType") + private fun bottomSheetChild( + config: MarketsPortfolioRoute, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = when (config) { + MarketsPortfolioRoute.AddToPortfolio -> addToPortfolioComponentFactory.create( + context = childByContext(componentContext), + params = AddToPortfolioComponent.Params( + addToPortfolioManager = model.newAddToPortfolioManager!!, + callback = model.addToPortfolioCallback, + ), + ) + is MarketsPortfolioRoute.TokenReceive -> tokenReceiveComponentFactory.create( + context = childByContext(componentContext), + params = TokenReceiveComponent.Params( + config = config.config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + } + + @AssistedFactory + interface Factory : MarketsPortfolioComponent.Factory { + override fun create( + context: AppComponentContext, + params: MarketsPortfolioComponent.Params, + ): DefaultMarketsPortfolioComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt new file mode 100644 index 0000000000..ee93e97a0c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt @@ -0,0 +1,74 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM + +internal class PortfolioAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) { + + data class EventBuilder( + val token: TokenMarketParams, + val source: String?, + ) { + + fun addToPortfolioClicked() = PortfolioAnalyticsEvent( + event = "Button - Add To Portfolio", + params = mapOf( + "Token" to token.symbol, + ), + ) + + fun popupToChooseAccount() = PortfolioAnalyticsEvent( + event = "Popup to choose account", + ) + + fun addToNotMainAccount() = PortfolioAnalyticsEvent( + event = "Button - Add (token not to main Account)", + ) + + fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent(event = "Wallet Selected") + + fun addToPortfolioContinue(blockchainNames: List) = PortfolioAnalyticsEvent( + event = "Token Network Selected", + params = mapOf( + "Count" to blockchainNames.size.toString(), + "Token" to token.symbol, + "blockchain" to blockchainNames.joinToString(separator = ", "), + ), + ) + + fun quickActionClick(actionUM: TokenActionsBSContentUM.Action, blockchainName: String) = + PortfolioAnalyticsEvent( + event = when (actionUM) { + TokenActionsBSContentUM.Action.Buy -> "Button - Buy" + TokenActionsBSContentUM.Action.Receive -> "Button - Receive" + TokenActionsBSContentUM.Action.Exchange -> "Button - Swap" + TokenActionsBSContentUM.Action.Stake -> "Button - Stake" + TokenActionsBSContentUM.Action.YieldMode -> "Button - Yield Mode" + else -> "error" + }, + params = buildMap { + put("Token", token.symbol) + source?.let { put("Source", it) } + put("blockchain", blockchainName) + }, + ) + + fun getTokenActionClick(actionUM: TokenActionsBSContentUM.Action) = PortfolioAnalyticsEvent( + event = when (actionUM) { + TokenActionsBSContentUM.Action.Buy -> "Popup Get token - Button Buy" + TokenActionsBSContentUM.Action.Receive -> "Popup Get token - Button Receive" + TokenActionsBSContentUM.Action.Exchange -> "Popup Get token - Button Exchange" + TokenActionsBSContentUM.Action.Stake -> "Popup Get token - Button Stake" + else -> "error" + }, + ) + + fun getTokenLater() = PortfolioAnalyticsEvent( + event = "Popup Get token - Button Later", + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ComponentModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ComponentModule.kt new file mode 100644 index 0000000000..beb881a924 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ComponentModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.di + +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.impl.DefaultMarketsPortfolioComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface ComponentModule { + + @Binds + @Singleton + fun bindMarketsPortfolioComponent( + factory: DefaultMarketsPortfolioComponent.Factory, + ): MarketsPortfolioComponent.Factory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ModelModule.kt new file mode 100644 index 0000000000..4656963d6b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface ModelModule { + + @Binds + @IntoMap + @ClassKey(MarketsPortfolioModel::class) + fun provideMarketsPortfolioModel(model: MarketsPortfolioModel): Model +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt new file mode 100644 index 0000000000..044a0ef229 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt @@ -0,0 +1,33 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.loader + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.tokens.model.TokenActionsState + +/** + * Portfolio data. Combined data from all flows that required to setup portfolio + * + * @property walletsWithCurrencies wallets with crypto currency statuses + * @property appCurrency app currency + * @property isBalanceHidden flag that indicates if balance should be hidden + * @property walletsWithBalance wallets with total balance + * +[REDACTED_AUTHOR] + */ +internal data class PortfolioData( + val walletsWithCurrencies: Map>, + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val walletsWithBalance: Map>, +) { + data class CryptoCurrencyData( + val userWallet: UserWallet, + val status: CryptoCurrencyStatus, + val actions: List, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioDataLoader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioDataLoader.kt new file mode 100644 index 0000000000..c22e143ea1 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioDataLoader.kt @@ -0,0 +1,135 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.loader + +import arrow.core.getOrElse +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.GetAllWalletsCryptoCurrencyStatusesUseCase +import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase +import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.yield.supply.models.YieldSupplyAvailability +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +/** + * Loader of portfolio data + * + * @property getAllWalletsCryptoCurrencyStatusesUseCase use case for getting all wallets crypto currency statuses + * @property getSelectedAppCurrencyUseCase use case for getting selected app currency + * @property getBalanceHidingSettingsUseCase use case for getting balance hiding settings + * @property getWalletTotalBalanceUseCase use case for getting wallet total balance + * +[REDACTED_AUTHOR] + */ +internal class PortfolioDataLoader @Inject constructor( + private val getAllWalletsCryptoCurrencyStatusesUseCase: GetAllWalletsCryptoCurrencyStatusesUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val getWalletTotalBalanceUseCase: GetWalletTotalBalanceUseCase, + private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, +) { + + /** Load data by [currencyRawId] */ + @OptIn(ExperimentalCoroutinesApi::class) + fun load(currencyRawId: CryptoCurrency.RawID): Flow { + return combine( + flow = getAllWalletsCryptoCurrenciesData(currencyRawId = currencyRawId), + flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(), + flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(), + ) { walletsWithCurrencies, appCurrency, isBalanceHidden -> + PortfolioData( + walletsWithCurrencies = walletsWithCurrencies, + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + walletsWithBalance = emptyMap(), + ) + } + // setup balances for wallets from walletsWithCurrencyStatuses + .flatMapLatest { portfolioData -> + getWalletsWithTotalBalanceFlow( + ids = portfolioData.walletsWithCurrencies.keys.map(UserWallet::walletId), + ) + .map { portfolioData.copy(walletsWithBalance = it) } + .onEmpty { emit(portfolioData) } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun getAllWalletsCryptoCurrenciesData( + currencyRawId: CryptoCurrency.RawID, + ): Flow>> { + return getAllWalletsCryptoCurrencyStatusesUseCase(currencyRawId) + .distinctUntilChanged() + .map { walletsWithMaybeStatuses -> + walletsWithMaybeStatuses.mapValues { entry -> + entry.value.mapNotNull { it.getOrNull() } + } + } + .flatMapLatest { walletsWithStatuses -> + val actionsFlows = walletsWithStatuses.flatMap { (wallet, statuses) -> + statuses.map { status -> + val yieldSupplyAvailability = yieldSupplyGetAvailabilityUseCase(status.currency).getOrElse { + YieldSupplyAvailability.Unavailable + } + getCryptoCurrencyActionsUseCase(wallet, status, yieldSupplyAvailability) + .map { tokenActionsState -> + PortfolioData.CryptoCurrencyData( + userWallet = wallet, + status = status, + actions = tokenActionsState.states, + ) + } + } + } + + combine(actionsFlows) { actions -> + walletsWithStatuses.mapValues { entry -> + entry.value.mapNotNull { status -> + actions.firstOrNull { + it.userWallet == entry.key && it.status == status + } + } + } + }.onEmpty { + emit( + walletsWithStatuses.mapValues { (wallet, statuses) -> + statuses.map { status -> + PortfolioData.CryptoCurrencyData( + userWallet = wallet, + status = status, + actions = emptyList(), + ) + } + }, + ) + } + }.onEmpty { + emit(emptyMap()) + } + .distinctUntilChanged() + } + + private fun getWalletsWithTotalBalanceFlow( + ids: List, + ): Flow>> { + return combine( + flows = ids + .map { userWalletId -> + getWalletTotalBalanceUseCase(userWalletId) + .map { userWalletId to it } + .distinctUntilChanged() + }, + transform = { it.toMap() }, + ) + .distinctUntilChanged() + .onEmpty { ids.associateWith { Lce.Loading(partialContent = null) } } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt new file mode 100644 index 0000000000..e139659522 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt @@ -0,0 +1,161 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.ui.userwallet.converter.UserWalletItemUMConverter +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.WalletSelectorBSContentUM +import kotlinx.collections.immutable.toImmutableList + +/** + * Factory to create AddToPortfolio bottom sheet content [TangemBottomSheetConfig] + * + * @property token token params + * @property onAddToPortfolioVisibilityChange callback is invoked when add to portfolio visibility is changed + * @property onWalletSelectorVisibilityChange callback is invoked when wallet selector visibility is changed + * @property onNetworkSwitchClick callback is invoked when network switch is clicked + * @property onAnotherWalletSelect callback is invoked when wallet is selected + * @property onContinueClick callback is invoked when continue button is clicked + * +[REDACTED_AUTHOR] + */ +@Suppress("LongParameterList") +internal class AddToPortfolioBSContentUMFactory( + private val addToPortfolioManager: AddToPortfolioManager, + private val token: TokenMarketParams, + private val onAddToPortfolioVisibilityChange: (Boolean) -> Unit, + private val onWalletSelectorVisibilityChange: (Boolean) -> Unit, + private val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit, + private val onAnotherWalletSelect: (UserWalletId) -> Unit, + private val onContinueClick: (selectedWalletId: UserWalletId, addedNetworks: Set) -> Unit, +) { + + /** + * Create [TangemBottomSheetConfig] + * + + * @param portfolioData portfolio data + * @param portfolioUIData portfolio bottom sheet visibility model + * @param selectedWallet selected wallet + * @param alreadyAddedNetworks already added networks + */ + @Suppress("LongParameterList") + fun create( + currentState: TangemBottomSheetConfig?, + portfolioData: PortfolioData, + portfolioUIData: PortfolioUIData, + selectedWallet: UserWallet?, + alreadyAddedNetworks: Set?, + artworks: Map, + ): TangemBottomSheetConfig { + return (currentState ?: TangemBottomSheetConfig.Empty).copy( + isShown = portfolioUIData.portfolioBSVisibilityModel.isAddToPortfolioBSVisible, + onDismissRequest = { onAddToPortfolioVisibilityChange(false) }, + content = if (selectedWallet != null && alreadyAddedNetworks != null) { + AddToPortfolioBSContentUM( + selectedWallet = selectedWallet.toSelectedUserWalletItemUM( + portfolioData = portfolioData, + balance = portfolioData.walletsWithBalance[selectedWallet.walletId]?.getOrNull(), + artwork = artworks[selectedWallet.walletId], + ), + selectNetworkUM = SelectNetworkUMConverter( + networksWithToggle = addToPortfolioManager.associateWithToggle( + userWalletId = selectedWallet.walletId, + alreadyAddedNetworkIds = alreadyAddedNetworks, + addToPortfolioData = portfolioUIData.addToPortfolioData, + ), + alreadyAddedNetworks = alreadyAddedNetworks, + onNetworkSwitchClick = onNetworkSwitchClick, + ).convert(value = token), + isScanCardNotificationVisible = portfolioUIData.isNeededColdWalletInteraction, + isContinueButtonEnabled = portfolioUIData.addToPortfolioData.isUserAddedNetworks( + userWalletId = selectedWallet.walletId, + ), + onContinueButtonClick = { + onContinueClick( + selectedWallet.walletId, + portfolioUIData.addToPortfolioData.getAddedNetworks( + userWalletId = selectedWallet.walletId, + alreadyAddedNetworkIds = alreadyAddedNetworks, + ), + ) + }, + walletSelectorConfig = createWalletSelectorBSConfig( + isShow = portfolioUIData.portfolioBSVisibilityModel.isWalletSelectorBSVisible, + portfolioData = portfolioData, + selectedWalletId = selectedWallet.walletId, + artworks = artworks, + ), + isWalletBlockVisible = portfolioData.walletsWithCurrencies + .filterKeys(UserWallet::isMultiCurrency).size > 1, + ) + } else { + TangemBottomSheetConfigContent.Empty + }, + ) + } + + private fun UserWallet.toSelectedUserWalletItemUM( + artwork: UserWalletItemUM.ImageState? = null, + portfolioData: PortfolioData, + balance: TotalFiatBalance?, + ): UserWalletItemUM { + return UserWalletItemUMConverter( + onClick = { onWalletSelectorVisibilityChange(true) }, + endIcon = UserWalletItemUM.EndIcon.Arrow, + balance = balance, + artwork = artwork, + appCurrency = portfolioData.appCurrency, + isBalanceHidden = portfolioData.isBalanceHidden, + ).convert(value = this) + } + + private fun createWalletSelectorBSConfig( + isShow: Boolean, + portfolioData: PortfolioData, + selectedWalletId: UserWalletId, + artworks: Map, + ): TangemBottomSheetConfig { + return TangemBottomSheetConfig( + isShown = isShow, + onDismissRequest = { onWalletSelectorVisibilityChange(false) }, + content = WalletSelectorBSContentUM( + userWallets = portfolioData.walletsWithCurrencies + .filterKeys(UserWallet::isMultiCurrency) + .map { it.key } + .map { userWallet -> + val balance = portfolioData.walletsWithBalance[userWallet.walletId] + + UserWalletItemUMConverter( + onClick = { id -> + if (id != selectedWalletId) { + onAnotherWalletSelect(id) + onWalletSelectorVisibilityChange(false) + } + }, + appCurrency = portfolioData.appCurrency, + balance = balance?.getOrNull(), + isBalanceHidden = portfolioData.isBalanceHidden, + endIcon = if (userWallet.walletId == selectedWalletId) { + UserWalletItemUM.EndIcon.Checkmark + } else { + UserWalletItemUM.EndIcon.None + }, + artwork = artworks[userWallet.walletId], + ).convert(userWallet) + } + .toImmutableList(), + onBack = { onWalletSelectorVisibilityChange(false) }, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioManager.kt new file mode 100644 index 0000000000..d176536657 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioManager.kt @@ -0,0 +1,193 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import timber.log.Timber +import javax.inject.Inject +import kotlin.collections.firstOrNull +import kotlin.collections.orEmpty +import kotlin.collections.set + +internal typealias WalletsWithNetworks = Map> + +/** + * Manager for tracking changing networks in AddToPortfolio + * +[REDACTED_AUTHOR] + */ +internal class AddToPortfolioManager @Inject constructor( + private val filterAvailableNetworksForWalletUseCase: FilterAvailableNetworksForWalletUseCase, +) { + + val availableNetworks = MutableStateFlow?>(value = null) + private val addedNetworks = MutableStateFlow(value = emptyMap()) + private val removedNetworks = MutableStateFlow(value = emptyMap()) + + /** Get [AddToPortfolioData] as flow */ + fun getAddToPortfolioData(): Flow { + return combine( + flow = availableNetworks, + flow2 = addedNetworks, + flow3 = removedNetworks, + transform = ::AddToPortfolioData, + ) + } + + /** Set available networks [networks] */ + fun setAvailableNetworks(networks: List) { + availableNetworks.value = networks.toSet() + } + + /** Add network [networkId] to [userWalletId] */ + fun addNetwork(userWalletId: UserWalletId, networkId: String) { + addedNetworks.add(userWalletId, networkId) + + removedNetworks.cancelPrevChangeIfExist(userWalletId = userWalletId, networkId = networkId) + } + + /** Remove network [networkId] from [userWalletId] */ + fun removeNetwork(userWalletId: UserWalletId, networkId: String) { + removedNetworks.add(userWalletId, networkId) + + addedNetworks.cancelPrevChangeIfExist( + userWalletId = userWalletId, + networkId = networkId, + ) + } + + /** Remove all networks by [userWalletId] */ + fun removeAllChanges(userWalletId: UserWalletId) { + addedNetworks.update { + it.toMutableMap().apply { remove(userWalletId) } + } + + removedNetworks.update { + it.toMutableMap().apply { remove(userWalletId) } + } + } + + fun associateWithToggle( + userWalletId: UserWalletId, + alreadyAddedNetworkIds: Set, + addToPortfolioData: AddToPortfolioData, + ): Map { + val filteredNetworks = filterAvailableNetworksForWalletUseCase( + userWalletId = userWalletId, + networks = addToPortfolioData.availableNetworks.orEmpty(), + ) + // Use user choice or check already added networks + return filteredNetworks.associateWith { availableNetwork -> + val isAddedByUser = addToPortfolioData.addedNetworks[userWalletId]?.contains(availableNetwork) + + if (isAddedByUser == true) return@associateWith true + + val isRemovedByUser = addToPortfolioData.removedNetworks[userWalletId]?.contains(availableNetwork) + + if (isRemovedByUser == true) return@associateWith false + + val isAddedBefore = alreadyAddedNetworkIds.any { it == availableNetwork.networkId } + + isAddedBefore + } + } + + private fun MutableStateFlow.cancelPrevChangeIfExist( + userWalletId: UserWalletId, + networkId: String, + ) { + if (value[userWalletId].orEmpty().any { it.networkId == networkId }) remove(userWalletId, networkId) + } + + private fun MutableStateFlow.add(userWalletId: UserWalletId, networkId: String) { + change(userWalletId = userWalletId, networkId = networkId, isAddAction = true) + } + + private fun MutableStateFlow.remove(userWalletId: UserWalletId, networkId: String) { + change(userWalletId = userWalletId, networkId = networkId, isAddAction = false) + } + + private fun MutableStateFlow.change( + userWalletId: UserWalletId, + networkId: String, + isAddAction: Boolean, + ) { + val network = availableNetworks.value.orEmpty().firstOrNull { it.networkId == networkId } + + if (network == null) { + Timber.d( + "Network [$networkId] doesn't contain in available networks [%s]", + availableNetworks.value?.joinToString { it.networkId }, + ) + + return + } + + update { walletsWithNetworks -> + walletsWithNetworks.toMutableMap().apply { + this[userWalletId] = if (isAddAction) { + this[userWalletId].orEmpty() + network + } else { + this[userWalletId].orEmpty() - network + } + } + } + } + + /** + * Add to portfolio data + * + * @property availableNetworks available networks that user can add to portfolio + * @property addedNetworks networks that user toggled on, but it might have already been added to the wallet + * @property removedNetworks networks that user toggled off, but it might haven't been added to the wallet + * + * Example for [addedNetworks] and [removedNetworks]. This lists will include new networks when user just + * toggle it. But when we will save user changes, we will check what tokens have already been added or + * haven't been added to the wallet. See [getAddedNetworks] and [getRemovedNetworks] + */ + data class AddToPortfolioData( + val availableNetworks: Set?, + val addedNetworks: WalletsWithNetworks, + val removedNetworks: WalletsWithNetworks, + ) { + + fun isUserAddedNetworks(userWalletId: UserWalletId): Boolean { + return addedNetworks[userWalletId].orEmpty().isNotEmpty() + } + + fun isUserChangedNetworks(userWalletId: UserWalletId): Boolean { + return addedNetworks[userWalletId].orEmpty().isNotEmpty() || + removedNetworks[userWalletId].orEmpty().isNotEmpty() + } + + /** Get new networks that user [userWalletId] added using [alreadyAddedNetworkIds] */ + fun getAddedNetworks( + userWalletId: UserWalletId, + alreadyAddedNetworkIds: Set, + ): Set { + val addedNetworksByUser = addedNetworks[userWalletId].orEmpty() + + return addedNetworksByUser.map { it.networkId } + .minus(alreadyAddedNetworkIds) + .mapNotNull { networkId -> addedNetworksByUser.firstOrNull { it.networkId == networkId } } + .toSet() + } + + /** Get networks that user [userWalletId] removed using [alreadyAddedNetworkIds] */ + fun getRemovedNetworks( + userWalletId: UserWalletId, + alreadyAddedNetworkIds: Set, + ): Set { + val removedNetworksByUser = removedNetworks[userWalletId].orEmpty() + + return alreadyAddedNetworkIds + .minus(removedNetworksByUser.map { it.networkId }.toSet()) + .mapNotNull { networkId -> removedNetworksByUser.firstOrNull { it.networkId == networkId } } + .toSet() + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt new file mode 100644 index 0000000000..e4be2f3924 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt @@ -0,0 +1,64 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.extensions.getGreyedOutIconRes +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketInfo.Network +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.converter.Converter + +/** + * Converter from [TokenMarketInfo.Network] to [BlockchainRowUM] + * + * @property alreadyAddedNetworks set of already added networks + * +[REDACTED_AUTHOR] + */ +internal class BlockchainRowUMConverter( + private val alreadyAddedNetworks: Set, +) : Converter, BlockchainRowUM> { + + override fun convert(value: Pair): BlockchainRowUM { + val (network, isSelected) = value + + val blockchainInfo = BlockchainUtils.getNetworkInfo(networkId = network.networkId) + ?: error("Can't find blockchain info for ${network.networkId}") + + val isMainNetwork = network.contractAddress == null + + val isEnabled = !alreadyAddedNetworks.contains(network.networkId) + + return BlockchainRowUM( + id = network.networkId, + name = blockchainInfo.name, + type = getNetworkType(network, blockchainInfo), + iconResId = if (isEnabled) { + if (isSelected) { + getActiveIconRes(blockchainInfo.blockchainId) + } else { + getGreyedOutIconRes(blockchainInfo.blockchainId) + } + } else { + getGreyedOutIconRes(blockchainInfo.blockchainId) + }, + isMainNetwork = isMainNetwork, + isSelected = isSelected, + isEnabled = isEnabled, + ) + } + + private fun getNetworkType(network: Network, blockchainInfo: BlockchainUtils.BlockchainInfo): String { + val isMainNetwork = network.contractAddress == null + return when { + BlockchainUtils.isL2Network(networkId = network.networkId) -> MAIN_NETWORK_L2_TYPE_NAME + isMainNetwork -> MAIN_NETWORK_TYPE_NAME + else -> blockchainInfo.protocolName + } + } + + private companion object { + const val MAIN_NETWORK_TYPE_NAME = "MAIN" + const val MAIN_NETWORK_L2_TYPE_NAME = "MAIN L2" + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt new file mode 100644 index 0000000000..550f17efbc --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt @@ -0,0 +1,440 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +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.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase +import com.tangem.domain.managetokens.model.CurrencyUnsupportedState +import com.tangem.domain.markets.SaveMarketTokensUseCase +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.currency.CryptoCurrency +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.transaction.usecase.ReceiveAddressesFactory +import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase +import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioDataLoader +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.features.feed.impl.R +import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle +import com.tangem.features.wallet.utils.UserWalletImageFetcher +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.operations.attestation.ArtworkSize +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject +import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioManager as NewAddToPortfolioManager + +@Suppress("LongParameterList", "LargeClass") +@Stable +@ModelScoped +internal class MarketsPortfolioModel @Inject constructor( + paramsContainer: ParamsContainer, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + tokenActionsIntentsFactory: TokenActionsHandler.Factory, + override val dispatchers: CoroutineDispatcherProvider, + private val messageSender: UiMessageSender, + private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, + private val getSelectedWalletUseCase: GetSelectedWalletUseCase, + private val portfolioDataLoader: PortfolioDataLoader, + private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, + private val saveMarketTokensUseCase: SaveMarketTokensUseCase, + private val addToPortfolioManager: AddToPortfolioManager, + private val analyticsEventHandler: AnalyticsEventHandler, + private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, + private val userWalletImageFetcher: UserWalletImageFetcher, + private val receiveAddressesFactory: ReceiveAddressesFactory, + accountsFeatureToggles: AccountsFeatureToggles, + newAddToPortfolioManagerFactory: NewAddToPortfolioManager.Factory, + newMarketsPortfolioDelegateFactory: NewMarketsPortfolioDelegate.Factory, +) : Model() { + + private val _state: MutableStateFlow = MutableStateFlow(value = MyPortfolioUM.Loading) + val state: StateFlow get() = _state + + private val params = paramsContainer.require() + private val analyticsEventBuilder = PortfolioAnalyticsEvent.EventBuilder( + token = params.token, + source = params.analyticsParams?.source, + ) + + val newAddToPortfolioManager: NewAddToPortfolioManager? + val newMarketsPortfolioDelegate: NewMarketsPortfolioDelegate? + + /** Multi-wallet [UserWalletId] that user uses to add new tokens in AddToPortfolio bottom sheet */ + private val selectedMultiWalletIdFlow = MutableStateFlow(value = null) + + private val portfolioBSVisibilityModelFlow = MutableStateFlow(value = PortfolioBSVisibilityModel()) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val addToPortfolioCallback = object : AddToPortfolioComponent.Callback { + override fun onDismiss() = bottomSheetNavigation.dismiss() + } + + private val currentAppCurrency = getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + + private val tokenActionsHandler = tokenActionsIntentsFactory.create( + currentAppCurrency = Provider { currentAppCurrency.value }, + updateTokenReceiveBSConfig = { updateBlock -> + if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled.not()) { + updateTokensState { + it.copy(tokenReceiveBSConfig = updateBlock(it.tokenReceiveBSConfig)) + } + } + }, + onHandleQuickAction = { handledAction -> + analyticsEventHandler.send( + analyticsEventBuilder.quickActionClick( + actionUM = handledAction.action, + blockchainName = handledAction + .cryptoCurrencyData + .status + .currency + .network + .name, + ), + ) + if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) { + configureReceiveAddresses(handledAction) + } + }, + ) + + private val factory = MyPortfolioUMFactory( + onAddClick = { + onAddToPortfolioBSVisibilityChange(isShow = true) + // === Analytics === + analyticsEventHandler.send( + analyticsEventBuilder.addToPortfolioClicked(), + ) + }, + addToPortfolioBSContentUMFactory = AddToPortfolioBSContentUMFactory( + addToPortfolioManager = addToPortfolioManager, + token = params.token, + onAddToPortfolioVisibilityChange = ::onAddToPortfolioBSVisibilityChange, + onWalletSelectorVisibilityChange = ::onWalletSelectorVisibilityChange, + onNetworkSwitchClick = ::onNetworkSwitchClick, + onAnotherWalletSelect = { walletId -> + onWalletSelect(walletId) + // === Analytics === + analyticsEventHandler.send( + analyticsEventBuilder.addToPortfolioWalletChanged(), + ) + }, + onContinueClick = { selectedWalletId, addedNetworks -> + onContinueClick(selectedWalletId, addedNetworks) + + // === Analytics === + analyticsEventHandler.send( + analyticsEventBuilder.addToPortfolioContinue( + blockchainNames = addedNetworks.mapNotNull { + BlockchainUtils.getNetworkInfo(it.networkId)?.name + }, + ), + ) + }, + ), + currentState = Provider { _state.value }, + tokenActionsHandler = tokenActionsHandler, + updateTokens = { updateBlock -> + updateTokensState { state -> + state.copy(tokens = updateBlock(state.tokens)) + } + }, + ) + + init { + if (accountsFeatureToggles.isFeatureEnabled) { + newAddToPortfolioManager = newAddToPortfolioManagerFactory + .create( + modelScope, + params.token, + params.analyticsParams, + ) + newMarketsPortfolioDelegate = newMarketsPortfolioDelegateFactory.create( + scope = modelScope, + token = params.token, + tokenActionsHandler = tokenActionsHandler, + buttonState = newAddToPortfolioManager.state.map { state -> + when (state) { + is NewAddToPortfolioManager.State.AvailableToAdd -> { + MyPortfolioUM.Tokens.AddButtonState.Available + } + NewAddToPortfolioManager.State.Init -> MyPortfolioUM.Tokens.AddButtonState.Loading + NewAddToPortfolioManager.State.NothingToAdd -> MyPortfolioUM.Tokens.AddButtonState.Unavailable + } + }, + onAddClick = { + analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioClicked()) + bottomSheetNavigation.activate(MarketsPortfolioRoute.AddToPortfolio) + }, + ) + newMarketsPortfolioDelegate.combineData() + .onEach { _state.value = it } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } else { + newAddToPortfolioManager = null + newMarketsPortfolioDelegate = null + // Subscribe on selected wallet flow to support actual selected wallet + subscribeOnSelectedMultiWalletUpdates() + + subscribeOnStateUpdates() + } + } + + fun setTokenNetworks(networks: List) { + addToPortfolioManager.setAvailableNetworks(networks) + newAddToPortfolioManager?.setTokenNetworks(networks) + newMarketsPortfolioDelegate?.setTokenNetworks(networks) + } + + fun setNoNetworksAvailable() { + addToPortfolioManager.setAvailableNetworks(emptyList()) + newAddToPortfolioManager?.setTokenNetworks(emptyList()) + newMarketsPortfolioDelegate?.setTokenNetworks(emptyList()) + } + + private fun subscribeOnSelectedMultiWalletUpdates() { + getSelectedWalletUseCase() + .getOrElse { e -> + Timber.e("Failed to load selected wallet: $e") + error("Failed to load selected wallet") + } + .onEach { userWallet -> + selectedMultiWalletIdFlow.value = userWallet.takeIf { it.isMultiCurrency }?.walletId + } + .launchIn(modelScope) + } + + private fun subscribeOnStateUpdates() { + combine( + flow = loadPortfolioDataWithArtworks(params.token.id), + flow2 = getPortfolioUIDataFlow(), + transform = { pair, portfolioUIData -> + val (portfolioData, artworks) = pair + factory.create(portfolioData, portfolioUIData, artworks) + }, + ) + .onEach { _state.value = it } + .launchIn(modelScope) + } + + private fun loadPortfolioDataWithArtworks( + currencyRawId: CryptoCurrency.RawID, + ): Flow>> { + val wallets = Channel>() + val portfolioFlow = portfolioDataLoader + .load(currencyRawId) + .onEach { wallets.trySend(it.walletsWithCurrencies.keys) } + + val artworksFlow = wallets.receiveAsFlow() + .distinctUntilChanged() + .flatMapLatest { userWalletImageFetcher.walletsImage(wallets = it, size = ArtworkSize.SMALL) } + + return combine( + flow = portfolioFlow, + flow2 = artworksFlow, + ) { portfolioData, artworks -> portfolioData to artworks } + } + + private fun getPortfolioUIDataFlow(): Flow { + return combine( + flow = portfolioBSVisibilityModelFlow, + flow2 = selectedMultiWalletIdFlow, + flow3 = addToPortfolioManager.getAddToPortfolioData(), + transform = { portfolioBSVisibilityModel, selectedWalletId, addToPortfolioData -> + PortfolioUIData( + portfolioBSVisibilityModel = portfolioBSVisibilityModel, + selectedWalletId = selectedWalletId, + addToPortfolioData = addToPortfolioData, + isNeededColdWalletInteraction = isNeededColdWalletInteraction(selectedWalletId, addToPortfolioData), + ) + }, + ) + } + + private suspend fun isNeededColdWalletInteraction( + selectedWalletId: UserWalletId?, + addToPortfolioData: AddToPortfolioManager.AddToPortfolioData, + ): Boolean { + return if (selectedWalletId != null) { + coldWalletAndHasMissedDerivationsUseCase.invoke( + userWalletId = selectedWalletId, + networksWithDerivationPath = addToPortfolioData.addedNetworks[selectedWalletId].orEmpty() + .associate { it.networkId to null }, + ) + } else { + false + } + } + + private fun onNetworkSwitchClick(blockchainRowUM: BlockchainRowUM, isChecked: Boolean) { + val selectedWalletId = selectedMultiWalletIdFlow.value + + if (selectedWalletId == null) { + Timber.e("Impossible to switch network when selected wallet is null") + return + } + + if (isChecked) { + modelScope.launch { + val unsupportedState = checkCurrencyUnsupportedState( + userWalletId = selectedWalletId, + rawNetworkId = blockchainRowUM.id, + isMainNetwork = blockchainRowUM.isMainNetwork, + ) + if (unsupportedState != null) { + showUnsupportedWarning(unsupportedState) + } else { + addToPortfolioManager.addNetwork(userWalletId = selectedWalletId, networkId = blockchainRowUM.id) + } + } + } else { + addToPortfolioManager.removeNetwork(userWalletId = selectedWalletId, networkId = blockchainRowUM.id) + } + } + + private suspend fun checkCurrencyUnsupportedState( + userWalletId: UserWalletId, + rawNetworkId: String, + isMainNetwork: Boolean, + ): CurrencyUnsupportedState? { + return checkCurrencyUnsupportedUseCase( + userWalletId = userWalletId, + networkId = rawNetworkId, + isMainNetwork = isMainNetwork, + ).getOrElse { throwable -> + Timber.e( + throwable, + """ + Failed to check currency unsupported state + |- User wallet ID: $userWalletId + |- Network ID: $rawNetworkId + |- Is main network: $isMainNetwork + """.trimIndent(), + ) + + val message = SnackbarMessage( + message = throwable.localizedMessage + ?.let(::stringReference) + ?: resourceReference(R.string.common_error), + ) + messageSender.send(message) + + null + } + } + + private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) { + val message = DialogMessage( + message = when (unsupportedState) { + is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + }, + ) + + messageSender.send(message) + } + + private fun onWalletSelect(userWalletId: UserWalletId) { + selectedMultiWalletIdFlow.update { prevUserWalletId -> + prevUserWalletId?.let(addToPortfolioManager::removeAllChanges) + + userWalletId + } + } + + private fun onContinueClick(userWalletId: UserWalletId, addedNetworks: Set) { + modelScope.launch { + saveMarketTokensUseCase( + userWalletId = userWalletId, + tokenMarketParams = params.token, + addedNetworks = addedNetworks, + removedNetworks = emptySet(), + ) + + onAddToPortfolioBSVisibilityChange(isShow = false) + + addToPortfolioManager.removeAllChanges(userWalletId) + } + } + + private fun onAddToPortfolioBSVisibilityChange(isShow: Boolean) { + portfolioBSVisibilityModelFlow.update { + it.copy(isAddToPortfolioBSVisible = isShow, isWalletSelectorBSVisible = false) + } + } + + private fun onWalletSelectorVisibilityChange(isShow: Boolean) { + portfolioBSVisibilityModelFlow.update { + it.copy(isAddToPortfolioBSVisible = true, isWalletSelectorBSVisible = isShow) + } + } + + private fun updateTokensState(block: (MyPortfolioUM.Tokens) -> MyPortfolioUM) { + _state.update { stateToUpdate -> + val tokensState = stateToUpdate as? MyPortfolioUM.Tokens ?: return@update stateToUpdate + block(tokensState) + } + } + + private fun configureReceiveAddresses(quickAction: TokenActionsHandler.HandledQuickAction) { + val isNewReceive = quickAction.action == TokenActionsBSContentUM.Action.Receive && + tokenReceiveFeatureToggle.isNewTokenReceiveEnabled + if (isNewReceive) { + modelScope.launch { + val tokenConfig = receiveAddressesFactory.create( + status = quickAction.cryptoCurrencyData.status, + userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId, + ) ?: return@launch + bottomSheetNavigation.activate(MarketsPortfolioRoute.TokenReceive(tokenConfig)) + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioRoute.kt new file mode 100644 index 0000000000..684f586b8a --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioRoute.kt @@ -0,0 +1,17 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.models.TokenReceiveConfig +import kotlinx.serialization.Serializable + +@Serializable +sealed interface MarketsPortfolioRoute : Route { + + @Serializable + data object AddToPortfolio : MarketsPortfolioRoute + + @Serializable + data class TokenReceive( + val config: TokenReceiveConfig, + ) : MarketsPortfolioRoute +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MyPortfolioUMFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MyPortfolioUMFactory.kt new file mode 100644 index 0000000000..72ff24db98 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MyPortfolioUMFactory.kt @@ -0,0 +1,153 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.utils.Provider +import kotlinx.collections.immutable.ImmutableList + +/** + * Factory for creating [MyPortfolioUM] + * + * @property onAddClick callback when user wants to add new token + * @property addToPortfolioBSContentUMFactory factory for creating add to portfolio bottom sheet content + * @property tokenActionsHandler token actions handler + * @property currentState current state provider + * @property updateTokens callback for updating tokens + * +[REDACTED_AUTHOR] + */ +internal class MyPortfolioUMFactory( + private val onAddClick: () -> Unit, + private val addToPortfolioBSContentUMFactory: AddToPortfolioBSContentUMFactory, + private val tokenActionsHandler: TokenActionsHandler, + private val currentState: Provider, + private val updateTokens: ((ImmutableList) -> ImmutableList) -> Unit, +) { + + fun create( + portfolioData: PortfolioData, + portfolioUIData: PortfolioUIData, + artworks: Map, + ): MyPortfolioUM { + val addToPortfolioData = portfolioUIData.addToPortfolioData + + val isOnlyUnavailableNetworks = addToPortfolioData.availableNetworks?.isEmpty() == true + if (isOnlyUnavailableNetworks) return MyPortfolioUM.Unavailable + + val walletsWithCurrencies = if (addToPortfolioData.availableNetworks == null) { + portfolioData.walletsWithCurrencies + } else { + portfolioData.walletsWithCurrencies.filterAvailableNetworks(networks = addToPortfolioData.availableNetworks) + } + + val isPortfolioEmpty = walletsWithCurrencies.flatMap { it.value }.isEmpty() + if (isPortfolioEmpty) { + val hasMultiWallets = walletsWithCurrencies.filterKeys(UserWallet::isMultiCurrency).isNotEmpty() + + return if (hasMultiWallets) { + MyPortfolioUM.AddFirstToken( + addToPortfolioBSConfig = createAddToPortfolioBSConfig( + portfolioData = portfolioData, + portfolioUIData = portfolioUIData, + artworks = artworks, + ), + onAddClick = onAddClick, + ) + } else { + MyPortfolioUM.UnavailableForWallet + } + } + + return TokensPortfolioUMConverter( + appCurrency = portfolioData.appCurrency, + isBalanceHidden = portfolioData.isBalanceHidden, + addButtonState = walletsWithCurrencies.getAddButtonState( + availableNetworks = addToPortfolioData.availableNetworks, + ), + bsConfig = createAddToPortfolioBSConfig( + portfolioData = portfolioData, + portfolioUIData = portfolioUIData, + artworks = artworks, + ), + onAddClick = onAddClick, + quickActionsIntents = tokenActionsHandler, + currentState = currentState, + updateTokens = updateTokens, + ) + .convert(walletsWithCurrencies) + } + + private fun createAddToPortfolioBSConfig( + portfolioData: PortfolioData, + portfolioUIData: PortfolioUIData, + artworks: Map, + ): TangemBottomSheetConfig { + val selectedWallet = portfolioData.walletsWithCurrencies.keys + .firstOrNull { it.walletId == portfolioUIData.selectedWalletId } + ?: portfolioData.walletsWithCurrencies.keys.firstOrNull { it.isMultiCurrency } + + val availableNetworks = portfolioUIData.addToPortfolioData.availableNetworks.orEmpty() + + val alreadyAddedNetworks = portfolioData.walletsWithCurrencies + .filterAvailableNetworks(availableNetworks)[selectedWallet] + ?.filter { !it.status.currency.isCustom } + ?.map { it.status.currency.network.backendId } + ?.toSet() + + return addToPortfolioBSContentUMFactory.create( + currentState = currentState().addToPortfolioBSConfig, + portfolioData = portfolioData, + portfolioUIData = portfolioUIData, + selectedWallet = selectedWallet, + alreadyAddedNetworks = alreadyAddedNetworks, + artworks = artworks, + ) + } + + private fun Map>.getAddButtonState( + availableNetworks: Set?, + ): MyPortfolioUM.Tokens.AddButtonState { + if (availableNetworks == null) return MyPortfolioUM.Tokens.AddButtonState.Loading + + val networkIds = availableNetworks.map { it.networkId } + + val isAllAvailableNetworksAdded = this + // User can add currencies only in multi-currency wallets + .filterKeys(UserWallet::isMultiCurrency) + .mapValues { entry -> entry.value.map { it.status.currency.network.backendId } } + // Each wallets contains all available networks? + .all { it.value.containsAll(networkIds) } + + return if (isAllAvailableNetworksAdded) { + MyPortfolioUM.Tokens.AddButtonState.Unavailable + } else { + MyPortfolioUM.Tokens.AddButtonState.Available + } + } + + /** Filter map values by available networks [networks] */ + private fun Map>.filterAvailableNetworks( + networks: Set, + ): Map> { + return mapValues { entry -> entry.value.filterAvailableNetworks(networks) } + } + + /** Filter list of [CryptoCurrencyStatus] by available networks [networks] */ + private fun List.filterAvailableNetworks( + networks: Set, + ): List { + val networkIds = networks.map(TokenMarketInfo.Network::networkId) + + return mapNotNull { cryptoCurrencyData -> + cryptoCurrencyData.takeIf { networkIds.contains(it.status.currency.network.backendId) } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/NewMarketsPortfolioDelegate.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/NewMarketsPortfolioDelegate.kt new file mode 100644 index 0000000000..9fdad17c66 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/NewMarketsPortfolioDelegate.kt @@ -0,0 +1,332 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.toUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.account.Account +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.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioHeader +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioListItem +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.WalletHeader +import com.tangem.utils.extensions.isZero +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LongParameterList") +internal class NewMarketsPortfolioDelegate @AssistedInject constructor( + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val allAccountSupplier: MultiAccountStatusListSupplier, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, + private val getUserWalletUseCase: GetUserWalletUseCase, + isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + @Assisted private val scope: CoroutineScope, + @Assisted private val token: TokenMarketParams, + @Assisted private val tokenActionsHandler: TokenActionsHandler, + @Assisted private val buttonState: Flow, + @Assisted private val onAddClick: () -> Unit, +) { + + private val currencyRawId: CryptoCurrency.RawID = token.id + private var expandedHolder: MutableStateFlow>>? = null + + private val settingsFlow: Flow = combine( + flow = getSelectedAppCurrencyUseCase.invokeOrDefault(), + flow2 = getBalanceHidingSettingsUseCase.isBalanceHidden(), + flow3 = isAccountsModeEnabledUseCase(), + transform = ::SettingsBox, + ).shareIn( + replay = 1, + started = SharingStarted.Eagerly, + scope = scope, + ).distinctUntilChanged() + + private val availableNetworks = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + fun setTokenNetworks(networks: List) { + availableNetworks.tryEmit(networks) + } + + fun combineData(): Flow { + return availableNetworks.transformLatest { availableNetworks -> + when { + availableNetworks.isEmpty() -> emit(MyPortfolioUM.Unavailable) + else -> emitAll(onAvailableNetworksFlow().distinctUntilChanged()) + } + }.distinctUntilChanged() + } + + private fun onAvailableNetworksFlow(): Flow = + portfolioWithThisCurrencyFLow().transformLatest { portfolioWithCurrency -> + when (portfolioWithCurrency.flattenAddedCurrency.isEmpty()) { + false -> emitAll(contentFlow(portfolioWithCurrency).distinctUntilChanged()) + true -> when (portfolioWithCurrency.hasMultiWallets) { + true -> emitAll(addFirstTokenFlow()) + false -> emit(MyPortfolioUM.UnavailableForWallet) + } + } + } + + private fun addFirstTokenFlow(): Flow = buttonState.map { state -> + when (state) { + MyPortfolioUM.Tokens.AddButtonState.Loading -> MyPortfolioUM.Loading + MyPortfolioUM.Tokens.AddButtonState.Available -> MyPortfolioUM.AddFirstToken( + onAddClick = onAddClick, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + ) + MyPortfolioUM.Tokens.AddButtonState.Unavailable -> MyPortfolioUM.Unavailable + } + } + + private fun contentFlow(portfoliosWithThisCurrency: PortfoliosWithThisCurrency): Flow { + fun Portfolio.actionsFoAccountCurrencies(): List>> = + accountsWithAdded.map { account -> + fun CryptoCurrencyStatus.actionsFlow() = getCryptoCurrencyActionsUseCase( + accountId = account.accountStatus.account.accountId, + currency = this.currency, + ).map { actionsState -> actionsState.cryptoCurrencyStatus.currency to actionsState } + account.addedCurrency.map { it.actionsFlow() } + }.flatten() + + val allAddedTokenActions = + portfoliosWithThisCurrency.portfolios.map { portfolio -> portfolio.actionsFoAccountCurrencies() }.flatten() + + return combine( + flow = combine(allAddedTokenActions) { it.toMap() }.distinctUntilChanged(), + flow2 = buttonState.distinctUntilChanged(), + flow3 = getExpandedHolder(portfoliosWithThisCurrency), + flow4 = settingsFlow.distinctUntilChanged(), + transform = { actions, addButtonState, expanded, settings -> + buildContentState( + portfolio = portfoliosWithThisCurrency, + allActions = actions, + addButtonState = addButtonState, + expanded = expanded, + settings = settings, + ) + }, + ) + } + + private fun getExpandedHolder( + portfolio: PortfoliosWithThisCurrency, + ): StateFlow>> { + val expandedHolder = this.expandedHolder + if (expandedHolder != null) return expandedHolder + val allAddedCurrency = portfolio.flattenAddedCurrency + val shouldForceExpand = allAddedCurrency.size == 1 && + allAddedCurrency.first().value.amount?.isZero() == true + + val initValue = when { + shouldForceExpand -> { + val currency = allAddedCurrency.first() + // find userWallet than have this single added token + portfolio.portfolios + .find { it.accountsWithAdded.any { account -> account.addedCurrency.isNotEmpty() } } + ?.userWallet + ?.let { setOf(it.walletId to currency.currency.id) } + .orEmpty() + } + else -> emptySet() + } + return MutableStateFlow(initValue) + .also { this.expandedHolder = it } + } + + private fun portfolioWithThisCurrencyFLow(): Flow = + allAccountSupplier().map { list -> list.map { it.addedAccountsFlow() } }.flatMapLatest { flows -> + combine(flows) { portfolios -> + PortfoliosWithThisCurrency( + currencyRawId = currencyRawId, + portfolios = portfolios.toList(), + ) + } + }.distinctUntilChanged() + + private fun AccountStatusList.addedAccountsFlow(): Flow = + getUserWalletUseCase.invokeFlow(this.userWalletId).mapNotNull { it.getOrNull() }.map { wallet -> + Portfolio( + userWallet = wallet, + accountStatusList = this, + accountsWithAdded = this.filterByRawID(), + ) + }.distinctUntilChanged() + + private fun AccountStatusList.filterByRawID(): List { + fun AccountStatus.filterByRawID(): List = when (this) { + is AccountStatus.CryptoPortfolio -> this.tokenList.flattenCurrencies() + .filter { status -> status.currency.id.rawCurrencyId == currencyRawId } + } + return accountStatuses.map { accountStatus -> + AccountWithAdded( + accountStatus = accountStatus, + addedCurrency = accountStatus.filterByRawID(), + ) + } + } + + private fun buildContentState( + portfolio: PortfoliosWithThisCurrency, + allActions: Map, + addButtonState: MyPortfolioUM.Tokens.AddButtonState, + expanded: Set>, + settings: SettingsBox, + ): MyPortfolioUM.Content { + val appCurrency = settings.appCurrency + val isBalanceHidden = settings.isBalanceHidden + val isAccountMode = settings.isAccountMode + val uiItems: MutableList = mutableListOf() + + fun toggleQuickActions(key: Pair) = expandedHolder?.update { expanded -> + val isExpand = expanded.contains(key) + if (isExpand) expanded.minus(key) else expanded.plus(key) + } + + val tokenUMConverter = PortfolioTokenUMConverter( + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + onTokenItemClick = { }, + tokenActionsHandler = tokenActionsHandler, + ) + + portfolio.portfolios.forEach { portfolioItem -> + if (portfolioItem.flattenAddedCurrency.isEmpty()) return@forEach + val userWallet = portfolioItem.userWallet + if (isAccountMode) { + uiItems.add(portfolioItem.userWallet.toWalletHeader()) + } else { + uiItems.add(portfolioItem.userWallet.toWalletPortfolioHeader()) + } + + portfolioItem.accountsWithAdded.forEach { accountWithAdded -> + if (accountWithAdded.addedCurrency.isEmpty()) return@forEach + if (isAccountMode) { + val account = accountWithAdded.accountStatus.account + uiItems.add(account.toAccountPortfolioHeader()) + } + + accountWithAdded.addedCurrency.forEach { currencyStatus -> + val actions = allActions[currencyStatus.currency]?.states.orEmpty() + val value = PortfolioData.CryptoCurrencyData( + userWallet = userWallet, + status = currencyStatus, + actions = actions, + ) + val expandedKey = portfolioItem.userWallet.walletId to currencyStatus.currency.id + val isExpand = expanded.contains(expandedKey) + + val tokenItem = tokenUMConverter.convertV2( + onTokenItemClick = { wallet, status -> + toggleQuickActions(wallet.walletId to status.currency.id) + }, + value = value, + isQuickActionsShown = isExpand, + ) + uiItems.add(tokenItem) + } + } + } + + return MyPortfolioUM.Content( + items = uiItems.toImmutableList(), + buttonState = addButtonState, + onAddClick = onAddClick, + ) + } + + private fun Account.toAccountPortfolioHeader(): PortfolioHeader = PortfolioHeader( + id = this.accountId.value, + state = AccountTitleUM.Account( + prefixText = TextReference.EMPTY, + name = this.accountName.toUM().value, + icon = when (this) { + is Account.CryptoPortfolio -> this.icon.toUM() + }, + ), + ) + + private fun UserWallet.toWalletPortfolioHeader(): PortfolioHeader = PortfolioHeader( + id = this.walletId.stringValue, + state = AccountTitleUM.Text( + title = stringReference(this.name), + ), + ) + + private fun UserWallet.toWalletHeader(): WalletHeader = WalletHeader( + id = this.walletId.stringValue, + name = stringReference(this.name), + ) + + @Suppress("LongParameterList") + @AssistedFactory + interface Factory { + fun create( + scope: CoroutineScope, + token: TokenMarketParams, + tokenActionsHandler: TokenActionsHandler, + buttonState: Flow, + onAddClick: () -> Unit, + ): NewMarketsPortfolioDelegate + } +} + +private data class PortfoliosWithThisCurrency( + val currencyRawId: CryptoCurrency.RawID, + val portfolios: List, +) { + + val hasMultiWallets: Boolean = portfolios.any { it.userWallet.isMultiCurrency } + + val flattenAddedCurrency: List = + portfolios.map { portfolio -> portfolio.flattenAddedCurrency }.flatten() +} + +private data class Portfolio( + val userWallet: UserWallet, + val accountStatusList: AccountStatusList, + val accountsWithAdded: List, +) { + val flattenAddedCurrency: List = + accountsWithAdded.map { it.addedCurrency }.flatten() +} + +private data class AccountWithAdded( + val addedCurrency: List, + val accountStatus: AccountStatus, +) + +private data class SettingsBox( + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val isAccountMode: Boolean, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioBSVisibilityModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioBSVisibilityModel.kt new file mode 100644 index 0000000000..dd3f13b3e7 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioBSVisibilityModel.kt @@ -0,0 +1,14 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +/** + * Model for portfolio bottom sheet visibility + * + * @property isAddToPortfolioBSVisible visibility of add to portfolio bottom sheet + * @property isWalletSelectorBSVisible visibility of wallet selector bottom sheet + * +[REDACTED_AUTHOR] + */ +internal data class PortfolioBSVisibilityModel( + val isAddToPortfolioBSVisible: Boolean = false, + val isWalletSelectorBSVisible: Boolean = false, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt new file mode 100644 index 0000000000..8041823bc4 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt @@ -0,0 +1,124 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList + +/** + * Converter from [UserWallet] and [CryptoCurrencyStatus] to [PortfolioTokenUM] + * +[REDACTED_AUTHOR] + */ +internal class PortfolioTokenUMConverter( + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val onTokenItemClick: (CryptoCurrencyStatus) -> Unit, + private val tokenActionsHandler: TokenActionsHandler, +) : Converter { + + fun convertV2( + value: PortfolioData.CryptoCurrencyData, + isQuickActionsShown: Boolean, + onTokenItemClick: (UserWallet, CryptoCurrencyStatus) -> Unit, + ): PortfolioTokenUM { + val tokenItemStateConverter = TokenItemStateConverter( + appCurrency = appCurrency, + onItemClick = { _, status -> onTokenItemClick(value.userWallet, status) }, + ) + return PortfolioTokenUM( + tokenItemState = tokenItemStateConverter.convert(value = value.status), + walletId = value.userWallet.walletId, + isBalanceHidden = isBalanceHidden, + isQuickActionsShown = isQuickActionsShown, + quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler), + ) + } + + override fun convert(value: PortfolioData.CryptoCurrencyData): PortfolioTokenUM { + val tokenItemStateConverter = TokenItemStateConverter( + appCurrency = appCurrency, + titleStateProvider = { TokenItemState.TitleState.Content(text = stringReference(value.userWallet.name)) }, + subtitleStateProvider = { + TokenItemState.SubtitleState.TextContent(value = stringReference(value.status.currency.name)) + }, + onItemClick = { _, status -> onTokenItemClick(status) }, + ) + + return PortfolioTokenUM( + tokenItemState = tokenItemStateConverter.convert(value = value.status), + walletId = value.userWallet.walletId, + isBalanceHidden = isBalanceHidden, + isQuickActionsShown = false, + quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler), + ) + } + + companion object { + fun quickActions( + cryptoData: PortfolioData.CryptoCurrencyData, + tokenActionsHandler: TokenActionsHandler, + ): PortfolioTokenUM.QuickActions { + return PortfolioTokenUM.QuickActions( + actions = toQuickActions(cryptoData.actions), + onQuickActionClick = { quickActionUM -> + when (quickActionUM) { + QuickActionUM.Buy -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Buy, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.Exchange -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Exchange, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.Receive -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Receive, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.Stake -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Stake, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.YieldMode -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.YieldMode, + cryptoCurrencyData = cryptoData, + ) + } + }, + onQuickActionLongClick = { actionUM -> + if (actionUM == QuickActionUM.Receive) { + tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.CopyAddress, + cryptoCurrencyData = cryptoData, + ) + } + }, + ) + } + + fun toQuickActions(actions: List) = buildList { + actions.forEach { action -> + if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { + when (action) { + is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy + is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(action.showBadge) + is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive + is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake + is TokenActionsState.ActionState.YieldMode -> QuickActionUM.YieldMode(action.apy) + else -> null + }?.let(::add) + } + } + }.toImmutableList() + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioUIData.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioUIData.kt new file mode 100644 index 0000000000..894b86dc98 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioUIData.kt @@ -0,0 +1,20 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Portfolio UI data. Combined data from all UI flows that required to setup portfolio + * + * @property portfolioBSVisibilityModel portfolio bottom sheet visibility model + * @property selectedWalletId selected wallet id + * @property addToPortfolioData add to portfolio data + * @property isNeededColdWalletInteraction flag that indicates if user has missed derivations and has a cold wallet + * +[REDACTED_AUTHOR] + */ +internal data class PortfolioUIData( + val portfolioBSVisibilityModel: PortfolioBSVisibilityModel, + val selectedWalletId: UserWalletId?, + val addToPortfolioData: AddToPortfolioManager.AddToPortfolioData, + val isNeededColdWalletInteraction: Boolean, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/SelectNetworkUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/SelectNetworkUMConverter.kt new file mode 100644 index 0000000000..5f9c6b282e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/SelectNetworkUMConverter.kt @@ -0,0 +1,37 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList + +/** + * Converter from [TokenMarketParams] to [SelectNetworkUM] + * + * @property networksWithToggle map of networks with toggles + * @property alreadyAddedNetworks already added networks + * @property onNetworkSwitchClick callback is called when network switch is clicked + * +[REDACTED_AUTHOR] + */ +internal class SelectNetworkUMConverter( + private val networksWithToggle: Map, + private val alreadyAddedNetworks: Set, + private val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit, +) : Converter { + + override fun convert(value: TokenMarketParams): SelectNetworkUM { + return SelectNetworkUM( + tokenId = value.id.value, + iconUrl = value.imageUrl, + tokenName = value.name, + tokenCurrencySymbol = value.symbol, + networks = BlockchainRowUMConverter(alreadyAddedNetworks) + .convertList(networksWithToggle.toList()) + .toImmutableList(), + onNetworkSwitchClick = { um, isChecked -> onNetworkSwitchClick(um, isChecked) }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt new file mode 100644 index 0000000000..d9abe7979e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt @@ -0,0 +1,221 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.routing.AppRoute +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig +import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.share.ShareManager +import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.onramp.model.OnrampSource +import com.tangem.domain.redux.ReduxStateHolder +import com.tangem.domain.tokens.legacy.TradeCryptoAction +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.features.feed.impl.R +import com.tangem.utils.Provider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.toImmutableList + +@Suppress("LongParameterList") +internal class TokenActionsHandler @AssistedInject constructor( + private val router: Router, + private val clipboardManager: ClipboardManager, + private val uiMessageSender: UiMessageSender, + private val reduxStateHolder: ReduxStateHolder, + @Assisted private val currentAppCurrency: Provider, + @Assisted private val updateTokenReceiveBSConfig: ((TangemBottomSheetConfig) -> TangemBottomSheetConfig) -> Unit, + @Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit, + private val isDemoCardUseCase: IsDemoCardUseCase, + private val messageSender: UiMessageSender, + private val shareManager: ShareManager, +) { + + private val disabledActionsInDemoMode = buildSet { + add(TokenActionsBSContentUM.Action.Sell) + } + + fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + onHandleQuickAction( + HandledQuickAction( + action = action, + cryptoCurrencyData = cryptoCurrencyData, + ), + ) + val userWallet = cryptoCurrencyData.userWallet + if (userWallet is UserWallet.Cold && handleDemoMode(action, userWallet)) return + + when (action) { + TokenActionsBSContentUM.Action.Buy -> onBuyClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Exchange -> onExchangeClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Receive -> onReceiveClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.CopyAddress -> onCopyAddress(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Sell -> onSellClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Send -> onSendClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Stake -> onStakeClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.YieldMode -> onYieldModeClick(cryptoCurrencyData) + } + } + + private fun handleDemoMode(action: TokenActionsBSContentUM.Action, userWallet: UserWallet.Cold): Boolean { + val isDemoCard = isDemoCardUseCase.invoke(userWallet.cardId) + val isNeededShowDemoWarning = isDemoCard && disabledActionsInDemoMode.contains(action) + + if (isNeededShowDemoWarning) { + showDemoModeWarning() + } + + return isNeededShowDemoWarning + } + + private fun showDemoModeWarning() { + val message = DialogMessage( + message = resourceReference(R.string.alert_demo_feature_disabled), + ) + messageSender.send(message) + } + + private fun onReceiveClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val cryptoCurrencyStatus = cryptoCurrencyData.status + val currency = cryptoCurrencyStatus.currency + val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return + + updateTokenReceiveBSConfig { + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = { + updateTokenReceiveBSConfig { + it.copy(isShown = false) + } + }, + content = TokenReceiveBottomSheetConfig( + asset = TokenReceiveBottomSheetConfig.Asset.Currency( + name = currency.name, + symbol = currency.symbol, + ), + network = currency.network, + networkAddress = networkAddress, + showMemoDisclaimer = currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE, + onCopyClick = { clipboardManager.setText(networkAddress.defaultAddress.value, isSensitive = true) }, + onShareClick = { shareManager.shareText(networkAddress.defaultAddress.value) }, + ), + ) + } + } + + private fun onCopyAddress(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val cryptoCurrencyStatus = cryptoCurrencyData.status + val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return + val addresses = networkAddress.availableAddresses + .mapToAddressModels(cryptoCurrencyStatus.currency) + .toImmutableList() + val defaultAddress = addresses.firstOrNull()?.value ?: return + + clipboardManager.setText(text = defaultAddress, isSensitive = true) + uiMessageSender.send(SnackbarMessage(resourceReference(R.string.wallet_notification_address_copied))) + } + + private fun onBuyClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + router.push( + AppRoute.Onramp( + userWalletId = cryptoCurrencyData.userWallet.walletId, + currency = cryptoCurrencyData.status.currency, + source = OnrampSource.MARKETS, + ), + ) + } + + private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + reduxStateHolder.dispatch( + TradeCryptoAction.Sell( + cryptoCurrencyStatus = cryptoCurrencyData.status, + appCurrencyCode = currentAppCurrency().code, + ), + ) + } + + private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + router.push( + AppRoute.Swap( + currencyFrom = cryptoCurrencyData.status.currency, + userWalletId = cryptoCurrencyData.userWallet.walletId, + isInitialReverseOrder = true, + screenSource = AnalyticsParam.ScreensSources.Markets.value, + ), + ) + } + + private fun onSendClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val route = AppRoute.SendEntryPoint( + userWalletId = cryptoCurrencyData.userWallet.walletId, + currency = cryptoCurrencyData.status.currency, + ) + router.push(route) + } + + private fun onStakeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val option = cryptoCurrencyData.actions.firstOrNull { it is TokenActionsState.ActionState.Stake } + ?.let { it as TokenActionsState.ActionState.Stake } + ?.option ?: return + + router.push( + AppRoute.Staking( + userWalletId = cryptoCurrencyData.userWallet.walletId, + cryptoCurrency = cryptoCurrencyData.status.currency, + integrationId = option.integrationId, + ), + ) + } + + private fun onYieldModeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val yieldSupplyApy = cryptoCurrencyData.actions.filterIsInstance() + .firstOrNull()?.apy ?: return + + val (userWalletId, cryptoCurrencyStatus) = cryptoCurrencyData.let { currencyData -> + currencyData.userWallet.walletId to currencyData.status + } + if (cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true) { + router.push( + AppRoute.YieldSupplyActive( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + apy = yieldSupplyApy, + ), + ) + } else { + router.push( + AppRoute.YieldSupplyPromo( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + apy = yieldSupplyApy, + ), + ) + } + } + + @AssistedFactory + interface Factory { + fun create( + currentAppCurrency: Provider, + updateTokenReceiveBSConfig: ((TangemBottomSheetConfig) -> TangemBottomSheetConfig) -> Unit, + onHandleQuickAction: (HandledQuickAction) -> Unit, + ): TokenActionsHandler + } + + data class HandledQuickAction( + val action: TokenActionsBSContentUM.Action, + val cryptoCurrencyData: PortfolioData.CryptoCurrencyData, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokensPortfolioUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokensPortfolioUMConverter.kt new file mode 100644 index 0000000000..4444e6a82f --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokensPortfolioUMConverter.kt @@ -0,0 +1,116 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isZero +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlin.collections.map + +/** + * Converter from [Map] of [UserWallet] and [CryptoCurrencyStatus] to [MyPortfolioUM.Tokens] + * +[REDACTED_AUTHOR] + */ +@Suppress("LongParameterList") +internal class TokensPortfolioUMConverter( + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val addButtonState: MyPortfolioUM.Tokens.AddButtonState, + private val bsConfig: TangemBottomSheetConfig, + private val onAddClick: () -> Unit, + private val quickActionsIntents: TokenActionsHandler, + private val currentState: Provider, + private val updateTokens: ((ImmutableList) -> ImmutableList) -> Unit, +) : Converter>, MyPortfolioUM.Tokens> { + + override fun convert(value: Map>): MyPortfolioUM.Tokens { + val currentTokensState = currentState() as? MyPortfolioUM.Tokens + + return MyPortfolioUM.Tokens( + tokens = value + .flatMap { entry -> entry.value } + .map { cryptoData -> + PortfolioTokenUMConverter( + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + onTokenItemClick = { toggleQuickActions(cryptoData) }, + tokenActionsHandler = quickActionsIntents, + ).convert(value = cryptoData) to cryptoData + } + .setQuickActionsVisibility(currentState = currentTokensState) + .toImmutableList(), + buttonState = addButtonState, + addToPortfolioBSConfig = bsConfig, + onAddClick = onAddClick, + tokenReceiveBSConfig = (currentState() as? MyPortfolioUM.Tokens) + ?.tokenReceiveBSConfig + ?: TangemBottomSheetConfig.Empty, + ) + } + + private fun List>.setQuickActionsVisibility( + currentState: MyPortfolioUM.Tokens?, + ): List { + return when { + // if there is only one token and it has empty balance, show quick actions for it + currentState == null && this.size == 1 && isEmptyBalance(this.first().second) -> { + this.map { (token, _) -> + token.copy(isQuickActionsShown = true) + } + } + // if there is no previous state, hide quick actions for all tokens + currentState == null -> { + this.map { (token, _) -> + token.copy(isQuickActionsShown = false) + } + } + else -> { + val previousList = currentState.tokens + + // otherwise, keep previous state + this.map { (token, _) -> + token.copy( + isQuickActionsShown = previousList + .firstOrNull { it.matchWith(token) } + ?.isQuickActionsShown == true, + ) + } + } + } + } + + private fun isEmptyBalance(cryptoData: PortfolioData.CryptoCurrencyData): Boolean { + return cryptoData.status.value.amount?.isZero() == true + } + + private fun toggleQuickActions(cryptoData: PortfolioData.CryptoCurrencyData) { + updateTokens { tokenList -> + tokenList.map { portfolioTokenUM -> + portfolioTokenUM.copy( + isQuickActionsShown = if (portfolioTokenUM.matchWith(cryptoData)) { + !portfolioTokenUM.isQuickActionsShown + } else { + false + }, + ) + }.toImmutableList() + } + } + + private fun PortfolioTokenUM.matchWith(token: PortfolioTokenUM): Boolean { + return this.walletId == token.walletId && this.tokenItemState.id == token.tokenItemState.id + } + + private fun PortfolioTokenUM.matchWith(cryptoData: PortfolioData.CryptoCurrencyData): Boolean { + return this.walletId == cryptoData.userWallet.walletId && + this.tokenItemState.id == cryptoData.status.currency.id.value + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/AddToPortfolioBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/AddToPortfolioBottomSheet.kt new file mode 100644 index 0000000000..16993ea52d --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/AddToPortfolioBottomSheet.kt @@ -0,0 +1,383 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +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.util.fastForEachIndexed +import com.tangem.common.ui.userwallet.UserWalletItem +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.buttons.common.TangemButton +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.buttons.common.TangemButtonSize +import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults +import com.tangem.core.ui.components.currency.icon.CoinIcon +import com.tangem.core.ui.components.rows.ArrowRow +import com.tangem.core.ui.components.rows.BlockchainRow +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewAddToPortfolioBSContentProvider +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM +import com.tangem.features.feed.impl.R +import kotlinx.coroutines.delay + +@Composable +internal fun AddToPortfolioBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.tertiary, + addBottomInsets = false, + titleText = resourceReference(R.string.common_add_to_portfolio), + ) { contentUM -> + Content( + modifier = Modifier.fillMaxWidth(), + state = contentUM, + ) + + WalletSelectorBottomSheet(contentUM.walletSelectorConfig) + } +} + +@Composable +private fun Content(state: AddToPortfolioBSContentUM, modifier: Modifier = Modifier) { + var continueButtonAreaHeight by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + val scrollState = rememberScrollState() + + Box(modifier = modifier) { + Column( + modifier = Modifier + .verticalScroll(state = scrollState) + .padding(horizontal = TangemTheme.dimens.spacing16), + ) { + if (state.isWalletBlockVisible) { + UserWalletItem( + state = state.selectedWallet, + blockColors = TangemBlockCardColors.copy( + containerColor = TangemTheme.colors.background.action, + disabledContainerColor = TangemTheme.colors.background.action, + ), + ) + SpacerH12() + } + + NetworkSelection( + modifier = Modifier.fillMaxWidth(), + state = state.selectNetworkUM, + ) + + SpacerH12() + + AnimatedVisibility( + visible = state.isScanCardNotificationVisible, + modifier = Modifier.fillMaxWidth(), + ) { + Column { + ScanWalletWarning(modifier = Modifier.fillMaxWidth()) + SpacerH12() + } + + // Scroll to the bottom when the notification appears and the scroll is at the bottom + LaunchedEffect(Unit) { + if (scrollState.canScrollForward.not()) { + delay(timeMillis = 500) + scrollState.animateScrollTo(scrollState.maxValue) + } + } + } + + SpacerH(with(density) { continueButtonAreaHeight.toDp() }) + } + + AnimatedVisibility( + visible = scrollState.canScrollForward, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + BottomFade(Modifier.align(Alignment.BottomCenter)) + } + + ContinueButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .onGloballyPositioned { + continueButtonAreaHeight = it.size.height + }, + enabled = state.isContinueButtonEnabled, + isTangemIconVisible = state.isScanCardNotificationVisible, + onClick = state.onContinueButtonClick, + ) + } +} + +@Composable +private fun ContinueButton( + enabled: Boolean, + isTangemIconVisible: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TangemButton( + enabled = enabled, + modifier = modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ) + .navigationBarsPadding() + .fillMaxWidth(), + text = stringResourceSafe(R.string.common_continue), + icon = if (enabled && isTangemIconVisible) { + TangemButtonIconPosition.End(R.drawable.ic_tangem_24) + } else { + TangemButtonIconPosition.None + }, + showProgress = false, + size = TangemButtonSize.Default, + colors = TangemButtonsDefaults.primaryButtonColors, + textStyle = TangemTheme.typography.subtitle1, + onClick = onClick, + animateContentChange = true, + ) +} + +@Suppress("LongMethod") +@Composable +private fun NetworkSelection(state: SelectNetworkUM, modifier: Modifier = Modifier) { + val hapticManager = LocalHapticManager.current + + InformationBlock( + modifier = modifier, + title = { + Text( + text = stringResourceSafe(R.string.markets_select_network), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + }, + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens.spacing14), + verticalAlignment = Alignment.CenterVertically, + ) { + CoinIcon( + modifier = Modifier.size(TangemTheme.dimens.size36), + url = state.iconUrl, + alpha = 1f, + colorFilter = null, + fallbackResId = R.drawable.ic_custom_token_44, + ) + SpacerW12() + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .weight(1f, fill = false) + .alignByBaseline(), + text = state.tokenName, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + SpacerW6() + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .alignByBaseline(), + text = state.tokenCurrencySymbol, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.tertiary, + overflow = TextOverflow.Visible, + maxLines = 1, + ) + } + + state.networks.fastForEachIndexed { index, network -> + ArrowRow( + isLastItem = index == state.networks.lastIndex, + content = { + BlockchainRow( + modifier = Modifier.padding(end = TangemTheme.dimens.spacing4), + model = network, + action = { + TangemSwitch( + checked = network.isSelected, + checkedColor = if (network.isEnabled) { + TangemTheme.colors.control.checked + } else { + TangemTheme.colors.icon.inactive + }, + onCheckedChange = { checked -> + if (checked) { + hapticManager.perform(TangemHapticEffect.View.ToggleOn) + } else { + hapticManager.perform(TangemHapticEffect.View.ToggleOff) + } + + state.onNetworkSwitchClick(network, checked) + }, + enabled = network.isEnabled, + ) + }, + ) + }, + ) + } + } + } +} + +@Composable +private fun ScanWalletWarning(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .background( + color = TangemTheme.colors.button.disabled, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .padding(TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing10), + ) { + Icon( + modifier = Modifier.requiredSize(TangemTheme.dimens.size20), + imageVector = ImageVector.vectorResource(R.drawable.ic_tangem_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + Text( + text = stringResourceSafe(R.string.markets_generate_addresses_notification), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview { + AddToPortfolioBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + content = content, + onDismissRequest = {}, + ), + ) + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PreviewContent( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview { + Content( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .fillMaxWidth(), + state = content, + ) + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PreviewContentRtl( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview(rtl = true) { + Content( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .fillMaxWidth(), + state = content, + ) + } +} + +// For on device testing +@Composable +@Preview +private fun PreviewContentTestOnDevice( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview( + alwaysShowBottomSheets = false, + ) { + var isShow by remember { mutableStateOf(false) } + + var contentState by remember { + mutableStateOf(content) + } + + LaunchedEffect(Unit) { + contentState = content.copy( + onContinueButtonClick = { + contentState = contentState.copy( + isScanCardNotificationVisible = !contentState.isScanCardNotificationVisible, + ) + }, + isContinueButtonEnabled = true, + selectedWallet = content.selectedWallet.copy( + onClick = { + contentState = contentState.copy( + isContinueButtonEnabled = !contentState.isContinueButtonEnabled, + ) + }, + ), + ) + } + + AddToPortfolioBottomSheet( + config = TangemBottomSheetConfig( + isShown = isShow, + content = contentState, + onDismissRequest = { isShow = false }, + ), + ) + + Button( + onClick = { isShow = !isShow }, + ) { + Text(text = "Toggle") + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/MyPortfolio.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/MyPortfolio.kt new file mode 100644 index 0000000000..7ff006cdb9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/MyPortfolio.kt @@ -0,0 +1,341 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.common.ui.account.AccountTitle +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SmallButtonShimmer +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewMyPortfolioUMProvider +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.* +import com.tangem.features.feed.impl.R + +@Composable +internal fun MyPortfolio(state: MyPortfolioUM, modifier: Modifier = Modifier) { + if (state is MyPortfolioUM.Content) { + val contentModifier = Modifier.padding( + start = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing20, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing32, + ) + PortfolioList(state, contentModifier) + return + } + InformationBlock( + modifier = modifier, + contentHorizontalPadding = TangemTheme.dimens.spacing0, + title = { Title() }, + action = { + if (state !is MyPortfolioUM.Tokens) return@InformationBlock + + AddButton(state = state.buttonState, onClick = state.onAddClick) + }, + ) { + val contentModifier = Modifier.padding( + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing12, + ) + + when (state) { + is MyPortfolioUM.Tokens -> TokenList(state = state) + is MyPortfolioUM.AddFirstToken -> AddFirstTokenContent(state = state, modifier = contentModifier) + MyPortfolioUM.Loading -> LoadingPlaceholder(modifier = contentModifier) + MyPortfolioUM.Unavailable -> UnavailableAsset(modifier = contentModifier) + MyPortfolioUM.UnavailableForWallet -> UnavailableAssetForWallet(modifier = contentModifier) + is MyPortfolioUM.Content -> PortfolioList(state = state) + } + } + + val bsConfig = state.addToPortfolioBSConfig + if (bsConfig != null) { + AddToPortfolioBottomSheet(config = bsConfig) + } +} + +@Composable +private fun Title() { + Text( + text = stringResourceSafe(R.string.markets_common_my_portfolio), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) +} + +@Composable +private fun AddButton(state: MyPortfolioUM.Tokens.AddButtonState, onClick: () -> Unit) { + when (state) { + MyPortfolioUM.Tokens.AddButtonState.Loading -> { + Box { + SmallButtonShimmer( + modifier = Modifier.width(width = TangemTheme.dimens.size63), + shape = RoundedCornerShape(TangemTheme.dimens.radius3), + withIcon = true, + ) + + Box( + Modifier + .matchParentSize() + .background(TangemTheme.colors.background.action), + ) + + RectangleShimmer( + modifier = Modifier + .align(Alignment.Center) + .size(width = TangemTheme.dimens.size63, height = TangemTheme.dimens.size18), + radius = TangemTheme.dimens.radius3, + ) + } + } + MyPortfolioUM.Tokens.AddButtonState.Available, + MyPortfolioUM.Tokens.AddButtonState.Unavailable, + -> { + SecondarySmallButton( + config = SmallButtonConfig( + text = resourceReference(R.string.markets_add_token), + icon = TangemButtonIconPosition.Start(R.drawable.ic_plus_24), + onClick = onClick, + isEnabled = state == MyPortfolioUM.Tokens.AddButtonState.Available, + ), + ) + } + } +} + +@Composable +private fun TokenList(state: MyPortfolioUM.Tokens, modifier: Modifier = Modifier) { + Column(modifier) { + state.tokens.fastForEachIndexed { index, token -> + key(token.tokenItemState.id) { + PortfolioItem( + modifier = Modifier.background(color = TangemTheme.colors.background.action), + state = token, + lastInList = index == state.tokens.size - 1, + ) + } + } + } + + TokenReceiveBottomSheet(config = state.tokenReceiveBSConfig) +} + +@Composable +private fun PortfolioList(state: MyPortfolioUM.Content, modifier: Modifier = Modifier) { + Column(modifier) { + key("PortfolioListHeader") { + Row( + modifier = Modifier.padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResourceSafe(R.string.markets_common_my_portfolio), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + AddButton(state = state.buttonState, onClick = state.onAddClick) + } + } + + state.items.fastForEachIndexed { index, item -> + val previousItem = state.items.getOrNull(index.dec()) + val nextItem = state.items.getOrNull(index.inc()) + val itemModifier = Modifier + .fillMaxWidth() + .getOffsetModifier(item, previousItem) + .getBackgroundModifier(item, previousItem, nextItem) + + key(item.id) { + PortfolioItem( + item = item, + modifier = itemModifier, + lastInList = index == state.items.size - 1, + ) + } + } + } +} + +@Composable +private fun Modifier.getBackgroundModifier( + item: PortfolioListItem, + previousItem: PortfolioListItem?, + nextItem: PortfolioListItem?, +): Modifier { + val color = TangemTheme.colors.background.action + val radius = 14.dp + val topRound = RoundedCornerShape(topStart = radius, topEnd = radius) + val bottomRound = RoundedCornerShape(bottomStart = radius, bottomEnd = radius) + val allRound = RoundedCornerShape(size = radius) + val backgroundModifier = when (item) { + is WalletHeader -> this + is PortfolioHeader -> this + .clip(topRound) + .background(color = color) + is PortfolioTokenUM -> when { + previousItem is PortfolioHeader && nextItem !is PortfolioTokenUM -> this + .clip(bottomRound) + .background(color = color) + previousItem is WalletHeader && nextItem !is PortfolioTokenUM -> this + .clip(allRound) + .background(color = color) + previousItem is PortfolioTokenUM && nextItem !is PortfolioTokenUM -> this + .clip(bottomRound) + .background(color = color) + else -> this.background(color = color) + } + } + return backgroundModifier +} + +private fun Modifier.getOffsetModifier(item: PortfolioListItem, previousItem: PortfolioListItem?): Modifier = when { + item is WalletHeader -> this.padding(top = 20.dp, start = 4.dp, end = 4.dp) + item is PortfolioHeader && previousItem is PortfolioTokenUM -> this.padding(top = 12.dp) + item is PortfolioHeader && previousItem == null -> this.padding(top = 20.dp) + previousItem is WalletHeader -> this.padding(top = 12.dp) + previousItem is PortfolioTokenUM -> this + else -> this +} + +@Composable +private fun PortfolioItem(item: PortfolioListItem, lastInList: Boolean, modifier: Modifier = Modifier) { + when (item) { + is PortfolioHeader -> AccountTitle( + modifier = modifier.padding( + start = 12.dp, + top = 12.dp, + bottom = 8.dp, + ), + accountTitleUM = item.state, + textStyle = TangemTheme.typography.caption1, + textColor = TangemTheme.colors.text.primary1, + ) + is PortfolioTokenUM -> PortfolioItem( + state = item, + modifier = modifier, + lastInList = lastInList, + ) + is WalletHeader -> Text( + modifier = modifier, + text = item.name.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + } +} + +@Composable +fun UnavailableAsset(modifier: Modifier = Modifier) { + UnavailableContent( + textId = R.string.markets_add_to_my_portfolio_unavailable_description, + modifier = modifier, + ) +} + +@Composable +fun UnavailableAssetForWallet(modifier: Modifier = Modifier) { + UnavailableContent( + textId = R.string.markets_add_to_my_portfolio_unavailable_for_wallet_description, + modifier = modifier, + ) +} + +@Composable +private fun UnavailableContent(@StringRes textId: Int, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = stringResourceSafe(textId), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) +} + +@Composable +private fun AddFirstTokenContent(state: MyPortfolioUM.AddFirstToken, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Text( + text = stringResourceSafe(R.string.markets_add_to_my_portfolio_description), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_add_to_portfolio), + onClick = state.onAddClick, + ) + } +} + +@Composable +private fun LoadingPlaceholder(modifier: Modifier = Modifier) { + Column(modifier = modifier) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + + TextShimmer( + modifier = Modifier.fillMaxWidth(fraction = 0.7f), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state: MyPortfolioUM) { + TangemThemePreview { + Box( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary), + ) { + MyPortfolio(state) + } + } +} + +@Preview +@Composable +private fun PreviewRtl(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state: MyPortfolioUM) { + TangemThemePreview(rtl = true) { + Box( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .padding(TangemTheme.dimens.spacing8), + ) { + MyPortfolio(state) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioItem.kt new file mode 100644 index 0000000000..9faba4479e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioItem.kt @@ -0,0 +1,153 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.icons.IconTint +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewMyPortfolioUMProvider +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.features.feed.impl.R +import com.tangem.utils.StringsSigns.DASH_SIGN +import kotlinx.collections.immutable.persistentListOf +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState as TokenFiatAmountState + +@Composable +internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifier: Modifier = Modifier) { + Column(modifier) { + val hapticManager = LocalHapticManager.current + val tokenItemState = remember(state.tokenItemState) { + when (state.tokenItemState) { + is TokenItemState.Content -> state.tokenItemState.copy( + onItemClick = { tokenItemState -> + val onClick = state.tokenItemState.onItemClick + if (onClick != null) { + hapticManager.perform(TangemHapticEffect.View.ContextClick) + onClick.invoke(tokenItemState) + } + }, + ) + else -> state.tokenItemState + } + } + TokenItem( + state = tokenItemState, + isBalanceHidden = state.isBalanceHidden, + itemPaddingValues = PaddingValues( + start = TangemTheme.dimens.spacing10, + end = TangemTheme.dimens.spacing12, + ), + ) + + PortfolioQuickActions( + modifier = Modifier + .padding( + bottom = if (lastInList) { + TangemTheme.dimens.spacing12 + } else { + TangemTheme.dimens.spacing24 + }, + ), + actions = state.quickActions.actions, + isVisible = state.isQuickActionsShown, + onActionClick = state.quickActions.onQuickActionClick, + onActionLongClick = state.quickActions.onQuickActionLongClick, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(PortfolioTokenUMProvider::class) tokenUM: PortfolioTokenUM) { + TangemThemePreview { + var isQuickActionsShown by remember { mutableStateOf(value = false) } + + val onItemClick = { + isQuickActionsShown = isQuickActionsShown.not() + } + + PortfolioItem( + modifier = Modifier.background(color = TangemTheme.colors.background.action), + state = tokenUM.copy( + tokenItemState = when (tokenUM.tokenItemState) { + is TokenItemState.Content -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() }) + is TokenItemState.Unreachable -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() }) + else -> tokenUM.tokenItemState + }, + isQuickActionsShown = isQuickActionsShown, + ), + lastInList = true, + ) + } +} + +private class PortfolioTokenUMProvider : CollectionPreviewParameterProvider( + collection = listOf( + tokenUM.copy( + tokenItemState = (tokenUM.tokenItemState as TokenItemState.Content).copy( + fiatAmountState = contentFiatAmount?.copy( + icons = persistentListOf( + TokenFiatAmountState.Content.IconUM( + iconRes = R.drawable.ic_staking_24, + tint = IconTint.Accent, + ), + ), + ), + ), + ), + tokenUM.copy( + tokenItemState = tokenUM.tokenItemState.copy( + fiatAmountState = contentFiatAmount?.copy(text = DASH_SIGN), + subtitle2State = (tokenUM.tokenItemState.subtitle2State as? TokenItemState.Subtitle2State.TextContent) + ?.copy(text = DASH_SIGN), + ), + ), + tokenUM.copy(isBalanceHidden = true), + tokenUM.copy( + tokenItemState = TokenItemState.Unreachable( + id = tokenUM.tokenItemState.id, + iconState = tokenUM.tokenItemState.iconState, + titleState = tokenUM.tokenItemState.titleState, + subtitleState = tokenUM.tokenItemState.subtitleState, + onItemClick = {}, + onItemLongClick = {}, + ), + ), + tokenUM.copy( + tokenItemState = TokenItemState.NoAddress( + id = tokenUM.tokenItemState.id, + iconState = tokenUM.tokenItemState.iconState, + titleState = tokenUM.tokenItemState.titleState, + subtitleState = tokenUM.tokenItemState.subtitleState, + onItemLongClick = {}, + ), + ), + tokenUM.copy( + tokenItemState = TokenItemState.Loading( + id = tokenUM.tokenItemState.id, + iconState = tokenUM.tokenItemState.iconState, + titleState = tokenUM.tokenItemState.titleState as TokenItemState.TitleState.Content, + subtitleState = tokenUM.tokenItemState.subtitleState, + ), + ), + ), +) { + + companion object { + val tokenUM = PreviewMyPortfolioUMProvider().sampleToken + val contentFiatAmount = tokenUM.tokenItemState.fiatAmountState as? TokenFiatAmountState.Content + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt new file mode 100644 index 0000000000..ffed264514 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt @@ -0,0 +1,268 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.SpacerH4 +import com.tangem.core.ui.components.icons.badge.drawBadge +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun PortfolioQuickActions( + actions: ImmutableList, + isVisible: Boolean, + onActionClick: (QuickActionUM) -> Unit, + onActionLongClick: (QuickActionUM) -> Unit, + modifier: Modifier = Modifier, +) { + if (actions.isEmpty()) return + + AnimatedVisibility( + modifier = modifier, + visible = isVisible, + enter = expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top), + ) { + Column { + actions.fastForEach { action -> + LineSeparator() + QuickActionItem( + state = action, + onClick = { onActionClick(action) }, + onLongClick = { onActionLongClick(action) }.takeIf { action.isLongClickAvailable }, + ) + } + } + } +} + +@Composable +private fun AnimatedVisibilityScope.LineSeparator(modifier: Modifier = Modifier) { + val lineColor = TangemTheme.colors.stroke.primary + val strokeWidth = TangemTheme.dimens.size1 + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val startPadding = TangemTheme.dimens.spacing30 + + val height = TangemTheme.dimens.size16 + + Canvas( + modifier = modifier + .animateEnterExit( + enter = expandVertically( + animationSpec = spring( + stiffness = Spring.StiffnessLow, + ), + expandFrom = Alignment.Top, + ) + fadeIn(), + exit = shrinkVertically( + spring( + stiffness = Spring.StiffnessLow, + ), + shrinkTowards = Alignment.Top, + ) + fadeOut(), + ) + .fillMaxWidth() + .height(height), + ) { + val x = if (isLtr) startPadding.toPx() else size.width - startPadding.toPx() + + drawLine( + color = lineColor, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = strokeWidth.toPx(), + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun AnimatedVisibilityScope.QuickActionItem( + state: QuickActionUM, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val hapticManager = LocalHapticManager.current + val onLongClickInternal: (() -> Unit)? = if (onLongClick != null) { + { + hapticManager.perform(TangemHapticEffect.View.LongPress) + onLongClick() + } + } else { + null + } + + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onLongClick = onLongClickInternal, + onClick = { + hapticManager.perform(TangemHapticEffect.View.SegmentTick) + onClick() + }, + ) + .padding(horizontal = TangemTheme.dimens.spacing14, vertical = TangemTheme.dimens.spacing4), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing18), + ) { + QuickActionIcon(state) + Column( + modifier = Modifier + .animateEnterExit( + enter = fadeIn(), + exit = fadeOut(), + ), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2), + ) { + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = state.description.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } +} + +@Composable +private fun AnimatedVisibilityScope.QuickActionIcon(state: QuickActionUM) { + val containerColor = TangemTheme.colors.background.action + Box( + Modifier + .animateEnterExit( + enter = scaleIn(), + exit = scaleOut(), + ) + .background( + color = TangemTheme.colors.button.secondary, + shape = CircleShape, + ) + .size(TangemTheme.dimens.size32) + .drawWithContent { + drawContent() + if (state is QuickActionUM.Exchange && state.shouldShowBadge) { + drawBadge(containerColor = containerColor, offset = 4.dp) + } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier + .requiredSize(TangemTheme.dimens.size16), + imageVector = ImageVector.vectorResource(id = state.icon), + contentDescription = null, + tint = TangemTheme.colors.button.primary, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + var isVisible by remember { mutableStateOf(true) } + + Column( + modifier = Modifier + .fillMaxWidth() + .height(680.dp), + ) { + Button( + onClick = { isVisible = !isVisible }, + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + ) { + Text(text = "Toggle") + } + SpacerH4() + Box( + modifier = Modifier.background(color = TangemTheme.colors.background.action), + ) { + PortfolioQuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(shouldShowBadge = true), + QuickActionUM.Receive, + ), + isVisible = isVisible, + onActionClick = {}, + onActionLongClick = {}, + ) + } + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewRtl() { + TangemThemePreview(rtl = true) { + Box(modifier = Modifier.background(color = TangemTheme.colors.background.action)) { + PortfolioQuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(shouldShowBadge = true), + QuickActionUM.Receive, + ), + isVisible = true, + onActionClick = {}, + onActionLongClick = {}, + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/TokenActionsBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/TokenActionsBottomSheet.kt new file mode 100644 index 0000000000..7b4a266b9e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/TokenActionsBottomSheet.kt @@ -0,0 +1,86 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.SimpleSettingsRow +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun TokenActionsBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + title = { content -> + TangemBottomSheetTitle(content.title) + }, + containerColor = TangemTheme.colors.background.tertiary, + content = { Content(it) }, + ) +} + +@Composable +private fun Content(content: TokenActionsBSContentUM) { + Column( + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) { + content.actions.forEachIndexed { index, action -> + Box( + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = content.actions.lastIndex, + addDefaultPadding = false, + ) + .background(TangemTheme.colors.background.action), + ) { + SimpleSettingsRow( + title = action.text.resolveReference(), + icon = action.iconRes, + redesign = true, + onItemsClick = { content.onActionClick(action) }, + ) + } + } + } +} + +@Preview(widthDp = 360, heightDp = 640) +@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview( + alwaysShowBottomSheets = true, + ) { + Box(Modifier.background(TangemTheme.colors.background.secondary)) { + TokenActionsBottomSheet( + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = TokenActionsBSContentUM( + title = "Wallet 1", + actions = TokenActionsBSContentUM.Action.entries.toImmutableList(), + onActionClick = {}, + ), + ), + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/WalletSelectorBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/WalletSelectorBottomSheet.kt new file mode 100644 index 0000000000..530786cf30 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/WalletSelectorBottomSheet.kt @@ -0,0 +1,145 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.common.ui.userwallet.UserWalletItem +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewAddToPortfolioBSContentProvider +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.WalletSelectorBSContentUM +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun WalletSelectorBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.tertiary, + addBottomInsets = false, + title = { content -> + TangemTopAppBar( + title = resourceReference(R.string.common_choose_wallet), + titleAlignment = Alignment.CenterHorizontally, + startButton = TopAppBarButtonUM.Back(content.onBack), + height = TangemTopAppBarHeight.BOTTOM_SHEET, + ) + }, + ) { content -> + Content( + modifier = Modifier + .fillMaxSize() + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing8, + ), + state = content, + ) + } +} + +@Composable +private fun Content(state: WalletSelectorBSContentUM, modifier: Modifier = Modifier) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + + Column( + modifier = modifier + .verticalScroll(rememberScrollState()), + ) { + BlockCard( + modifier = Modifier.fillMaxSize(), + colors = TangemBlockCardColors.copy( + containerColor = TangemTheme.colors.background.action, + disabledContainerColor = TangemTheme.colors.background.action, + ), + ) { + state.userWallets.forEach { state -> + key(state.id) { + UserWalletItem( + modifier = Modifier.fillMaxWidth(), + blockColors = TangemBlockCardColors.copy( + containerColor = TangemTheme.colors.background.action, + disabledContainerColor = TangemTheme.colors.background.action, + ), + state = state, + ) + } + } + } + SpacerH(bottomBarHeight) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + WalletSelectorBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = WalletSelectorBSContentUM( + userWallets = persistentListOf( + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.Checkmark, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + ), + onBack = {}, + ), + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewContent() { + TangemThemePreview { + Content( + state = WalletSelectorBSContentUM( + userWallets = persistentListOf( + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.Checkmark, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + ), + onBack = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt new file mode 100644 index 0000000000..4de442ca27 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt @@ -0,0 +1,86 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.persistentListOf + +internal class PreviewAddToPortfolioBSContentProvider : PreviewParameterProvider { + + private val blockchainRow = BlockchainRowUM( + id = "1", + name = "Etherium 3", + type = "TEST", + iconResId = R.drawable.ic_eth_16, + isMainNetwork = false, + isSelected = false, + ) + + val userWallet = UserWalletItemUM( + id = "1", + name = stringReference("Wallet 1"), + information = UserWalletItemUM.Information.Loaded(TextReference.Str("3 cards")), + balance = UserWalletItemUM.Balance.Loading, + isEnabled = true, + endIcon = UserWalletItemUM.EndIcon.Arrow, + onClick = {}, + ) + + override val values: Sequence + get() = sequenceOf( + AddToPortfolioBSContentUM( + selectedWallet = userWallet, + selectNetworkUM = SelectNetworkUM( + tokenId = "etherium", + tokenName = "Etherium", + tokenCurrencySymbol = "ETH", + networks = persistentListOf( + blockchainRow.copy( + type = "MAIN", + isMainNetwork = true, + isSelected = true, + ), + blockchainRow, + blockchainRow, + ), + onNetworkSwitchClick = { _, _ -> }, + iconUrl = null, + ), + isScanCardNotificationVisible = true, + isWalletBlockVisible = true, + isContinueButtonEnabled = true, + onContinueButtonClick = {}, + walletSelectorConfig = TangemBottomSheetConfig.Empty, + ), + AddToPortfolioBSContentUM( + selectedWallet = userWallet, + selectNetworkUM = SelectNetworkUM( + tokenId = "etherium", + tokenName = "Etherium Etherium Etherium Etherium", + tokenCurrencySymbol = "ETH", + networks = persistentListOf( + blockchainRow.copy( + type = "MAIN", + isMainNetwork = true, + isSelected = true, + ).copy(name = "Etherium Etherium Etherium Etherium"), + *Array(25) { blockchainRow }, + ), + + onNetworkSwitchClick = { _, _ -> }, + iconUrl = null, + ), + isScanCardNotificationVisible = true, + isWalletBlockVisible = false, + isContinueButtonEnabled = false, + onContinueButtonClick = {}, + walletSelectorConfig = TangemBottomSheetConfig.Empty, + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt new file mode 100644 index 0000000000..3a40be94b8 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt @@ -0,0 +1,150 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.common.ui.account.AccountIconPreviewData +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.* +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +@Suppress("PropertyUsedBeforeDeclaration") +internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider { + + override val values: Sequence + get() = sequenceOf( + MyPortfolioUM.Tokens( + tokens = persistentListOf(sampleToken, sampleToken), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Available, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + tokenReceiveBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.Tokens( + tokens = persistentListOf(sampleToken, sampleToken.copy(isQuickActionsShown = true)), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Unavailable, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + tokenReceiveBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.Tokens( + tokens = persistentListOf(sampleToken.copy(isQuickActionsShown = true), sampleToken), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Loading, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + tokenReceiveBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.AddFirstToken( + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.Content( + items = persistentListOf( + walletPortfolioHeader, + accountToken, + accountToken, + ), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Available, + onAddClick = {}, + ), + MyPortfolioUM.Content( + items = persistentListOf( + walletHeader, + accountHeader, + accountToken, + accountToken, + ), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Available, + onAddClick = {}, + ), + MyPortfolioUM.Content( + items = persistentListOf( + walletHeader, + accountHeader, + accountToken.copy(isQuickActionsShown = true), + accountToken, + ), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Available, + onAddClick = {}, + ), + MyPortfolioUM.Loading, + MyPortfolioUM.Unavailable, + MyPortfolioUM.UnavailableForWallet, + ) + + val walletHeader + get() = WalletHeader( + id = UUID.randomUUID().toString(), + name = stringReference("Wallet 1"), + ) + + val walletPortfolioHeader + get() = PortfolioHeader( + state = AccountTitleUM.Text(title = stringReference("Wallet 1")), + id = UUID.randomUUID().toString(), + ) + + val accountHeader + get() = PortfolioHeader( + state = AccountTitleUM.Account( + icon = AccountIconPreviewData.randomAccountIcon(), + name = stringReference("Main Account"), + prefixText = TextReference.EMPTY, + ), + id = UUID.randomUUID().toString(), + ) + val coinIconState + get() = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = com.tangem.core.ui.R.drawable.img_polygon_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ) + val accountToken + get() = sampleToken.copy( + tokenItemState = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = coinIconState, + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Polygon")), + fiatAmountState = FiatAmountState.Content(text = "321 $"), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "5,412 MATIC"), + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(value = "Token")), + onItemClick = {}, + onItemLongClick = {}, + ), + ) + + val sampleToken + get() = PortfolioTokenUM( + tokenItemState = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = CurrencyIconState.Locked, + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "My wallet")), + fiatAmountState = FiatAmountState.Content(text = "486,65 \$"), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "733,71097 MATIC"), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = stringReference(value = "XRP Ledger token"), + ), + onItemClick = {}, + onItemLongClick = {}, + ), + isQuickActionsShown = false, + quickActions = PortfolioTokenUM.QuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(shouldShowBadge = true), + QuickActionUM.Receive, + ), + onQuickActionClick = {}, + onQuickActionLongClick = {}, + ), + isBalanceHidden = false, + walletId = UserWalletId(""), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/AddToPortfolioBSContentUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/AddToPortfolioBSContentUM.kt new file mode 100644 index 0000000000..11e574c9e7 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/AddToPortfolioBSContentUM.kt @@ -0,0 +1,15 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent + +internal data class AddToPortfolioBSContentUM( + val selectedWallet: UserWalletItemUM, + val selectNetworkUM: SelectNetworkUM, + val isWalletBlockVisible: Boolean, + val isScanCardNotificationVisible: Boolean, + val isContinueButtonEnabled: Boolean, + val onContinueButtonClick: () -> Unit, + val walletSelectorConfig: TangemBottomSheetConfig, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/MyPortfolioUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/MyPortfolioUM.kt new file mode 100644 index 0000000000..010f7023cf --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/MyPortfolioUM.kt @@ -0,0 +1,52 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class MyPortfolioUM { + + abstract val addToPortfolioBSConfig: TangemBottomSheetConfig? + + data class Tokens( + override val addToPortfolioBSConfig: TangemBottomSheetConfig, + val tokens: ImmutableList, + val buttonState: AddButtonState, + val tokenReceiveBSConfig: TangemBottomSheetConfig, + val onAddClick: () -> Unit, + ) : MyPortfolioUM() { + + enum class AddButtonState { + Loading, + Available, + Unavailable, + } + } + + data class Content( + val items: ImmutableList, + val buttonState: Tokens.AddButtonState, + val onAddClick: () -> Unit, + ) : MyPortfolioUM() { + + override val addToPortfolioBSConfig: TangemBottomSheetConfig = TangemBottomSheetConfig.Empty + } + + data class AddFirstToken( + override val addToPortfolioBSConfig: TangemBottomSheetConfig, + val onAddClick: () -> Unit, + ) : MyPortfolioUM() + + data object Loading : MyPortfolioUM() { + override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null + } + + data object Unavailable : MyPortfolioUM() { + override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null + } + + data object UnavailableForWallet : MyPortfolioUM() { + override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/PortfolioTokenUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/PortfolioTokenUM.kt new file mode 100644 index 0000000000..c4817c30c2 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/PortfolioTokenUM.kt @@ -0,0 +1,39 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed interface PortfolioListItem { + val id: String +} + +internal data class WalletHeader( + override val id: String, + val name: TextReference, +) : PortfolioListItem + +internal data class PortfolioHeader( + override val id: String, + val state: AccountTitleUM, +) : PortfolioListItem + +internal data class PortfolioTokenUM( + val tokenItemState: TokenItemState, + val walletId: UserWalletId, + val isBalanceHidden: Boolean, + val isQuickActionsShown: Boolean, + val quickActions: QuickActions, +) : PortfolioListItem { + override val id: String = tokenItemState.id + + data class QuickActions( + val actions: ImmutableList, + val onQuickActionClick: (QuickActionUM) -> Unit, + val onQuickActionLongClick: (QuickActionUM) -> Unit, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt new file mode 100644 index 0000000000..d9ba4e54b8 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt @@ -0,0 +1,51 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.features.feed.impl.R + +@Immutable +internal sealed class QuickActionUM( + val title: TextReference, + val description: TextReference, + @DrawableRes val icon: Int, + val isLongClickAvailable: Boolean = false, +) { + data object Buy : QuickActionUM( + title = resourceReference(R.string.common_buy), + description = resourceReference(R.string.buy_token_description), + icon = R.drawable.ic_plus_24, + ) + + data class Exchange( + val shouldShowBadge: Boolean, + ) : QuickActionUM( + title = resourceReference(R.string.common_exchange), + description = resourceReference(R.string.exсhange_token_description), + icon = R.drawable.ic_exchange_vertical_24, + ) + + data object Receive : QuickActionUM( + title = resourceReference(R.string.common_receive), + description = resourceReference(R.string.receive_token_description), + icon = R.drawable.ic_arrow_down_24, + isLongClickAvailable = true, + ) + + data object Stake : QuickActionUM( + title = resourceReference(R.string.common_stake), + description = resourceReference(R.string.stake_token_description), + icon = R.drawable.ic_staking_24, + ) + + data class YieldMode( + private val apy: String, + ) : QuickActionUM( + title = resourceReference(R.string.yield_module_start_earning), + description = resourceReference(R.string.yield_module_main_screen_promo_banner_message, wrappedList(apy)), + icon = R.drawable.ic_analytics_up_mini_24, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/SelectNetworkUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/SelectNetworkUM.kt new file mode 100644 index 0000000000..4ee09913a4 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/SelectNetworkUM.kt @@ -0,0 +1,13 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import kotlinx.collections.immutable.ImmutableList + +internal data class SelectNetworkUM( + val tokenId: String, + val iconUrl: String?, + val tokenName: String, + val tokenCurrencySymbol: String, + val networks: ImmutableList, + val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt new file mode 100644 index 0000000000..b02f6a8266 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt @@ -0,0 +1,58 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.ImmutableList + +internal data class TokenActionsBSContentUM( + val title: String, + val actions: ImmutableList, + val onActionClick: (Action) -> Unit, +) : TangemBottomSheetConfigContent { + + @Immutable + enum class Action( + val text: TextReference, + @DrawableRes val iconRes: Int, + ) { + CopyAddress( + text = resourceReference(R.string.common_copy_address), + iconRes = R.drawable.ic_copy_24, + ), + Send( + text = resourceReference(R.string.common_send), + iconRes = R.drawable.ic_arrow_up_24, + ), + Receive( + text = resourceReference(R.string.common_receive), + iconRes = R.drawable.ic_arrow_down_24, + ), + Buy( + text = resourceReference(R.string.common_buy), + iconRes = R.drawable.ic_plus_24, + ), + Sell( + text = resourceReference(R.string.common_sell), + iconRes = R.drawable.ic_currency_24, + ), + Exchange( + text = resourceReference(R.string.common_exchange), + iconRes = R.drawable.ic_exchange_horizontal_24, + ), + Stake( + text = resourceReference(R.string.common_stake), + iconRes = R.drawable.ic_staking_24, + ), + YieldMode( + text = resourceReference(R.string.yield_module_start_earning), + iconRes = R.drawable.ic_analytics_up_mini_24, + ), + ; + + val order: Int = ordinal + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/WalletSelectorBSContentUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/WalletSelectorBSContentUM.kt new file mode 100644 index 0000000000..e93a65ddcd --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/WalletSelectorBSContentUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import kotlinx.collections.immutable.ImmutableList + +internal data class WalletSelectorBSContentUM( + val userWallets: ImmutableList, + val onBack: () -> Unit, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 966fedb289..91d996e6ea 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -16,8 +16,8 @@ import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.impl.R -import com.tangem.features.feed.ui.feed.state.* import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.ui.feed.state.* import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList @@ -30,6 +30,7 @@ import kotlinx.coroutines.launch import org.joda.time.DateTime import org.joda.time.DateTimeZone import javax.inject.Inject +import kotlin.collections.all @Stable @ModelScoped @@ -68,6 +69,11 @@ internal class FeedComponentModel @Inject constructor( TrendingNewsStateFactory( currentStateProvider = Provider { state.value }, onStateUpdate = { newState -> state.update { newState } }, + onRetryClicked = { + modelScope.launch(dispatchers.default) { + fetchTrendingNewsUseCase.invoke() + } + }, ) } @@ -88,14 +94,7 @@ internal class FeedComponentModel @Inject constructor( flow4 = manageTrendingNewsUseCase.observeTrendingNews(), ) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult -> updateMarketCharts(itemsByOrder, loadingStatesByOrder, errorStatesByOrder) - trendingNewsStateFactory.updateTrendingNewsState( - result = trendingNewsResult, - onRetryClicked = { - modelScope.launch(dispatchers.default) { - fetchTrendingNewsUseCase.invoke() - } - }, - ) + trendingNewsStateFactory.updateTrendingNewsState(result = trendingNewsResult) updateGlobalState() val currentSortType = state.value.marketChartConfig.currentSortByType val items = itemsByOrder[currentSortType] @@ -140,7 +139,11 @@ internal class FeedComponentModel @Inject constructor( onMarketItemClick = {}, onSortTypeClick = {}, ), - news = NewsUM.Loading, + news = NewsUM( + content = persistentListOf(), + onRetryClicked = {}, + newsUMState = NewsUMState.LOADING, + ), trendingArticle = null, marketChartConfig = MarketChartConfig( marketCharts = buildMap { @@ -224,10 +227,10 @@ internal class FeedComponentModel @Inject constructor( val newsState = currentState.news val marketCharts = currentState.marketChartConfig.marketCharts - val isNewsLoading = newsState is NewsUM.Loading + val isNewsLoading = newsState.newsUMState == NewsUMState.LOADING val areAllChartsLoading = marketCharts.values.all { it is MarketChartUM.Loading } - val isNewsError = newsState is NewsUM.Error + val isNewsError = newsState.newsUMState == NewsUMState.ERROR val areAllChartsError = marketCharts.values.all { it is MarketChartUM.LoadingError } val newGlobalState = when { 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 08e876be53..5f5943a741 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 @@ -29,6 +29,8 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.markets.* import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.news.model.NewsListConfig +import com.tangem.domain.news.usecase.GetNewsUseCase import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions @@ -38,6 +40,7 @@ import com.tangem.features.feed.impl.R import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.feed.model.market.details.converter.DescriptionConverter import com.tangem.features.feed.model.market.details.converter.ExchangeItemStateConverter +import com.tangem.features.feed.model.market.details.converter.RelatedNewsConverter import com.tangem.features.feed.model.market.details.converter.TokenMarketInfoConverter import com.tangem.features.feed.model.market.details.formatter.* import com.tangem.features.feed.model.market.details.state.QuotesStateUpdater @@ -49,6 +52,7 @@ import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -76,6 +80,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( private val getUserWalletsUseCase: GetWalletsUseCase, private val excludedBlockchains: ExcludedBlockchains, private val urlOpener: UrlOpener, + private val getNewsUseCase: GetNewsUseCase, ) : Model() { private val quotesJob = JobHolder() @@ -138,6 +143,10 @@ internal class MarketsTokenDetailsModel @Inject constructor( // ================== ) + private val relatedNewsConverter by lazy { + RelatedNewsConverter() + } + private val descriptionConverter = DescriptionConverter( onReadModeClicked = { content -> showBottomSheet(content) @@ -230,6 +239,12 @@ internal class MarketsTokenDetailsModel @Inject constructor( ), shouldShowPriceSubtitle = false, onShouldShowPriceSubtitleChange = ::onShouldShowPriceSubtitleChange, + relatedNews = MarketsTokenDetailsUM.RelatedNews( + articles = persistentListOf(), + onArticledClicked = { + // TODO in [REDACTED_JIRA] + }, + ), ), ) @@ -264,6 +279,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( } initialLoad() + loadRelatedNews() } private fun initialLoad() { @@ -286,6 +302,27 @@ internal class MarketsTokenDetailsModel @Inject constructor( } } + private fun loadRelatedNews() { + modelScope.launch(dispatchers.default) { + getNewsUseCase.getNews( + limit = RELATED_NEWS_LIMIT, + newsListConfig = NewsListConfig( + language = Locale.getDefault().language, + snapshot = null, + tokenIds = listOf(params.token.id.value), + ), + ).onRight { articles -> + state.update { marketsTokenDetailsUM -> + marketsTokenDetailsUM.copy( + relatedNews = marketsTokenDetailsUM.relatedNews.copy( + articles = relatedNewsConverter.convert(articles), + ), + ) + } + } + } + } + private fun loadChart(interval: PriceChangeInterval) { modelScope.launch { state.update { marketsTokenDetailsUM -> @@ -649,5 +686,6 @@ internal class MarketsTokenDetailsModel @Inject constructor( private companion object { const val QUOTES_UPDATE_INTERVAL_MILLIS = 60000L + const val RELATED_NEWS_LIMIT = 10 } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt new file mode 100644 index 0000000000..4a9b183de1 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt @@ -0,0 +1,84 @@ +package com.tangem.features.feed.model.market.details.converter + +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.FormattedDate +import com.tangem.core.ui.utils.getFormattedDate +import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.news.ShortArticle +import com.tangem.features.feed.impl.R +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toPersistentSet +import org.joda.time.DateTime + +class RelatedNewsConverter : Converter, ImmutableList> { + + override fun convert(value: List): ImmutableList { + return value.map { shortArticle -> + ArticleConfigUM( + id = shortArticle.id, + title = shortArticle.title, + score = shortArticle.score, + isTrending = false, + tags = buildArticleTags(shortArticle), + createdAt = mapFormattedDate(shortArticle.createdAt), + isViewed = shortArticle.viewed, + ) + }.toPersistentList() + } + + private fun buildArticleTags(article: ShortArticle): kotlinx.collections.immutable.ImmutableSet { + val categoryLabels = article.categories.map { category -> + LabelUM(text = TextReference.Str(category.name)) + } + val tokenLabels = article.relatedTokens.map { token -> + LabelUM( + text = TextReference.Str(token.symbol), + leadingContent = LabelLeadingContentUM.Token( + iconUrl = getTokenIconUrlFromDefaultHost( + tokenId = CryptoCurrency.RawID(token.id), + ), + ), + ) + } + return (categoryLabels + tokenLabels).toPersistentSet() + } + + private fun mapFormattedDate(createdAt: String): TextReference { + val formattedDate = getFormattedDate( + createdAt = createdAt, + now = DateTime.now(), + ) + return when (formattedDate) { + is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date) + is FormattedDate.HoursAgo -> TextReference.PluralRes( + id = R.plurals.news_published_hours_ago, + count = formattedDate.hours, + formatArgs = wrappedList(formattedDate.hours), + ) + is FormattedDate.MinutesAgo -> TextReference.PluralRes( + id = R.plurals.news_published_minutes_ago, + count = formattedDate.minutes, + formatArgs = wrappedList(formattedDate.minutes), + ) + is FormattedDate.Today -> TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(R.string.common_today), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str(formattedDate.time), + ), + ), + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt index 19e70cf231..323fe1b119 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt @@ -238,7 +238,7 @@ internal class MarketsListModel @Inject constructor( modelScope.launch { marketsListUMStateManager.isInSearchStateFlow.collectLatest { isInSearchMode -> - activeListManager = if (isInSearchMode) { + activeListManager = if (isInSearchMode || marketsListUMStateManager.searchQuery.isNotEmpty()) { searchMarketsListManager } else { searchMarketsListManager.clearStateAndStopAllActions() diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt index 950630f411..0aaf292083 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt @@ -1,9 +1,5 @@ package com.tangem.features.feed.ui -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Scaffold @@ -39,8 +35,7 @@ internal fun EntryContent( containerColor = background, contentWindowInsets = WindowInsetsZero, topBar = { - AnimatedContent( - targetState = stackState.value.active.instance, + Children( modifier = Modifier .then( if (!isOpenedInBottomSheet) { @@ -56,8 +51,11 @@ internal fun EntryContent( } } }, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - ) { currentState -> currentState.Title(bottomSheetState) } + stack = stackState.value, + animation = stackAnimation(fade()), + ) { child -> + child.instance.Title(bottomSheetState) + } }, content = { contentPadding -> Children( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index e1362d244a..d2822c4ba1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -49,9 +49,9 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState import com.tangem.features.feed.ui.feed.state.* -import com.tangem.features.feed.model.market.list.state.SortByTypeUM @Composable internal fun FeedListHeader(feedListSearchBar: FeedListSearchBar, modifier: Modifier = Modifier) { @@ -269,34 +269,26 @@ private fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCallb @Suppress("CanBeNonNullable") @Composable private fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) { - AnimatedContent(news) { newsUM -> - when (newsUM) { - is NewsUM.Content -> { - if (newsUM.content.isNotEmpty()) { + AnimatedContent(news.newsUMState) { newsUMState -> + when (newsUMState) { + NewsUMState.LOADING -> NewsLoadingBlock() + NewsUMState.CONTENT -> { + if (news.content.isNotEmpty()) { NewsContentBlock( feedListCallbacks = feedListCallbacks, - news = newsUM, + news = news, trendingArticle = trendingArticle, ) } } - NewsUM.Loading -> { - NewsLoadingBlock() - } - is NewsUM.Error -> { - NewsErrorBlock(onRetryClick = newsUM.onRetryClicked) - } + NewsUMState.ERROR -> NewsErrorBlock(onRetryClick = news.onRetryClicked) } } } @Suppress("LongMethod") @Composable -private fun NewsContentBlock( - feedListCallbacks: FeedListCallbacks, - news: NewsUM.Content, - trendingArticle: ArticleConfigUM?, -) { +private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) { Column { Header( title = { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt index 8b084d229a..565f19a7bf 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt @@ -9,8 +9,8 @@ import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.features.feed.ui.feed.state.* import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.ui.feed.state.* import kotlinx.collections.immutable.* @Suppress("MagicNumber") @@ -33,7 +33,11 @@ internal object FeedListPreviewDataProvider { onMarketItemClick = {}, onSortTypeClick = {}, ), - news = NewsUM.Content(articles.filter { it.isTrending.not() }.toImmutableList()), + news = NewsUM( + content = articles.filter { it.isTrending.not() }.toImmutableList(), + onRetryClicked = {}, + newsUMState = NewsUMState.CONTENT, + ), trendingArticle = articles.first { it.isTrending }, marketChartConfig = MarketChartConfig( marketCharts = createMarketCharts(marketItems, includeErrorState = false), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt index 15e71f5a11..2602fb2395 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt @@ -33,11 +33,16 @@ internal data class FeedListSearchBar( val placeholderText: TextReference, ) -@Immutable -internal sealed interface NewsUM { - data object Loading : NewsUM - data class Content(val content: ImmutableList) : NewsUM - data class Error(val onRetryClicked: () -> Unit) : NewsUM +internal data class NewsUM( + val content: ImmutableList, + val onRetryClicked: () -> Unit, + val newsUMState: NewsUMState, +) + +internal enum class NewsUMState { + LOADING, + CONTENT, + ERROR, } internal data class MarketChartConfig( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt index 15aee2ff6a..c2a568ad85 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt @@ -15,6 +15,8 @@ import com.tangem.domain.models.news.TrendingNews import com.tangem.features.feed.impl.R import com.tangem.utils.Provider import com.tangem.utils.StringsSigns +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentSet import org.joda.time.DateTime @@ -22,34 +24,48 @@ import org.joda.time.DateTime internal class TrendingNewsStateFactory( private val currentStateProvider: Provider, private val onStateUpdate: (FeedListUM) -> Unit, + private val onRetryClicked: () -> Unit, ) { - fun updateTrendingNewsState(result: TrendingNews, onRetryClicked: () -> Unit) { + fun updateTrendingNewsState(result: TrendingNews) { val currentState = currentStateProvider() when (result) { is TrendingNews.Data -> handleDataState(currentState, result.articles) - is TrendingNews.Error -> handleErrorState(currentState, onRetryClicked) + is TrendingNews.Error -> handleErrorState(currentState) } } private fun handleDataState(currentState: FeedListUM, articles: List) { val (trendingArticle, commonArticles) = separateTrendingAndCommonArticles(articles) + val commonArticlesUM = commonArticles.map { mapToArticleConfigUM(it, isTrending = false) }.toPersistentList() + val updatedNews = when (currentState.news.newsUMState) { + NewsUMState.CONTENT -> currentState.news.copy(content = commonArticlesUM) + NewsUMState.LOADING, + NewsUMState.ERROR, + -> NewsUM( + content = commonArticlesUM, + onRetryClicked = onRetryClicked, + newsUMState = NewsUMState.CONTENT, + ) + } onStateUpdate( currentState.copy( trendingArticle = trendingArticle?.let { mapToArticleConfigUM(it, isTrending = true) }, - news = NewsUM.Content( - commonArticles.map { mapToArticleConfigUM(it, isTrending = false) }.toPersistentList(), - ), + news = updatedNews, ), ) } - private fun handleErrorState(currentState: FeedListUM, onRetryClicked: () -> Unit) { + private fun handleErrorState(currentState: FeedListUM) { onStateUpdate( currentState.copy( trendingArticle = null, - news = NewsUM.Error(onRetryClicked = onRetryClicked), + news = NewsUM( + content = persistentListOf(), + onRetryClicked = onRetryClicked, + newsUMState = NewsUMState.ERROR, + ), ), ) } @@ -79,7 +95,7 @@ internal class TrendingNewsStateFactory( ) } - private fun buildArticleTags(article: ShortArticle): kotlinx.collections.immutable.ImmutableSet { + private fun buildArticleTags(article: ShortArticle): ImmutableSet { val categoryLabels = article.categories.map { category -> LabelUM(text = TextReference.Str(category.name)) } 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 4773bac517..60ea3e9f82 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 @@ -135,6 +135,7 @@ private fun Content( state = state.body, isAccountEnabled = isAccountEnabled, portfolioBlock = portfolioBlock, + relatedNews = state.relatedNews, ) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt index 2bb53ddbc1..d36d027eaa 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -1,26 +1,33 @@ package com.tangem.features.feed.ui.market.detailed.components -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.news.ArticleCard +import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.items.DescriptionItem import com.tangem.core.ui.components.items.DescriptionPlaceholder import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM +import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM.RelatedNews @Suppress("CanBeNonNullable") // TODO will be removed after [REDACTED_JIRA] internal fun LazyListScope.tokenMarketDetailsBody( state: MarketsTokenDetailsUM.Body, isAccountEnabled: Boolean, portfolioBlock: @Composable ((Modifier) -> Unit)?, + relatedNews: RelatedNews, ) { when (state) { MarketsTokenDetailsUM.Body.Loading -> { @@ -51,6 +58,10 @@ internal fun LazyListScope.tokenMarketDetailsBody( } } + if (relatedNews.articles.isNotEmpty()) { + relatedNews(relatedNews) + } + if (isAccountEnabled) { aboutCoinHeader() } @@ -190,6 +201,45 @@ private fun LazyListScope.loadingInfoBlocks() { } } +private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { + item("related-news") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp, top = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + modifier = Modifier.padding(start = 16.dp), + text = "Related news", + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + + LazyRow( + verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + state = rememberLazyListState(), + ) { + items( + items = relatedNews.articles, + key = ArticleConfigUM::id, + ) { article -> + ArticleCard( + articleConfigUM = article, + onArticleClick = { relatedNews.onArticledClicked(article.id) }, + modifier = Modifier + .height(164.dp) + .width(216.dp), + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), + ) + } + } + } + } +} + @Composable private fun Modifier.blockPaddings(): Modifier { return this.padding( 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 8f44d04000..964ccd4dad 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 @@ -7,13 +7,7 @@ import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.markets.PriceChangeInterval -import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM -import com.tangem.features.feed.ui.market.detailed.state.InsightsUM -import com.tangem.features.feed.ui.market.detailed.state.ListedOnUM -import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM -import com.tangem.features.feed.ui.market.detailed.state.MetricsUM -import com.tangem.features.feed.ui.market.detailed.state.PricePerformanceUM -import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreUM +import com.tangem.features.feed.ui.market.detailed.state.* import kotlinx.collections.immutable.persistentListOf internal object MarketsTokenDetailsPreview { @@ -49,6 +43,10 @@ internal object MarketsTokenDetailsPreview { triggerPriceChange = consumedEvent(), onShouldShowPriceSubtitleChange = {}, shouldShowPriceSubtitle = false, + relatedNews = MarketsTokenDetailsUM.RelatedNews( + articles = persistentListOf(), + onArticledClicked = {}, + ), ) val contentState = MarketsTokenDetailsUM( @@ -135,5 +133,9 @@ internal object MarketsTokenDetailsPreview { triggerPriceChange = consumedEvent(), onShouldShowPriceSubtitleChange = {}, shouldShowPriceSubtitle = false, + relatedNews = MarketsTokenDetailsUM.RelatedNews( + articles = persistentListOf(), + onArticledClicked = {}, + ), ) } \ 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 86685f6ac9..a1155099a5 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 @@ -2,11 +2,13 @@ package com.tangem.features.feed.ui.market.detailed.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.charts.state.MarketChartDataProducer +import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.markets.PriceChangeInterval +import kotlinx.collections.immutable.ImmutableList import java.math.BigDecimal internal data class MarketsTokenDetailsUM( @@ -25,6 +27,7 @@ internal data class MarketsTokenDetailsUM( val body: Body, val shouldShowPriceSubtitle: Boolean, val onShouldShowPriceSubtitleChange: (Boolean) -> Unit, + val relatedNews: RelatedNews, ) { data class ChartState( @@ -69,4 +72,9 @@ internal data class MarketsTokenDetailsUM( val fullDescription: TextReference?, val onReadMoreClick: () -> Unit, ) + + data class RelatedNews( + val articles: ImmutableList, + val onArticledClicked: (id: Int) -> Unit, + ) } \ No newline at end of file From d8fe4991f5a7720420bfffb4ba19e856e7624339 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Dec 2025 11:13:23 +0100 Subject: [PATCH 026/172] Updated on 2026-08-14 --- .../ui/components/pager/PagerIndicator.kt | 168 ++++++++++++++---- 1 file changed, 134 insertions(+), 34 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/pager/PagerIndicator.kt b/core/ui/src/main/java/com/tangem/core/ui/components/pager/PagerIndicator.kt index 8dc43f2d53..418cc7344d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/pager/PagerIndicator.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/pager/PagerIndicator.kt @@ -1,5 +1,6 @@ package com.tangem.core.ui.components.pager +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope @@ -8,18 +9,24 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* +import androidx.compose.runtime.getValue 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.Shape -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import kotlin.math.abs +import kotlin.math.min + +// six - cause the central indicator has width multiplied twice +private const val TOTAL_MAX_INDICATORS = 6 +private const val SPACER_COUNT_BETWEEN_INDICATORS = 4 /** * Horizontal pager indicator @@ -29,82 +36,163 @@ import com.tangem.core.ui.res.TangemThemePreview */ @Composable fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier, indicatorCount: Int = 5) { + if (pagerState.pageCount == 0) return + val listState = rememberLazyListState() val indicatorColor = TangemTheme.colors.control.key val overlayColor = TangemTheme.colors.overlay.secondary - val indicatorSize = 8.dp + + val inactiveIndicatorColor = remember(indicatorColor) { + indicatorColor.copy(alpha = 0.5f) + } + + val baseIndicatorSize = 8.dp val spacing = 4.dp - val totalWidth: Dp = indicatorSize * indicatorCount + spacing * (indicatorCount - 1) - val widthInPx = LocalDensity.current.run { indicatorSize.toPx() } - - val currentItem by remember { + val indicatorState by remember(pagerState, indicatorCount) { derivedStateOf { - pagerState.currentPage + val count = pagerState.pageCount + val current = pagerState.currentPage + + val winSize = min(indicatorCount, count) + val centerPosition = winSize / 2 + + val start = when { + count <= winSize -> 0 + current <= centerPosition -> 0 + current >= count - centerPosition - 1 -> count - winSize + else -> current - centerPosition + } + Triple(count, winSize, start) } } - val itemCount = pagerState.pageCount + val (itemCount, windowSize, windowStart) = indicatorState + val currentItem by remember { derivedStateOf { pagerState.currentPage } } - LaunchedEffect(key1 = currentItem) { - val viewportSize = listState.layoutInfo.viewportSize - listState.animateScrollToItem( - currentItem, - (widthInPx / 2 - viewportSize.width / 2).toInt(), - ) + LaunchedEffect(currentItem, windowStart) { + if (itemCount > windowSize) { + listState.animateScrollToItem(windowStart.coerceIn(0, itemCount - 1)) + } + } + + val maxContainerWidth = remember(baseIndicatorSize, spacing) { + baseIndicatorSize * TOTAL_MAX_INDICATORS + spacing * SPACER_COUNT_BETWEEN_INDICATORS } Box( modifier = modifier .height(32.dp) + .width(maxContainerWidth + 32.dp) .background( color = overlayColor, shape = CircleShape, ) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 12.dp) + .clip(CircleShape), contentAlignment = Alignment.Center, ) { LazyRow( - modifier = Modifier - .width(totalWidth), + modifier = Modifier.wrapContentWidth(), state = listState, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(spacing), userScrollEnabled = false, ) { indicatorItems( itemCount = itemCount, currentItem = currentItem, - indicatorShape = CircleShape, activeColor = indicatorColor, - inActiveColor = indicatorColor.copy(alpha = 0.5f), - indicatorSize = indicatorSize, + inActiveColor = inactiveIndicatorColor, + baseSize = baseIndicatorSize, + windowSize = windowSize, + windowStart = windowStart, ) } } } +@Suppress("MagicNumber", "CyclomaticComplexMethod") +private fun calculateIndicatorHeight(position: Int, currentPosition: Int, baseSize: Dp, windowSize: Int): Dp { + val distance = abs(position - currentPosition) + val mediumSize = 6.dp + val smallSize = 4.dp + + if (windowSize < 5) { + return when { + distance <= 1 -> baseSize + distance == 2 -> mediumSize + else -> smallSize + } + } + + val isEdgeFocus = currentPosition == 0 || currentPosition == windowSize - 1 + val isNearEdgeFocus = currentPosition == 1 || currentPosition == windowSize - 2 + return when { + isEdgeFocus -> when { + distance <= 2 -> baseSize + distance == 3 -> mediumSize + else -> smallSize + } + isNearEdgeFocus -> when { + distance <= 1 -> baseSize + distance == 2 -> mediumSize + else -> smallSize + } + else -> when { + distance <= 1 -> baseSize + else -> mediumSize + } + } +} + @Suppress("LongParameterList") private fun LazyListScope.indicatorItems( itemCount: Int, currentItem: Int, - indicatorShape: Shape, activeColor: Color, inActiveColor: Color, - indicatorSize: Dp, + baseSize: Dp, + windowSize: Int, + windowStart: Int, ) { - items(itemCount) { index -> + val safeWindowSize = min(windowSize, itemCount) + if (safeWindowSize <= 0) return - val isSelected = index == currentItem + val windowEnd = windowStart + safeWindowSize + val currentPosInWindow = (currentItem - windowStart).coerceIn(0, safeWindowSize - 1) + + items(itemCount) { pageIndex -> + val isInWindow = pageIndex in windowStart until windowEnd + val positionInWindow = (pageIndex - windowStart).coerceIn(0, safeWindowSize - 1) + + val isSelected = pageIndex == currentItem + + val refinedHeight = if (isInWindow) { + calculateIndicatorHeight( + position = positionInWindow, + currentPosition = currentPosInWindow, + baseSize = baseSize, + windowSize = safeWindowSize, + ) + } else { + 0.dp + } + val targetWidth = if (isSelected) refinedHeight * 2 else refinedHeight + val targetShape = if (isSelected) RoundedCornerShape(16.dp) else CircleShape + val animatedWidth by animateDpAsState(targetValue = targetWidth, label = "width") + val animatedHeight by animateDpAsState(targetValue = refinedHeight, label = "height") Box( modifier = Modifier - .clip(indicatorShape) - .size(indicatorSize) + .padding(vertical = (baseSize - animatedHeight) / 2) + .clip(targetShape) + .width(animatedWidth) + .height(animatedHeight) .background( if (isSelected) activeColor else inActiveColor, - indicatorShape, + targetShape, ), ) } @@ -112,19 +200,31 @@ private fun LazyListScope.indicatorItems( @Preview(showBackground = true) @Composable -private fun PagerIndicatorPreviewFirstPage() { +private fun PagerIndicatorPreview() { TangemThemePreview { - Box( + Column( modifier = Modifier .background(TangemTheme.colors.background.primary) - .padding(), - contentAlignment = Alignment.Center, + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { val pagerState = rememberPagerState( - initialPage = 0, + initialPage = 2, pageCount = { 10 }, ) PagerIndicator(pagerState = pagerState) + + val pagerState1 = rememberPagerState( + initialPage = 0, + pageCount = { 3 }, + ) + PagerIndicator(pagerState = pagerState1) + + val pagerState2 = rememberPagerState( + initialPage = 0, + pageCount = { 1 }, + ) + PagerIndicator(pagerState = pagerState2) } } } \ No newline at end of file From f7dc4dac0f580f577e7a39274d797489cc3d99d6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Dec 2025 15:13:32 +0500 Subject: [PATCH 027/172] Updated on 2026-08-14 --- .../com/tangem/features/send/v2/send/model/SendModel.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt index 1028a207b5..5035e1ea7c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -334,15 +334,15 @@ internal class SendModel @Inject constructor( userWalletId = params.userWalletId, currency = cryptoCurrency, ).onEach { (account, cryptoCurrencyStatus) -> + isAccountModeFlow.value = isAccountsModeEnabledUseCase.invokeSync() + accountFlow.value = account + cryptoCurrencyStatusFlow.value = cryptoCurrencyStatus feeCryptoCurrencyStatusFlow.value = getFeePaidCryptoCurrencyStatusSyncUseCase( userWalletId = params.userWalletId, cryptoCurrencyStatus = cryptoCurrencyStatus, ).getOrNull() ?: cryptoCurrencyStatus - isAccountModeFlow.value = isAccountsModeEnabledUseCase.invokeSync() - accountFlow.value = account - if (params.amount != null) { router.replaceAll(Confirm) } From e00fd4599ef2c2ce48a39b283dff36d3f37abbdc Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Dec 2025 09:42:40 +0200 Subject: [PATCH 028/172] Updated on 2026-08-14 --- .../domain/models/staking/BalanceItemExt.kt | 27 +++ .../staking/P2PEthPoolStakingAccountExt.kt | 64 +++++ .../domain/models/staking/StakingBalance.kt | 48 ++-- .../models/staking/StakingBalanceEntry.kt | 23 ++ .../models/staking/StakingEntryActions.kt | 23 ++ .../domain/models/staking/StakingEntryType.kt | 31 +++ .../CryptoCurrencyStatusFactoryTest.kt | 12 +- .../TotalFiatBalanceCalculatorTest.kt | 4 +- .../impl/presentation/model/StakingModel.kt | 21 +- .../state/converters/BalanceItemConverter.kt | 177 -------------- .../RewardsValidatorStateConverter.kt | 26 +- .../StakingBalanceEntryConverter.kt | 227 ++++++++++++++++++ .../converters/YieldBalancesConverter.kt | 58 +++-- .../SetInitialDataStateTransformer.kt | 4 +- 14 files changed, 492 insertions(+), 253 deletions(-) create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/staking/BalanceItemExt.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccountExt.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalanceEntry.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryActions.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryType.kt delete mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/BalanceItemExt.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/BalanceItemExt.kt new file mode 100644 index 0000000000..b71f5b9b52 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/BalanceItemExt.kt @@ -0,0 +1,27 @@ +package com.tangem.domain.models.staking + +import com.tangem.domain.models.staking.StakingEntryType.Companion.fromBalanceType + +fun BalanceItem.toStakingBalanceEntry(validatorName: String? = null): StakingBalanceEntry { + return StakingBalanceEntry( + id = groupId, + type = fromBalanceType(type), + amount = amount, + validator = validatorAddress?.let { + ValidatorInfo(address = it, name = validatorName) + }, + date = date, + actions = StakingEntryActions.StakeKit( + pendingActions = pendingActions, + pendingActionsConstraints = pendingActionsConstraints, + ), + isPending = isPending, + rawCurrencyId = rawCurrencyId, + ) +} + +fun List.toStakingBalanceEntries( + validatorNameResolver: (String?) -> String? = { null }, +): List { + return map { it.toStakingBalanceEntry(validatorNameResolver(it.validatorAddress)) } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccountExt.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccountExt.kt new file mode 100644 index 0000000000..c189a5465f --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccountExt.kt @@ -0,0 +1,64 @@ +package com.tangem.domain.models.staking + +import java.math.BigDecimal + +fun P2PEthPoolStakingAccount.toStakingBalanceEntries(vaultName: String? = null): List { + return buildList { + if (stake.assets > BigDecimal.ZERO) { + add( + StakingBalanceEntry( + id = vaultAddress, + type = StakingEntryType.STAKED, + amount = stake.assets, + validator = ValidatorInfo(address = vaultAddress, name = vaultName), + date = null, + actions = StakingEntryActions.P2PEthPool( + ticket = null, + estimatedWithdrawalDate = null, + isClaimable = false, + ), + isPending = false, + rawCurrencyId = null, + ), + ) + } + + exitQueue.requests.forEach { request -> + add( + StakingBalanceEntry( + id = "${vaultAddress}_${request.ticket}", + type = StakingEntryType.UNSTAKING, + amount = request.totalAssets, + validator = ValidatorInfo(address = vaultAddress, name = vaultName), + date = request.withdrawalTimestamp, + actions = StakingEntryActions.P2PEthPool( + ticket = request.ticket, + estimatedWithdrawalDate = request.withdrawalTimestamp, + isClaimable = request.isClaimable, + ), + isPending = false, + rawCurrencyId = null, + ), + ) + } + + if (availableToWithdraw > BigDecimal.ZERO) { + add( + StakingBalanceEntry( + id = "${vaultAddress}_withdrawable", + type = StakingEntryType.WITHDRAWABLE, + amount = availableToWithdraw, + validator = ValidatorInfo(address = vaultAddress, name = vaultName), + date = null, + actions = StakingEntryActions.P2PEthPool( + ticket = null, + estimatedWithdrawalDate = null, + isClaimable = true, + ), + isPending = false, + rawCurrencyId = null, + ), + ) + } + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt index a83dd1b1e2..3e852db2fa 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt @@ -1,6 +1,7 @@ package com.tangem.domain.models.staking import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.serialization.SerializedBigDecimal import kotlinx.serialization.Serializable import java.math.BigDecimal @@ -21,6 +22,9 @@ sealed interface StakingBalance { @Serializable sealed interface Data : StakingBalance { + /** Provider-agnostic list of balance entries for UI display */ + val entries: List + @Serializable data class StakeKit( override val stakingId: StakingID, @@ -28,25 +32,23 @@ sealed interface StakingBalance { val balance: YieldBalanceItem, ) : Data { - override val totalStaked: BigDecimal - get() = balance.items - .filter { it.type == BalanceType.STAKED } - .sumOf { it.amount } + override val totalStaked: SerializedBigDecimal = balance.items + .filter { it.type == BalanceType.STAKED } + .sumOf { it.amount } - override val totalRewards: BigDecimal - get() = balance.items - .filter { it.type == BalanceType.REWARDS } - .sumOf { it.amount } + override val totalRewards: SerializedBigDecimal = balance.items + .filter { it.type == BalanceType.REWARDS } + .sumOf { it.amount } - override val unstakingAmount: BigDecimal - get() = balance.items - .filter { it.type == BalanceType.UNSTAKING || it.type == BalanceType.UNLOCKING } - .sumOf { it.amount } + override val unstakingAmount: SerializedBigDecimal = balance.items + .filter { it.type == BalanceType.UNSTAKING || it.type == BalanceType.UNLOCKING } + .sumOf { it.amount } - override val withdrawableAmount: BigDecimal - get() = balance.items - .filter { it.type == BalanceType.UNSTAKED } - .sumOf { it.amount } + override val withdrawableAmount: SerializedBigDecimal = balance.items + .filter { it.type == BalanceType.UNSTAKED } + .sumOf { it.amount } + + override val entries: List = balance.items.toStakingBalanceEntries() } @Serializable @@ -56,17 +58,15 @@ sealed interface StakingBalance { val account: P2PEthPoolStakingAccount, ) : Data { - override val totalStaked: BigDecimal - get() = account.stake.assets + override val totalStaked: SerializedBigDecimal = account.stake.assets - override val totalRewards: BigDecimal - get() = account.stake.totalEarnedAssets + override val totalRewards: SerializedBigDecimal = account.stake.totalEarnedAssets - override val unstakingAmount: BigDecimal - get() = account.exitQueue.total + override val unstakingAmount: SerializedBigDecimal = account.exitQueue.total - override val withdrawableAmount: BigDecimal - get() = account.availableToWithdraw + override val withdrawableAmount: SerializedBigDecimal = account.availableToWithdraw + + override val entries: List = account.toStakingBalanceEntries() } } diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalanceEntry.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalanceEntry.kt new file mode 100644 index 0000000000..bd38711c65 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalanceEntry.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.models.staking + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class StakingBalanceEntry( + val id: String, + val type: StakingEntryType, + val amount: SerializedBigDecimal, + val validator: ValidatorInfo?, + val date: Instant?, + val actions: StakingEntryActions, + val isPending: Boolean, + val rawCurrencyId: String?, +) + +@Serializable +data class ValidatorInfo( + val address: String, + val name: String?, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryActions.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryActions.kt new file mode 100644 index 0000000000..da7f7c48e8 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryActions.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.models.staking + +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +sealed interface StakingEntryActions { + + @Serializable + data class StakeKit( + val pendingActions: List, + val pendingActionsConstraints: List, + ) : StakingEntryActions { + val hasPendingActions: Boolean get() = pendingActions.isNotEmpty() + } + + @Serializable + data class P2PEthPool( + val ticket: String?, + val estimatedWithdrawalDate: Instant?, + val isClaimable: Boolean, + ) : StakingEntryActions +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryType.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryType.kt new file mode 100644 index 0000000000..ec0f416e45 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryType.kt @@ -0,0 +1,31 @@ +package com.tangem.domain.models.staking + +import kotlinx.serialization.Serializable + +@Serializable +enum class StakingEntryType { + AVAILABLE, + STAKED, + PREPARING, + LOCKED, + UNSTAKING, + UNLOCKING, + WITHDRAWABLE, + REWARDS, + UNKNOWN, + ; + + companion object { + fun fromBalanceType(type: BalanceType): StakingEntryType = when (type) { + BalanceType.AVAILABLE -> AVAILABLE + BalanceType.STAKED -> STAKED + BalanceType.PREPARING -> PREPARING + BalanceType.LOCKED -> LOCKED + BalanceType.UNSTAKING -> UNSTAKING + BalanceType.UNLOCKING -> UNLOCKING + BalanceType.UNSTAKED -> WITHDRAWABLE // stakekit's UNSTAKED = ready to withdraw + BalanceType.REWARDS -> REWARDS + BalanceType.UNKNOWN -> UNKNOWN + } + } +} \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt index 84fa8cedeb..f8a1d282a2 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt @@ -428,10 +428,10 @@ class CryptoCurrencyStatusFactoryTest { source = StatusSource.ACTUAL, balance = YieldBalanceItem( items = listOf( - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value }, - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns "unknown" }, ), @@ -525,10 +525,10 @@ class CryptoCurrencyStatusFactoryTest { source = StatusSource.ACTUAL, balance = YieldBalanceItem( items = listOf( - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value }, - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns "unknown" }, ), @@ -614,10 +614,10 @@ class CryptoCurrencyStatusFactoryTest { source = StatusSource.ACTUAL, balance = YieldBalanceItem( items = listOf( - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value }, - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns "unknown" }, ), diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt index 7f08ae5dbc..197949310f 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt @@ -589,11 +589,11 @@ class TotalFiatBalanceCalculatorTest { private fun createStakeKitBalance(amount: BigDecimal, balanceType: BalanceType): StakingBalance.Data.StakeKit { return StakingBalance.Data.StakeKit( - stakingId = mockk(), + stakingId = mockk(relaxed = true), source = StatusSource.ACTUAL, balance = YieldBalanceItem( items = listOf( - mockk { + mockk(relaxed = true) { every { this@mockk.amount } returns amount every { this@mockk.type } returns balanceType }, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index 07da487fa9..494dcdd997 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -199,14 +199,21 @@ internal class StakingModel @Inject constructor( } private var appCurrency: AppCurrency by Delegates.notNull() - private val balancesToShow: List + private val balancesToShow: List get() { - val stakeKitBalance = cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit - return invalidatePendingTransactionsUseCase( - balanceItems = stakeKitBalance?.balance?.items.orEmpty(), - stakingActions = stakingActions, - token = integration.token, - ).getOrElse { emptyList() } + val stakingBalance = cryptoCurrencyStatus.value.stakingBalance + return when (stakingBalance) { + is StakingBalance.Data.StakeKit -> { + val invalidatedItems = invalidatePendingTransactionsUseCase( + balanceItems = stakingBalance.balance.items, + stakingActions = stakingActions, + token = integration.token, + ).getOrElse { emptyList() } + invalidatedItems.toStakingBalanceEntries() + } + is StakingBalance.Data.P2PEthPool -> stakingBalance.entries + else -> emptyList() + } } private var isInitialInfoAnalyticSent: Boolean = false diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt deleted file mode 100644 index 1b4a71149b..0000000000 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt +++ /dev/null @@ -1,177 +0,0 @@ -package com.tangem.features.staking.impl.presentation.state.converters - -import com.tangem.core.ui.extensions.* -import com.tangem.core.ui.format.bigdecimal.crypto -import com.tangem.core.ui.format.bigdecimal.fiat -import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.core.ui.utils.parseBigDecimal -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.staking.BalanceItem -import com.tangem.domain.models.staking.BalanceType -import com.tangem.domain.models.staking.BalanceType.Companion.isClickable -import com.tangem.domain.models.staking.StakingBalance -import com.tangem.domain.models.staking.action.StakingActionType -import com.tangem.domain.staking.model.StakingIntegration -import com.tangem.domain.staking.utils.getRewardStakingBalance -import com.tangem.features.staking.impl.R -import com.tangem.features.staking.impl.presentation.state.BalanceState -import com.tangem.lib.crypto.BlockchainUtils -import com.tangem.lib.crypto.BlockchainUtils.isTon -import com.tangem.utils.Provider -import com.tangem.utils.converter.Converter -import com.tangem.utils.extensions.orZero -import kotlinx.collections.immutable.toPersistentList -import kotlinx.datetime.Instant -import java.math.BigDecimal -import java.util.Calendar - -internal class BalanceItemConverter( - private val cryptoCurrencyStatus: CryptoCurrencyStatus, - private val appCurrencyProvider: Provider, - private val integration: StakingIntegration, -) : Converter { - - override fun convert(value: BalanceItem): BalanceState? { - val appCurrency = appCurrencyProvider() - val cryptoCurrency = cryptoCurrencyStatus.currency - - val target = integration.targets.firstOrNull { - value.validatorAddress?.contains(it.address, ignoreCase = true) == true - } - val cryptoAmount = value.getBalanceValue() - val fiatAmount = cryptoCurrencyStatus.value.fiatRate?.times(cryptoAmount) - - val title = value.type.getTitle(target?.name) - return title?.let { - BalanceState( - groupId = value.groupId, - target = target, - title = title, - subtitle = getSubtitle(value), - type = value.type, - cryptoValue = cryptoAmount.parseBigDecimal(cryptoCurrency.decimals), - cryptoAmount = cryptoAmount, - formattedCryptoAmount = stringReference( - cryptoAmount.format { crypto(cryptoCurrency) }, - ), - fiatAmount = fiatAmount, - formattedFiatAmount = stringReference( - fiatAmount.format { - fiat( - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ) - }, - ), - rawCurrencyId = value.rawCurrencyId, - pendingActions = value.pendingActions.toPersistentList(), - isClickable = value.isClickable(), - isPending = value.isPending, - targetAddress = value.validatorAddress, - ) - } - } - - private fun BalanceItem.getBalanceValue(): BigDecimal { - val isIncludeStakingTotalBalance = BlockchainUtils.isIncludeStakingTotalBalance( - blockchainId = cryptoCurrencyStatus.currency.network.rawId, - ) - val stakeKitBalance = cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit - return if (isIncludeStakingTotalBalance) { - amount - } else { - amount - stakeKitBalance?.getRewardStakingBalance().orZero() - } - } - - private fun BalanceType.getTitle(validatorName: String?) = when (this) { - BalanceType.PREPARING, - BalanceType.STAKED, - -> validatorName?.let { stringReference(it) } - BalanceType.UNSTAKED -> resourceReference(R.string.staking_unstaked) - BalanceType.UNSTAKING -> resourceReference(R.string.staking_unstaking) - BalanceType.LOCKED -> resourceReference(R.string.staking_locked) - BalanceType.AVAILABLE, - BalanceType.REWARDS, - BalanceType.UNLOCKING, - BalanceType.UNKNOWN, - -> null - } - - private fun getSubtitle(balance: BalanceItem) = when (balance.type) { - BalanceType.UNSTAKING -> getUnbondingDate(balance.date) - BalanceType.UNSTAKED -> resourceReference(R.string.staking_tap_to_withdraw) - BalanceType.LOCKED -> if (balance.pendingActions.any { it.type == StakingActionType.VOTE_LOCKED }) { - resourceReference(R.string.staking_tap_to_unlock_or_vote) - } else { - resourceReference(R.string.staking_tap_to_unlock) - } - BalanceType.PREPARING -> { - val warmupPeriod = integration.warmupPeriodDays - combinedReference( - resourceReference(R.string.staking_details_warmup_period), - stringReference(" "), - pluralReference(R.plurals.common_days, warmupPeriod, wrappedList(warmupPeriod)), - ) - } - BalanceType.AVAILABLE, - BalanceType.STAKED, - BalanceType.UNLOCKING, - BalanceType.REWARDS, - BalanceType.UNKNOWN, - -> null - } - - private fun getUnbondingDate(date: Instant?): TextReference? { - val unbondingPeriod = integration.cooldownPeriodDays ?: return null - if (date == null) { - return combinedReference( - resourceReference(R.string.staking_details_unbonding_period), - stringReference(" "), - pluralReference(R.plurals.common_days, unbondingPeriod, wrappedList(unbondingPeriod)), - ) - } - - val nowCalendar = Calendar.getInstance() - nowCalendar.resetHours() - - val endDate = Calendar.getInstance() - endDate.timeInMillis = date.toEpochMilliseconds() - endDate.resetHours() - - val days = ((endDate.timeInMillis - nowCalendar.timeInMillis) / DAY_IN_MILLIS).toInt() - return if (days > 0) { - resourceReference( - R.string.common_left, - wrappedList( - pluralReference(R.plurals.common_days, days, wrappedList(days)), - ), - ) - } else { - resourceReference(R.string.common_today) - } - } - - private fun BalanceItem.isClickable(): Boolean { - val networkId = cryptoCurrencyStatus.currency.network.rawId - return when { - // TON allows withdrawing funds in the preparing state, unlike other networks. - isTon(networkId) && this.type == BalanceType.PREPARING -> { - pendingActions.any { it.type == StakingActionType.WITHDRAW } - } - else -> this.type.isClickable() && !this.isPending - } - } - - private fun Calendar.resetHours() { - this[Calendar.HOUR_OF_DAY] = 0 - this[Calendar.MINUTE] = 0 - this[Calendar.SECOND] = 0 - this[Calendar.MILLISECOND] = 0 - } - - private companion object { - const val DAY_IN_MILLIS = 24 * 60 * 60 * 1000 - } -} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt index e81cb4512e..bfa8c333f9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt @@ -24,20 +24,22 @@ internal class RewardsValidatorStateConverter( private val appCurrencyProvider: Provider, private val integration: StakingIntegration, ) : Converter { + override fun convert(value: Unit): StakingStates.RewardsValidatorsState { val stakingBalance = cryptoCurrencyStatus.value.stakingBalance - return if (stakingBalance is StakingBalance.Data.StakeKit) { - val balances = stakingBalance.balance.items - StakingStates.RewardsValidatorsState.Data( - isPrimaryButtonEnabled = true, - rewards = balances - .filter { it.type == BalanceType.REWARDS } - .mapRewardBalances(cryptoCurrencyStatus) - .toPersistentList(), - ) - } else { - // TODO p2p - StakingStates.RewardsValidatorsState.Empty() + return when (stakingBalance) { + is StakingBalance.Data.StakeKit -> { + val balances = stakingBalance.balance.items + StakingStates.RewardsValidatorsState.Data( + isPrimaryButtonEnabled = true, + rewards = balances + .filter { it.type == BalanceType.REWARDS } + .mapRewardBalances(cryptoCurrencyStatus) + .toPersistentList(), + ) + } + is StakingBalance.Data.P2PEthPool -> StakingStates.RewardsValidatorsState.Empty() + else -> StakingStates.RewardsValidatorsState.Empty() } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt new file mode 100644 index 0000000000..45009e5529 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt @@ -0,0 +1,227 @@ +package com.tangem.features.staking.impl.presentation.state.converters + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.staking.BalanceType +import com.tangem.domain.models.staking.PendingAction +import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.models.staking.StakingBalanceEntry +import com.tangem.domain.models.staking.StakingEntryActions +import com.tangem.domain.models.staking.StakingEntryType +import com.tangem.domain.models.staking.action.StakingActionType +import com.tangem.domain.staking.model.StakingIntegration +import com.tangem.domain.staking.model.StakingTarget +import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.BalanceState +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.lib.crypto.BlockchainUtils.isTon +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.datetime.Instant +import java.math.BigDecimal +import java.util.Calendar + +internal class StakingBalanceEntryConverter( + private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val appCurrencyProvider: Provider, + private val integration: StakingIntegration, +) : Converter { + + override fun convert(value: StakingBalanceEntry): BalanceState? { + val appCurrency = appCurrencyProvider() + val cryptoCurrency = cryptoCurrencyStatus.currency + + val target = findTarget(value) + val cryptoAmount = value.getBalanceValue() + val fiatAmount = cryptoCurrencyStatus.value.fiatRate?.times(cryptoAmount) + + val title = value.type.getTitle(target?.name ?: value.validator?.name) + return title?.let { + BalanceState( + groupId = value.id, + target = target, + title = title, + subtitle = getSubtitle(value), + type = value.type.toBalanceType(), + cryptoValue = cryptoAmount.parseBigDecimal(cryptoCurrency.decimals), + cryptoAmount = cryptoAmount, + formattedCryptoAmount = stringReference( + cryptoAmount.format { crypto(cryptoCurrency) }, + ), + fiatAmount = fiatAmount, + formattedFiatAmount = stringReference( + fiatAmount.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + ), + rawCurrencyId = value.rawCurrencyId, + pendingActions = value.getPendingActions().toPersistentList(), + isClickable = value.isClickable(), + isPending = value.isPending, + targetAddress = value.validator?.address, + ) + } + } + + private fun findTarget(entry: StakingBalanceEntry): StakingTarget? { + return integration.targets.firstOrNull { + entry.validator?.address?.contains(it.address, ignoreCase = true) == true + } + } + + private fun StakingBalanceEntry.getBalanceValue(): BigDecimal { + val isIncludeStakingTotalBalance = BlockchainUtils.isIncludeStakingTotalBalance( + blockchainId = cryptoCurrencyStatus.currency.network.rawId, + ) + return if (isIncludeStakingTotalBalance) { + amount + } else { + val stakingBalance = cryptoCurrencyStatus.value.stakingBalance + if (stakingBalance is StakingBalance.Data.StakeKit) { + amount - stakingBalance.totalRewards + } else { + amount + } + } + } + + private fun StakingEntryType.getTitle(validatorName: String?): TextReference? = when (this) { + StakingEntryType.PREPARING, + StakingEntryType.STAKED, + -> validatorName?.let { stringReference(it) } + StakingEntryType.WITHDRAWABLE -> resourceReference(R.string.staking_unstaked) + StakingEntryType.UNSTAKING -> resourceReference(R.string.staking_unstaking) + StakingEntryType.LOCKED -> resourceReference(R.string.staking_locked) + StakingEntryType.AVAILABLE, + StakingEntryType.REWARDS, + StakingEntryType.UNLOCKING, + StakingEntryType.UNKNOWN, + -> null + } + + private fun getSubtitle(entry: StakingBalanceEntry): TextReference? = when (entry.type) { + StakingEntryType.UNSTAKING -> getUnbondingDate(entry.date) + StakingEntryType.WITHDRAWABLE -> resourceReference(R.string.staking_tap_to_withdraw) + StakingEntryType.LOCKED -> { + val hasVoteLocked = entry.getPendingActions().any { it.type == StakingActionType.VOTE_LOCKED } + if (hasVoteLocked) { + resourceReference(R.string.staking_tap_to_unlock_or_vote) + } else { + resourceReference(R.string.staking_tap_to_unlock) + } + } + StakingEntryType.PREPARING -> { + val warmupPeriod = integration.warmupPeriodDays + TextReference.Combined( + wrappedList( + resourceReference(R.string.staking_details_warmup_period), + stringReference(" "), + pluralReference(R.plurals.common_days, warmupPeriod, wrappedList(warmupPeriod)), + ), + ) + } + StakingEntryType.AVAILABLE, + StakingEntryType.STAKED, + StakingEntryType.UNLOCKING, + StakingEntryType.REWARDS, + StakingEntryType.UNKNOWN, + -> null + } + + private fun getUnbondingDate(date: Instant?): TextReference? { + val unbondingPeriod = integration.cooldownPeriodDays ?: return null + if (date == null) { + return TextReference.Combined( + wrappedList( + resourceReference(R.string.staking_details_unbonding_period), + stringReference(" "), + pluralReference(R.plurals.common_days, unbondingPeriod, wrappedList(unbondingPeriod)), + ), + ) + } + + val nowCalendar = Calendar.getInstance() + nowCalendar.resetHours() + + val endDate = Calendar.getInstance() + endDate.timeInMillis = date.toEpochMilliseconds() + endDate.resetHours() + + val days = ((endDate.timeInMillis - nowCalendar.timeInMillis) / DAY_IN_MILLIS).toInt() + return if (days > 0) { + resourceReference( + R.string.common_left, + wrappedList( + pluralReference(R.plurals.common_days, days, wrappedList(days)), + ), + ) + } else { + resourceReference(R.string.common_today) + } + } + + private fun StakingBalanceEntry.isClickable(): Boolean { + val networkId = cryptoCurrencyStatus.currency.network.rawId + return when { + // TON allows withdrawing funds in the preparing state, unlike other networks. + isTon(networkId) && this.type == StakingEntryType.PREPARING -> { + getPendingActions().any { it.type == StakingActionType.WITHDRAW } + } + else -> this.type.isClickableType() && !this.isPending + } + } + + private fun StakingEntryType.isClickableType(): Boolean = when (this) { + StakingEntryType.STAKED, + StakingEntryType.WITHDRAWABLE, + StakingEntryType.LOCKED, + -> true + else -> false + } + + private fun StakingBalanceEntry.getPendingActions(): List { + return when (val actions = this.actions) { + is StakingEntryActions.StakeKit -> actions.pendingActions + is StakingEntryActions.P2PEthPool -> persistentListOf() + } + } + + private fun StakingEntryType.toBalanceType(): BalanceType { + return when (this) { + StakingEntryType.AVAILABLE -> BalanceType.AVAILABLE + StakingEntryType.STAKED -> BalanceType.STAKED + StakingEntryType.PREPARING -> BalanceType.PREPARING + StakingEntryType.LOCKED -> BalanceType.LOCKED + StakingEntryType.UNSTAKING -> BalanceType.UNSTAKING + StakingEntryType.UNLOCKING -> BalanceType.UNLOCKING + StakingEntryType.WITHDRAWABLE -> BalanceType.UNSTAKED + StakingEntryType.REWARDS -> BalanceType.REWARDS + StakingEntryType.UNKNOWN -> BalanceType.UNKNOWN + } + } + + private fun Calendar.resetHours() { + this[Calendar.HOUR_OF_DAY] = 0 + this[Calendar.MINUTE] = 0 + this[Calendar.SECOND] = 0 + this[Calendar.MILLISECOND] = 0 + } + + private companion object { + const val DAY_IN_MILLIS = 24 * 60 * 60 * 1000 + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt index 1c1b278c34..3fcfbe6f06 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt @@ -6,13 +6,13 @@ 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.CryptoCurrencyStatus -import com.tangem.domain.models.staking.BalanceItem import com.tangem.domain.models.staking.BalanceType import com.tangem.domain.models.staking.RewardBlockType import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.models.staking.StakingBalanceEntry +import com.tangem.domain.models.staking.StakingEntryType import com.tangem.domain.models.staking.action.StakingActionType import com.tangem.domain.staking.model.StakingIntegration -import com.tangem.domain.staking.utils.getRewardStakingBalance import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState import com.tangem.features.staking.impl.presentation.state.YieldReward import com.tangem.lib.crypto.BlockchainUtils @@ -24,23 +24,23 @@ import kotlinx.collections.immutable.toPersistentList internal class YieldBalancesConverter( private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val appCurrencyProvider: Provider, - private val balancesToShowProvider: Provider>, + private val balancesToShowProvider: Provider>, private val integration: StakingIntegration, ) : Converter { - private val balanceItemConverter by lazy(LazyThreadSafetyMode.NONE) { - BalanceItemConverter(cryptoCurrencyStatus, appCurrencyProvider, integration) + private val balanceEntryConverter by lazy(LazyThreadSafetyMode.NONE) { + StakingBalanceEntryConverter(cryptoCurrencyStatus, appCurrencyProvider, integration) } override fun convert(value: Unit): InnerYieldBalanceState { val appCurrency = appCurrencyProvider() - val cryptoCurrency = cryptoCurrencyStatus.currency - val stakeKitBalance = cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit - val balanceToShowItems = balancesToShowProvider() + val stakingBalance = cryptoCurrencyStatus.value.stakingBalance + val balanceEntries = balancesToShowProvider() + val hasStakingData = stakingBalance is StakingBalance.Data - return if (stakeKitBalance != null || balanceToShowItems.any { it.isPending }) { - val cryptoRewardsValue = stakeKitBalance?.getRewardStakingBalance() + return if (hasStakingData || balanceEntries.any { it.isPending }) { + val cryptoRewardsValue = (stakingBalance as? StakingBalance.Data)?.totalRewards val fiatRate = cryptoCurrencyStatus.value.fiatRate val fiatRewardsValue = if (fiatRate != null && cryptoRewardsValue != null) { @@ -48,14 +48,11 @@ internal class YieldBalancesConverter( } else { null } - val type = getRewardBlockType() - val pendingRewardsConstraints = stakeKitBalance?.balance?.items - ?.firstOrNull { it.type == BalanceType.REWARDS } - ?.pendingActionsConstraints - ?.firstOrNull { it.type == StakingActionType.CLAIM_REWARDS } + val type = getRewardBlockType(stakingBalance) + val pendingRewardsConstraints = getRewardConstraints(stakingBalance) InnerYieldBalanceState.Data( - integrationId = stakeKitBalance?.stakingId?.integrationId, + integrationId = stakingBalance?.stakingId?.integrationId, reward = YieldReward( rewardsCrypto = cryptoRewardsValue.format { crypto(cryptoCurrency) }, rewardsFiat = fiatRewardsValue.format { @@ -68,24 +65,32 @@ internal class YieldBalancesConverter( rewardConstraints = pendingRewardsConstraints, ), isActionable = type.isActionable, - balances = balanceToShowItems.mapBalances(), + balances = balanceEntries.mapBalances(), ) } else { - // TODO p2p InnerYieldBalanceState.Empty } } - private fun List.mapBalances() = asSequence() - .filterNot { it.amount.isZero() || it.type == BalanceType.REWARDS } - .mapNotNull(balanceItemConverter::convert) + private fun List.mapBalances() = asSequence() + .filterNot { it.amount.isZero() || it.type == StakingEntryType.REWARDS } + .mapNotNull(balanceEntryConverter::convert) .sortedByDescending { it.cryptoAmount } .sortedBy { it.type.order } .toPersistentList() - private fun getRewardBlockType(): RewardBlockType { + private fun getRewardBlockType(stakingBalance: StakingBalance?): RewardBlockType { val blockchainId = cryptoCurrencyStatus.currency.network.rawId - val stakeKitBalance = cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit + + if (stakingBalance is StakingBalance.Data.P2PEthPool) { + return if (isStakingRewardUnavailable(blockchainId)) { + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + } else { + RewardBlockType.NoRewards + } + } + + val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit val rewards = stakeKitBalance?.balance?.items ?.filter { it.type == BalanceType.REWARDS && !it.amount.isZero() } @@ -105,4 +110,11 @@ internal class YieldBalancesConverter( else -> RewardBlockType.NoRewards } } + + private fun getRewardConstraints(stakingBalance: StakingBalance?) = + (stakingBalance as? StakingBalance.Data.StakeKit) + ?.balance?.items + ?.firstOrNull { it.type == BalanceType.REWARDS } + ?.pendingActionsConstraints + ?.firstOrNull { it.type == StakingActionType.CLAIM_REWARDS } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index f1404c4a63..730f8ffad1 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -17,7 +17,7 @@ import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.staking.BalanceItem +import com.tangem.domain.models.staking.StakingBalanceEntry import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.StakingTarget @@ -49,7 +49,7 @@ internal class SetInitialDataStateTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val userWalletProvider: Provider, private val appCurrencyProvider: Provider, - private val balancesToShowProvider: Provider>, + private val balancesToShowProvider: Provider>, private val isAccountsModeEnabled: Boolean, private val account: Account.CryptoPortfolio?, private val isBalanceHidden: Boolean, From 46a4a937113f7968fe31f32e888cf3b6ecdac5d2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Dec 2025 13:19:26 +0500 Subject: [PATCH 029/172] Updated on 2026-08-14 --- .../tangem/core/ui/components/chip/Chip.kt | 122 ++++++++++++++ .../core/ui/components/chip/entity/ChipUM.kt | 10 ++ .../tangem/core/ui/components/label/Label.kt | 4 +- .../news/news-details/api/build.gradle.kts | 2 + .../news/details/api/NewsDetailsComponent.kt | 12 ++ .../impl/DefaultNewsDetailsComponent.kt | 32 +++- .../details/impl/ui/NewsDetailsContent.kt | 84 +++++----- features/news/news-list/api/.gitignore | 1 + features/news/news-list/api/build.gradle.kts | 20 +++ .../news/list/api/NewsListComponent.kt | 23 +++ features/news/news-list/impl/.gitignore | 1 + features/news/news-list/impl/build.gradle.kts | 46 ++++++ .../list/impl/DefaultNewsListComponent.kt | 62 +++++++ .../features/news/list/impl/NewsListModel.kt | 81 ++++++++++ .../news/list/impl/di/NewsListModule.kt | 18 +++ .../news/list/impl/ui/NewsListContent.kt | 152 ++++++++++++++++++ .../features/news/list/impl/ui/NewsListUM.kt | 14 ++ features/tester/impl/build.gradle.kts | 2 + .../tester/presentation/TesterActivity.kt | 42 +++++ .../presentation/navigation/TesterScreen.kt | 1 + .../tester/presentation/news/state/NewsUM.kt | 1 + .../news/viewmodel/NewsViewModel.kt | 2 + .../impl/src/main/res/values/strings.xml | 1 + settings.gradle.kts | 3 + 24 files changed, 693 insertions(+), 43 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/chip/Chip.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/chip/entity/ChipUM.kt create mode 100644 features/news/news-list/api/.gitignore create mode 100644 features/news/news-list/api/build.gradle.kts create mode 100644 features/news/news-list/api/src/main/kotlin/com/tangem/features/news/list/api/NewsListComponent.kt create mode 100644 features/news/news-list/impl/.gitignore create mode 100644 features/news/news-list/impl/build.gradle.kts create mode 100644 features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/DefaultNewsListComponent.kt create mode 100644 features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/NewsListModel.kt create mode 100644 features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/di/NewsListModule.kt create mode 100644 features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui/NewsListContent.kt create mode 100644 features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui/NewsListUM.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/chip/Chip.kt b/core/ui/src/main/java/com/tangem/core/ui/components/chip/Chip.kt new file mode 100644 index 0000000000..fb14a4a72e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/chip/Chip.kt @@ -0,0 +1,122 @@ +package com.tangem.core.ui.components.chip + +import android.content.res.Configuration +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +fun Chip(state: ChipUM, modifier: Modifier = Modifier) { + val backgroundColor by animateColorAsState( + targetValue = if (state.isSelected) { + TangemTheme.colors.button.primary + } else { + TangemTheme.colors.button.secondary + }, + ) + + val textColor by animateColorAsState( + targetValue = if (state.isSelected) { + TangemTheme.colors.text.primary2 + } else { + TangemTheme.colors.text.primary1 + }, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(color = backgroundColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = state.onClick, + ) + .padding(PaddingValues(horizontal = 24.dp, vertical = 8.dp)), + ) { + Text( + text = state.text.resolveReference(), + style = TangemTheme.typography.button, + color = textColor, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ChipPreview() { + TangemThemePreview { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .background(TangemTheme.colors.background.primary) + .padding(16.dp), + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Chip( + state = ChipUM( + id = 0, + text = TextReference.Str("All News"), + isSelected = true, + onClick = {}, + ), + ) + Chip( + state = ChipUM( + id = 1, + text = TextReference.Str("Regulation"), + isSelected = false, + onClick = {}, + ), + ) + Chip( + state = ChipUM( + id = 2, + text = TextReference.Str("ETFs"), + isSelected = false, + onClick = {}, + ), + ) + Chip( + state = ChipUM( + id = 3, + text = TextReference.Str("Institutions"), + isSelected = false, + onClick = {}, + ), + ) + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/chip/entity/ChipUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/chip/entity/ChipUM.kt new file mode 100644 index 0000000000..a2785cb42b --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/chip/entity/ChipUM.kt @@ -0,0 +1,10 @@ +package com.tangem.core.ui.components.chip.entity + +import com.tangem.core.ui.extensions.TextReference + +data class ChipUM( + val id: Int, + val text: TextReference, + val isSelected: Boolean = false, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt b/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt index 0f6b07e0d1..4b3d1db8d6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt @@ -168,7 +168,9 @@ private fun LabelPreview() { TangemThemePreview { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(16.dp), + modifier = Modifier + .background(TangemTheme.colors.background.primary) + .padding(16.dp), ) { FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/features/news/news-details/api/build.gradle.kts b/features/news/news-details/api/build.gradle.kts index b68f6815c6..a9c29a2049 100644 --- a/features/news/news-details/api/build.gradle.kts +++ b/features/news/news-details/api/build.gradle.kts @@ -9,6 +9,8 @@ android { } dependencies { + implementation(deps.compose.foundation) + /* Project - Core */ implementation(projects.core.decompose) implementation(projects.core.ui) diff --git a/features/news/news-details/api/src/main/kotlin/com/tangem/features/news/details/api/NewsDetailsComponent.kt b/features/news/news-details/api/src/main/kotlin/com/tangem/features/news/details/api/NewsDetailsComponent.kt index 167f65b88f..56d379faf0 100644 --- a/features/news/news-details/api/src/main/kotlin/com/tangem/features/news/details/api/NewsDetailsComponent.kt +++ b/features/news/news-details/api/src/main/kotlin/com/tangem/features/news/details/api/NewsDetailsComponent.kt @@ -1,11 +1,23 @@ package com.tangem.features.news.details.api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableContentComponent interface NewsDetailsComponent : ComposableContentComponent { data class Params(val selectedArticleId: Int = 0) + @Composable + fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) + interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/DefaultNewsDetailsComponent.kt b/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/DefaultNewsDetailsComponent.kt index d68562beb6..98c8989e54 100644 --- a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/DefaultNewsDetailsComponent.kt +++ b/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/DefaultNewsDetailsComponent.kt @@ -1,11 +1,15 @@ package com.tangem.features.news.details.impl +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.features.news.details.api.NewsDetailsComponent import com.tangem.features.news.details.impl.ui.NewsDetailsContent import dagger.assisted.Assisted @@ -19,16 +23,40 @@ internal class DefaultNewsDetailsComponent @AssistedInject constructor( private val model: NewsDetailsModel = getOrCreateModel(params) + @Composable + override fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) { + val uiState by model.uiState.collectAsStateWithLifecycle() + val bsState by bottomSheetState + + BackHandler(enabled = bsState == BottomSheetState.EXPANDED) { + navigateBack() + } + + NewsDetailsContent( + state = uiState, + onBackClick = ::navigateBack, + modifier = modifier, + isBottomSheetMode = true, + ) + } + @Composable override fun Content(modifier: Modifier) { - val uiState by model.uiState.collectAsState() + val uiState by model.uiState.collectAsStateWithLifecycle() NewsDetailsContent( state = uiState, onBackClick = model::onBackClick, modifier = modifier, + isBottomSheetMode = false, ) } + private fun navigateBack() = router.pop() + @AssistedFactory interface Factory : NewsDetailsComponent.Factory { override fun create( diff --git a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsContent.kt b/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsContent.kt index a149f57bf4..a2c5e93a36 100644 --- a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsContent.kt +++ b/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsContent.kt @@ -24,13 +24,19 @@ import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.buttons.common.TangemButtonSize import com.tangem.core.ui.components.pager.PagerIndicator +import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.news.details.impl.MockArticlesFactory // TODO [REDACTED_TASK_KEY] make internal @Composable -fun NewsDetailsContent(state: NewsDetailsUM, onBackClick: () -> Unit, modifier: Modifier = Modifier) { +fun NewsDetailsContent( + state: NewsDetailsUM, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + isBottomSheetMode: Boolean = false, +) { val pagerState = rememberPagerState( initialPage = state.selectedArticleIndex, pageCount = { state.articles.size }, @@ -39,49 +45,47 @@ fun NewsDetailsContent(state: NewsDetailsUM, onBackClick: () -> Unit, modifier: Column( modifier = modifier .fillMaxSize() - .background(TangemTheme.colors.background.secondary) - .systemBarsPadding(), + .background(TangemTheme.colors.background.tertiary) + .conditionalCompose(isBottomSheetMode) { systemBarsPadding() }, ) { - Column { - TangemTopAppBar( - title = null, - startButton = TopAppBarButtonUM.Icon( - iconRes = R.drawable.ic_back_24, - onClicked = onBackClick, - ), - endButton = TopAppBarButtonUM.Icon( - iconRes = R.drawable.ic_share_24, - onClicked = state.onShareClick, - ), - ) - Box( - modifier = Modifier.fillMaxSize(), - ) { - if (state.articles.isNotEmpty()) { - HorizontalPager( - state = pagerState, + TangemTopAppBar( + title = null, + startButton = TopAppBarButtonUM.Icon( + iconRes = R.drawable.ic_back_24, + onClicked = onBackClick, + ), + endButton = TopAppBarButtonUM.Icon( + iconRes = R.drawable.ic_share_24, + onClicked = state.onShareClick, + ), + ) + Box( + modifier = Modifier.fillMaxSize(), + ) { + if (state.articles.isNotEmpty()) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + ArticleDetail( + article = state.articles[page], modifier = Modifier.fillMaxSize(), - ) { page -> - ArticleDetail( - article = state.articles[page], - modifier = Modifier.fillMaxSize(), - onLikeClick = state.onLikeClick, - ) - } + onLikeClick = state.onLikeClick, + ) + } - if (state.articles.size > 1) { - Column( + if (state.articles.size > 1) { + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + ) { + PagerIndicator( + pagerState = pagerState, modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth(), - ) { - PagerIndicator( - pagerState = pagerState, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(bottom = 16.dp), - ) - } + .align(Alignment.CenterHorizontally) + .padding(bottom = 16.dp), + ) } } } diff --git a/features/news/news-list/api/.gitignore b/features/news/news-list/api/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/news/news-list/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/news/news-list/api/build.gradle.kts b/features/news/news-list/api/build.gradle.kts new file mode 100644 index 0000000000..2de4c641e7 --- /dev/null +++ b/features/news/news-list/api/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.news.list.api" +} + +dependencies { + implementation(deps.compose.foundation) + + /* Project - Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /* Compose */ + implementation(deps.compose.runtime) +} \ No newline at end of file diff --git a/features/news/news-list/api/src/main/kotlin/com/tangem/features/news/list/api/NewsListComponent.kt b/features/news/news-list/api/src/main/kotlin/com/tangem/features/news/list/api/NewsListComponent.kt new file mode 100644 index 0000000000..225a75a329 --- /dev/null +++ b/features/news/news-list/api/src/main/kotlin/com/tangem/features/news/list/api/NewsListComponent.kt @@ -0,0 +1,23 @@ +package com.tangem.features.news.list.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface NewsListComponent : ComposableContentComponent { + + data class Params(val selectedFilter: String? = null) + + @Composable + fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/news/news-list/impl/.gitignore b/features/news/news-list/impl/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/news/news-list/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/news/news-list/impl/build.gradle.kts b/features/news/news-list/impl/build.gradle.kts new file mode 100644 index 0000000000..baf05ca033 --- /dev/null +++ b/features/news/news-list/impl/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.news.list.impl" +} + +dependencies { + /* AndroidX */ + implementation(deps.lifecycle.compose) + implementation(deps.androidx.activity.compose) + + /** Compose */ + implementation(deps.compose.foundation) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.compose.material3) + + /** Core modules */ + implementation(projects.core.ui) + implementation(projects.core.utils) + implementation(projects.core.decompose) + implementation(projects.common.ui) + implementation(projects.common.routing) + + /** Feature modules */ + implementation(projects.features.news.newsList.api) + + /** Domain modules */ + implementation(projects.domain.models) + implementation(projects.domain.news) + + /** Other dependencies */ + implementation(deps.kotlin.immutable.collections) + implementation(deps.arrow.core) + implementation(deps.timber) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/DefaultNewsListComponent.kt b/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/DefaultNewsListComponent.kt new file mode 100644 index 0000000000..1ed37bdc4e --- /dev/null +++ b/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/DefaultNewsListComponent.kt @@ -0,0 +1,62 @@ +package com.tangem.features.news.list.impl + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.features.news.list.api.NewsListComponent +import com.tangem.features.news.list.impl.ui.NewsListContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultNewsListComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: NewsListComponent.Params, +) : NewsListComponent, AppComponentContext by context { + + private val model: NewsListModel = getOrCreateModel(params) + + @Composable + override fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) { + val uiState by model.uiState.collectAsStateWithLifecycle() + val bsState by bottomSheetState + + BackHandler(enabled = bsState == BottomSheetState.EXPANDED) { + navigateBack() + } + + NewsListContent( + state = uiState, + onBackClick = ::navigateBack, + modifier = modifier, + ) + } + + @Composable + override fun Content(modifier: Modifier) { + val uiState by model.uiState.collectAsStateWithLifecycle() + NewsListContent( + state = uiState, + onBackClick = model::onBackClick, + modifier = modifier, + ) + } + + private fun navigateBack() = router.pop() + + @AssistedFactory + interface Factory : NewsListComponent.Factory { + override fun create(context: AppComponentContext, params: NewsListComponent.Params): DefaultNewsListComponent + } +} \ No newline at end of file diff --git a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/NewsListModel.kt b/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/NewsListModel.kt new file mode 100644 index 0000000000..18c010aaac --- /dev/null +++ b/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/NewsListModel.kt @@ -0,0 +1,81 @@ +package com.tangem.features.news.list.impl + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase +import com.tangem.features.news.list.impl.ui.NewsListUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +internal class NewsListModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val getNewsCategoriesUseCase: GetNewsCategoriesUseCase, + private val router: Router, +) : Model() { + + val uiState: StateFlow + field = MutableStateFlow( + NewsListUM( + selectedCategoryId = 0, + filters = persistentListOf(), + articles = persistentListOf(), + onArticleClick = ::onArticleClick, + ), + ) + + init { + modelScope.launch(dispatchers.default) { + val filterChips = getNewsCategoriesUseCase + .invoke() + .map { articleCategory -> + ChipUM( + id = articleCategory.id, + text = TextReference.Str(articleCategory.name), + isSelected = false, + onClick = { + onCategoryClick(articleCategory.id) + }, + ) + } + .toImmutableList() + uiState.update { currentState -> + currentState.copy(filters = filterChips) + } + } + } + + fun onBackClick() { + router.pop() + } + + private fun onArticleClick(articleId: Int) { + // TODO [REDACTED_TASK_KEY] + articleId + } + + private fun onCategoryClick(categoryId: Int) { + uiState.update { currentState -> + currentState.copy( + selectedCategoryId = categoryId, + filters = updateFilterChips(categoryId), + ) + } + } + + private fun updateFilterChips(categoryId: Int): ImmutableList { + return uiState.value.filters.map { chip -> + chip.copy(isSelected = chip.id == categoryId) + }.toImmutableList() + } +} \ No newline at end of file diff --git a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/di/NewsListModule.kt b/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/di/NewsListModule.kt new file mode 100644 index 0000000000..b3d323d053 --- /dev/null +++ b/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/di/NewsListModule.kt @@ -0,0 +1,18 @@ +package com.tangem.features.news.list.impl.di + +import com.tangem.features.news.list.api.NewsListComponent +import com.tangem.features.news.list.impl.DefaultNewsListComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface NewsListModule { + + @Binds + @Singleton + fun bindNewsListComponentFactory(factory: DefaultNewsListComponent.Factory): NewsListComponent.Factory +} \ No newline at end of file diff --git a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui/NewsListContent.kt b/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui/NewsListContent.kt new file mode 100644 index 0000000000..ea8a333901 --- /dev/null +++ b/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui/NewsListContent.kt @@ -0,0 +1,152 @@ +package com.tangem.features.news.list.impl.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.news.ArticleCard +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.components.chip.Chip +import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableSet + +@Composable +internal fun NewsListContent(state: NewsListUM, onBackClick: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .background(color = TangemTheme.colors.background.tertiary), + ) { + AppBarWithBackButton( + text = stringResourceSafe(R.string.common_news), + onBackClick = onBackClick, + ) + + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = state.filters, + key = { it.id }, + ) { filter -> + Chip(state = filter) + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + ) { + items( + items = state.articles, + key = ArticleConfigUM::id, + ) { article -> + ArticleCard( + articleConfigUM = article, + onArticleClick = { state.onArticleClick(article.id) }, + modifier = Modifier + .fillMaxWidth() + .height(164.dp), + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), + ) + SpacerH(12.dp) + } + } + } +} + +@Suppress("LongMethod") +@Preview(showBackground = true) +@Composable +private fun NewsListContentPreview() { + val tags = listOf( + LabelUM(TextReference.Str("Regulation")), + LabelUM(TextReference.Str("BTC")), + LabelUM(TextReference.Str("ETH")), + ).toImmutableSet() + + val articles = persistentListOf( + ArticleConfigUM( + id = 1, + title = "SEC delays decisions on ETH-staking ETFs and spot XRP/SOL funds", + score = 6.5f, + createdAt = TextReference.Str("1h ago"), + isTrending = false, + tags = tags, + isViewed = false, + ), + ArticleConfigUM( + id = 2, + title = "Bitcoin ETFs log 4th straight day of inflows (+\$550M)", + score = 8.6f, + createdAt = TextReference.Str("8h ago"), + isTrending = false, + tags = tags, + isViewed = true, + ), + ArticleConfigUM( + id = 3, + title = "Bitcoin reclaims ~\$115K amid macro prints and ETF optimism", + score = 7.8f, + createdAt = TextReference.Str("22 Jun, 11:30"), + isTrending = false, + tags = tags, + isViewed = false, + ), + ) + + val filters = persistentListOf( + ChipUM( + id = 0, + text = TextReference.Str("All News"), + isSelected = true, + onClick = {}, + ), + ChipUM( + id = 1, + text = TextReference.Str("Regulation"), + isSelected = false, + onClick = {}, + ), + ChipUM( + id = 2, + text = TextReference.Str("ETFs"), + isSelected = false, + onClick = {}, + ), + ChipUM( + id = 3, + text = TextReference.Str("Institutions"), + isSelected = false, + onClick = {}, + ), + ) + + TangemThemePreview { + NewsListContent( + state = NewsListUM( + selectedCategoryId = 0, + filters = filters, + articles = articles, + onArticleClick = {}, + ), + onBackClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui/NewsListUM.kt b/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui/NewsListUM.kt new file mode 100644 index 0000000000..d3e853c285 --- /dev/null +++ b/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui/NewsListUM.kt @@ -0,0 +1,14 @@ +package com.tangem.features.news.list.impl.ui + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.core.ui.components.chip.entity.ChipUM +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class NewsListUM( + val selectedCategoryId: Int?, + val filters: ImmutableList, + val articles: ImmutableList, + val onArticleClick: (Int) -> Unit, +) \ No newline at end of file diff --git a/features/tester/impl/build.gradle.kts b/features/tester/impl/build.gradle.kts index ba40b749c4..92a8671ee4 100644 --- a/features/tester/impl/build.gradle.kts +++ b/features/tester/impl/build.gradle.kts @@ -61,6 +61,8 @@ dependencies { implementation(projects.features.pushNotifications.api) implementation(projects.features.news.newsDetails.api) implementation(projects.features.news.newsDetails.impl) + implementation(projects.features.news.newsList.api) + implementation(projects.features.news.newsList.impl) /* SDK */ implementation(tangemDeps.blockchain) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt index 393fac33ad..333c9fd9e6 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt @@ -14,6 +14,9 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.tangem.common.routing.AppRouter import com.tangem.core.navigation.finisher.AppFinisher import com.tangem.core.ui.UiDependencies +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.screen.ComposeActivity import com.tangem.feature.tester.presentation.accounts.ui.AccountsScreen @@ -38,9 +41,11 @@ import com.tangem.feature.tester.presentation.testpush.viewmodel.TestPushViewMod import com.tangem.feature.tester.presentation.news.ui.NewsScreen import com.tangem.feature.tester.presentation.news.viewmodel.NewsViewModel import com.tangem.features.news.details.impl.MockArticlesFactory +import com.tangem.features.news.details.impl.ui.ArticleUM import com.tangem.features.news.details.impl.ui.NewsDetailsContent import com.tangem.features.news.details.impl.ui.NewsDetailsUM import dagger.hilt.android.AndroidEntryPoint +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentSetOf import javax.inject.Inject @@ -190,6 +195,43 @@ internal class TesterActivity : ComposeActivity() { onBackClick = { innerTesterRouter.back() }, ) } + + composable(route = TesterScreen.NEWS_DETAILS_BOTTOM_SHEET.name) { + NewsDetailsBottomSheetTest( + onDismiss = { innerTesterRouter.back() }, + ) + } + } + } + + @Composable + private fun NewsDetailsBottomSheetTest(onDismiss: () -> Unit) { + data class NewsDetailsBottomSheetContent( + val articles: ImmutableList, + ) : TangemBottomSheetConfigContent + + val content = NewsDetailsBottomSheetContent( + articles = MockArticlesFactory.createMockArticles(), + ) + + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismiss, + content = content, + ), + containerColor = TangemTheme.colors.background.tertiary, + ) { sheetContent -> + NewsDetailsContent( + state = NewsDetailsUM( + articles = sheetContent.articles, + selectedArticleIndex = 0, + onLikeClick = { }, + onShareClick = { }, + ), + onBackClick = onDismiss, + isBottomSheetMode = true, + ) } } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt index 854706cec2..8d06310e39 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt @@ -16,4 +16,5 @@ internal enum class TesterScreen { ACCOUNTS, NEWS, NEWS_DETAILS, + NEWS_DETAILS_BOTTOM_SHEET, } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/state/NewsUM.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/state/NewsUM.kt index b832b363d8..43c8c9efca 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/state/NewsUM.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/state/NewsUM.kt @@ -12,5 +12,6 @@ data class NewsUM( enum class ButtonUM(@StringRes val textResId: Int) { NEWS_DETAILS(R.string.news_details), + NEWS_DETAILS_BOTTOM_SHEET(R.string.news_details_bottom_sheet), } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/viewmodel/NewsViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/viewmodel/NewsViewModel.kt index 5ed86201e4..f39c639ff5 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/viewmodel/NewsViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/viewmodel/NewsViewModel.kt @@ -27,6 +27,7 @@ internal class NewsViewModel @Inject constructor() : ViewModel() { onBackClick = ::onBackClick, buttons = persistentSetOf( NewsUM.ButtonUM.NEWS_DETAILS, + NewsUM.ButtonUM.NEWS_DETAILS_BOTTOM_SHEET, ), onButtonClick = ::onButtonClick, ) @@ -39,6 +40,7 @@ internal class NewsViewModel @Inject constructor() : ViewModel() { private fun onButtonClick(button: NewsUM.ButtonUM) { when (button) { NewsUM.ButtonUM.NEWS_DETAILS -> router?.open(TesterScreen.NEWS_DETAILS) + NewsUM.ButtonUM.NEWS_DETAILS_BOTTOM_SHEET -> router?.open(TesterScreen.NEWS_DETAILS_BOTTOM_SHEET) } } } \ No newline at end of file diff --git a/features/tester/impl/src/main/res/values/strings.xml b/features/tester/impl/src/main/res/values/strings.xml index 15b31a07c6..901f76c14e 100644 --- a/features/tester/impl/src/main/res/values/strings.xml +++ b/features/tester/impl/src/main/res/values/strings.xml @@ -20,4 +20,5 @@ Accounts News News details + News details (Bottom Sheet) diff --git a/settings.gradle.kts b/settings.gradle.kts index 50802ec815..53bbc68e17 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -305,6 +305,9 @@ include(":features:feed:impl") include(":features:news:news-details:api") include(":features:news:news-details:impl") + +include(":features:news:news-list:api") +include(":features:news:news-list:impl") // endregion Feature modules // region Domain modules From 3767ae01b2505dd23bcf26ec59e1e58125780353 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Dec 2025 13:01:59 +0300 Subject: [PATCH 030/172] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 1 + .../com/tangem/domain/feedback/models/FeedbackEmailType.kt | 3 +++ .../com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt | 1 + .../tangem/domain/feedback/utils/EmailMessageBodyResolver.kt | 1 + .../tangem/domain/feedback/utils/EmailMessageTitleResolver.kt | 1 + .../com/tangem/domain/feedback/utils/EmailSubjectResolver.kt | 1 + .../impl/child/finalize/model/MultiWalletFinalizeModel.kt | 2 +- 7 files changed, 9 insertions(+), 1 deletion(-) diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 12311681da..2e2e66c3af 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -548,6 +548,7 @@ Please tell us what card or ring do you have Hi support team, Please tell us more about your issue. Every small detail can help. + Backup issue Previously activated wallet My suggestions Can\'t scan a card/ring diff --git a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt index 520f49e395..81aaaffd7a 100644 --- a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt +++ b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt @@ -18,6 +18,9 @@ sealed interface FeedbackEmailType { /** User rate the app as "can be better" */ data class RateCanBeBetter(override val walletMetaInfo: WalletMetaInfo) : FeedbackEmailType + /** User has problem with backup */ + data class BackupProblem(override val walletMetaInfo: WalletMetaInfo) : FeedbackEmailType + /** User has problem with scanning */ data object ScanningProblem : FeedbackEmailType { override val walletMetaInfo: WalletMetaInfo? = null diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt index 07e0eb137e..7bc3d5342a 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt @@ -97,6 +97,7 @@ class SendFeedbackEmailUseCase( is FeedbackEmailType.Visa.FeatureIsBeta, -> this is FeedbackEmailType.DirectUserRequest, + is FeedbackEmailType.BackupProblem, is FeedbackEmailType.RateCanBeBetter, is FeedbackEmailType.StakingProblem, is FeedbackEmailType.SwapProblem, diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt index 1e4ca44bd2..998110b7b7 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt @@ -28,6 +28,7 @@ internal class EmailMessageBodyResolver( is FeedbackEmailType.SwapProblem -> addSwapProblemBody(type) is FeedbackEmailType.CurrencyDescriptionError -> addTokenInfo(type) is FeedbackEmailType.PreActivatedWallet -> addUserRequestBody(type.walletMetaInfo) + is FeedbackEmailType.BackupProblem -> addUserRequestBody(type.walletMetaInfo) is FeedbackEmailType.ScanningProblem, is FeedbackEmailType.CardAttestationFailed, -> addPhoneInfoBody() diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt index 36940c948e..03d304ddb7 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt @@ -28,6 +28,7 @@ internal class EmailMessageTitleResolver(private val resources: Resources) { is FeedbackEmailType.Visa.Withdrawal, is FeedbackEmailType.Visa.FeatureIsBeta, is FeedbackEmailType.PreActivatedWallet, + is FeedbackEmailType.BackupProblem, -> R.string.feedback_preface_support is FeedbackEmailType.RateCanBeBetter -> R.string.feedback_preface_rate_negative is FeedbackEmailType.ScanningProblem -> R.string.feedback_preface_scan_failed diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt index 4b1e4f9799..ad9330b4bc 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt @@ -25,6 +25,7 @@ internal class EmailSubjectResolver(private val resources: Resources) { resources.getStringSafe(R.string.feedback_subject_support_tangem) } } + is FeedbackEmailType.BackupProblem -> resources.getStringSafe(R.string.feedback_subject_backup_problem) is FeedbackEmailType.RateCanBeBetter -> resources.getStringSafe(R.string.feedback_subject_rate_negative) is FeedbackEmailType.ScanningProblem -> resources.getStringSafe(R.string.feedback_subject_scan_failed) is FeedbackEmailType.TransactionSendingProblem, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt index 4c9424cf98..159ccf4bd9 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt @@ -390,7 +390,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( modelScope.launch { val cardInfo = getWalletMetaInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch - sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo)) + sendFeedbackEmailUseCase(FeedbackEmailType.BackupProblem(cardInfo)) } } From ee0e649e6ac20dcfab89812c5e2f1df4136ac44c Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Dec 2025 16:43:42 +0200 Subject: [PATCH 031/172] Updated on 2026-08-14 --- .../domain/staking/model/CooldownPeriod.kt | 8 ++++++ .../staking/model/P2PEthPoolIntegration.kt | 8 ++++-- .../staking/model/StakeKitIntegration.kt | 4 ++- .../staking/model/StakingIntegration.kt | 2 +- .../presentation/state/StakingNotification.kt | 12 +++----- .../StakingBalanceEntryConverter.kt | 5 ++-- .../SetInitialDataStateTransformer.kt | 9 ++---- .../StakingInfoNotificationsFactory.kt | 20 +++++-------- .../state/utils/CooldownPeriodUtils.kt | 28 +++++++++++++++++++ 9 files changed, 63 insertions(+), 33 deletions(-) create mode 100644 domain/staking/src/main/java/com/tangem/domain/staking/model/CooldownPeriod.kt create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/CooldownPeriodUtils.kt diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/CooldownPeriod.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/CooldownPeriod.kt new file mode 100644 index 0000000000..bc66de0687 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/CooldownPeriod.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.staking.model + +sealed class CooldownPeriod { + + data class Fixed(val days: Int) : CooldownPeriod() + + data class Range(val minDays: Int, val maxDays: Int) : CooldownPeriod() +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt index 052cb4509b..8842e9281c 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt @@ -64,7 +64,10 @@ class P2PEthPoolIntegration( override val warmupPeriodDays: Int = 0 - override val cooldownPeriodDays: Int = DEFAULT_COOLDOWN_DAYS + override val cooldownPeriod: CooldownPeriod = CooldownPeriod.Range( + minDays = MIN_COOLDOWN_DAYS, + maxDays = MAX_COOLDOWN_DAYS, + ) override val rewardSchedule: RewardSchedule = RewardSchedule.DAY @@ -73,7 +76,8 @@ class P2PEthPoolIntegration( override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = token companion object { - private const val DEFAULT_COOLDOWN_DAYS = 7 + private const val MIN_COOLDOWN_DAYS = 1 + private const val MAX_COOLDOWN_DAYS = 4 private val DEFAULT_MINIMUM_STAKE = BigDecimal("0.01") } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt index fa2d4542a7..540f4f4955 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt @@ -51,7 +51,9 @@ class StakeKitIntegration( override val warmupPeriodDays: Int = yield.metadata.warmupPeriod.days - override val cooldownPeriodDays: Int? = yield.metadata.cooldownPeriod?.days + override val cooldownPeriod: CooldownPeriod? = yield.metadata.cooldownPeriod?.days?.let { + CooldownPeriod.Fixed(it) + } override val rewardSchedule: RewardSchedule = yield.metadata.rewardSchedule.toRewardSchedule() diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt index 48f78ec663..b9b95360db 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt @@ -45,7 +45,7 @@ sealed interface StakingIntegration { val warmupPeriodDays: Int - val cooldownPeriodDays: Int? + val cooldownPeriod: CooldownPeriod? val rewardSchedule: RewardSchedule diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt index f1b90ef243..c126cd165c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt @@ -4,7 +4,9 @@ import androidx.annotation.StringRes import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.* +import com.tangem.domain.staking.model.CooldownPeriod import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.utils.toTextReference internal object StakingNotification { @@ -106,19 +108,13 @@ internal object StakingNotification { ) data class Unstake( - val cooldownPeriodDays: Int, + val cooldownPeriod: CooldownPeriod, @StringRes val subtitleRes: Int, ) : Info( title = resourceReference(R.string.common_unstake), subtitle = resourceReference( subtitleRes, - wrappedList( - pluralReference( - id = R.plurals.common_days, - count = cooldownPeriodDays, - formatArgs = wrappedList(cooldownPeriodDays), - ), - ), + wrappedList(cooldownPeriod.toTextReference()), ), ) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt index 45009e5529..3cfec3cb1c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt @@ -22,6 +22,7 @@ import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.StakingTarget import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.BalanceState +import com.tangem.features.staking.impl.presentation.state.utils.toTextReference import com.tangem.lib.crypto.BlockchainUtils import com.tangem.lib.crypto.BlockchainUtils.isTon import com.tangem.utils.Provider @@ -143,13 +144,13 @@ internal class StakingBalanceEntryConverter( } private fun getUnbondingDate(date: Instant?): TextReference? { - val unbondingPeriod = integration.cooldownPeriodDays ?: return null + val cooldownPeriod = integration.cooldownPeriod ?: return null if (date == null) { return TextReference.Combined( wrappedList( resourceReference(R.string.staking_details_unbonding_period), stringReference(" "), - pluralReference(R.plurals.common_days, unbondingPeriod, wrappedList(unbondingPeriod)), + cooldownPeriod.toTextReference(), ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index 730f8ffad1..c1340346e2 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -33,6 +33,7 @@ import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType import com.tangem.features.staking.impl.presentation.state.converters.RewardsValidatorStateConverter import com.tangem.features.staking.impl.presentation.state.converters.YieldBalancesConverter import com.tangem.features.staking.impl.presentation.state.utils.getRewardScheduleText +import com.tangem.features.staking.impl.presentation.state.utils.toTextReference import com.tangem.utils.Provider import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.isNullOrZero @@ -156,15 +157,11 @@ internal class SetInitialDataStateTransformer( } private fun createUnbondingPeriodItem(): RoundedListWithDividersItemData? { - val cooldownPeriodDays = integration.cooldownPeriodDays ?: return null + val cooldownPeriod = integration.cooldownPeriod ?: return null return RoundedListWithDividersItemData( id = R.string.staking_details_unbonding_period, startText = TextReference.Res(R.string.staking_details_unbonding_period), - endText = pluralReference( - id = R.plurals.common_days, - count = cooldownPeriodDays, - formatArgs = wrappedList(cooldownPeriodDays), - ), + endText = cooldownPeriod.toTextReference(), iconClick = { clickIntents.onInfoClick(InfoType.UNBONDING_PERIOD) }, ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt index 908f550629..8628070ef4 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt @@ -2,7 +2,6 @@ package com.tangem.features.staking.impl.presentation.state.transformers.notific import com.tangem.blockchain.common.Amount import com.tangem.common.ui.notifications.NotificationUM -import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto @@ -19,6 +18,7 @@ import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceStat import com.tangem.features.staking.impl.presentation.state.StakingNotification import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.utils.toTextReference import com.tangem.lib.crypto.BlockchainUtils.isCardano import com.tangem.lib.crypto.BlockchainUtils.isCosmos import com.tangem.lib.crypto.BlockchainUtils.isTon @@ -115,17 +115,11 @@ internal class StakingInfoNotificationsFactory( resourceReference(R.string.staking_notification_withdraw_text) } StakingActionType.UNLOCK_LOCKED -> { - val cooldownPeriodDays = integration.cooldownPeriodDays - if (cooldownPeriodDays != null) { + val cooldownPeriod = integration.cooldownPeriod + if (cooldownPeriod != null) { resourceReference(R.string.staking_unlocked_locked) to resourceReference( R.string.staking_notification_unlock_text, - wrappedList( - pluralReference( - id = R.plurals.common_days, - count = cooldownPeriodDays, - formatArgs = wrappedList(cooldownPeriodDays), - ), - ), + wrappedList(cooldownPeriod.toTextReference()), ) } else { null to null @@ -276,13 +270,13 @@ internal class StakingInfoNotificationsFactory( } private fun MutableList.addUnstakeInfoNotification() { - val cooldownPeriodDays = integration.cooldownPeriodDays + val cooldownPeriod = integration.cooldownPeriod val cryptoCurrencyNetworkIdValue = cryptoCurrencyStatusProvider().currency.network.rawId - if (cooldownPeriodDays != null) { + if (cooldownPeriod != null) { add( StakingNotification.Info.Unstake( - cooldownPeriodDays = cooldownPeriodDays, + cooldownPeriod = cooldownPeriod, subtitleRes = if (isCosmos(cryptoCurrencyNetworkIdValue)) { R.string.staking_notification_unstake_cosmos_text } else { diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/CooldownPeriodUtils.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/CooldownPeriodUtils.kt new file mode 100644 index 0000000000..f31d5d3b6e --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/CooldownPeriodUtils.kt @@ -0,0 +1,28 @@ +package com.tangem.features.staking.impl.presentation.state.utils + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.staking.model.CooldownPeriod +import com.tangem.features.staking.impl.R +import com.tangem.utils.StringsSigns.MINUS +import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE + +internal fun CooldownPeriod.toTextReference(): TextReference { + return when (this) { + is CooldownPeriod.Fixed -> pluralReference( + id = R.plurals.common_days, + count = days, + formatArgs = wrappedList(days), + ) + is CooldownPeriod.Range -> combinedReference( + stringReference("$minDays$MINUS$maxDays$NON_BREAKING_SPACE"), + pluralReference( + id = R.plurals.common_days_no_param, + count = maxDays, + ), + ) + } +} \ No newline at end of file From 636ae3ee50c91a7492826c3ce2e97e7b562e071c Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Dec 2025 16:52:11 +0200 Subject: [PATCH 032/172] Updated on 2026-08-14 --- .../staking/impl/presentation/state/StakingStateController.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt index 92676e87dd..a9164b0dca 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt @@ -17,10 +17,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import com.tangem.core.decompose.di.ModelScoped import javax.inject.Inject -import javax.inject.Singleton -@Singleton +@ModelScoped internal class StakingStateController @Inject constructor( urlOpener: UrlOpener, ) { From a71b7ed4ba90b51c8f8cedd9b90e56e2ce7c684f Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Dec 2025 11:05:04 +0100 Subject: [PATCH 033/172] Updated on 2026-08-14 --- .../com/tangem/common/ui/news/ArticleInfo.kt | 8 +- .../news/models/response/NewsArticleDto.kt | 11 -- .../models/response/NewsDetailsResponse.kt | 17 ++ .../tangem/data/news/repository/NewsMapper.kt | 11 +- .../domain/models/news/OriginalArticle.kt | 10 +- .../news/usecase/ObserveNewsDetailsUseCase.kt | 3 +- .../feed/entry/components/FeedEntryRoute.kt | 3 + .../components/DefaultFeedEntryComponent.kt | 57 ++++-- .../feed/components/FeedEntryChildFactory.kt | 5 +- .../DefaultMarketsTokenDetailsComponent.kt | 5 +- .../impl/model/TokenActionsHandler.kt | 44 +++-- .../portfolio/impl/ui/state/QuickActionUM.kt | 2 +- .../impl/ui/state/TokenActionsBSContentUM.kt | 2 +- .../details/DefaultNewsDetailsComponent.kt | 51 +++++- .../tangem/features/feed/di/ModelModule.kt | 6 + .../feed/model/feed/FeedComponentModel.kt | 13 +- .../feed/model/feed/FeedModelClickIntents.kt | 2 +- .../details/MarketsTokenDetailsModel.kt | 13 +- .../details/converter/RelatedNewsConverter.kt | 38 +--- .../model/market/list/MarketsListModel.kt | 4 +- .../model/news/details/NewsDetailsModel.kt | 93 ++++++++++ .../details/converter/NewsDetailsConverter.kt | 101 +++++++++++ .../tangem/features/feed/ui/feed/FeedList.kt | 6 +- .../ui/feed/state/TrendingNewsStateFactory.kt | 38 +--- .../feed/ui/market/list/MarketsList.kt | 8 +- .../ui/news/details}/NewsDetailsContent.kt | 166 +++++++++--------- .../details/state}/MockArticlesFactory.kt | 67 ++++--- .../ui/news/details/state}/NewsDetailsUM.kt | 26 +-- .../feed/ui/utils/CreatedTimeFormatter.kt | 40 +++++ .../news/news-details/api/build.gradle.kts | 20 --- .../news/details/api/NewsDetailsComponent.kt | 23 --- .../news/news-details/impl/build.gradle.kts | 43 ----- .../impl/DefaultNewsDetailsComponent.kt | 67 ------- .../news/details/impl/NewsDetailsModel.kt | 41 ----- .../news/details/impl/di/NewsDetailsModule.kt | 18 -- features/tester/impl/build.gradle.kts | 4 - .../tester/presentation/TesterActivity.kt | 70 -------- .../presentation/menu/state/TesterMenuUM.kt | 1 - .../presentation/navigation/TesterScreen.kt | 3 - .../tester/presentation/news/state/NewsUM.kt | 17 -- .../tester/presentation/news/ui/NewsScreen.kt | 62 ------- .../news/viewmodel/NewsViewModel.kt | 46 ----- settings.gradle.kts | 6 - 43 files changed, 590 insertions(+), 681 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt rename features/{news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui => feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details}/NewsDetailsContent.kt (63%) rename features/{news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl => feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state}/MockArticlesFactory.kt (80%) rename features/{news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui => feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state}/NewsDetailsUM.kt (52%) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt delete mode 100644 features/news/news-details/api/build.gradle.kts delete mode 100644 features/news/news-details/api/src/main/kotlin/com/tangem/features/news/details/api/NewsDetailsComponent.kt delete mode 100644 features/news/news-details/impl/build.gradle.kts delete mode 100644 features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/DefaultNewsDetailsComponent.kt delete mode 100644 features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/NewsDetailsModel.kt delete mode 100644 features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/di/NewsDetailsModule.kt delete mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/state/NewsUM.kt delete mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/ui/NewsScreen.kt delete mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/viewmodel/NewsViewModel.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleInfo.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleInfo.kt index f7cc656eae..a2834e2afb 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleInfo.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleInfo.kt @@ -1,7 +1,10 @@ package com.tangem.common.ui.news import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -9,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.res.TangemTheme @@ -49,6 +53,8 @@ internal fun ArticleInfo(score: Float, createdAt: String, modifier: Modifier = M Text( text = createdAt, style = TangemTheme.typography.subtitle2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, color = TangemTheme.colors.text.secondary, ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt index 292b3d4559..ef9e77a8f0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt @@ -27,15 +27,4 @@ data class NewsRelatedTokenDto( @Json(name = "id") val id: String, @Json(name = "symbol") val symbol: String, @Json(name = "name") val name: String, -) - -@JsonClass(generateAdapter = true) -data class NewsOriginalArticleDto( - @Json(name = "id") val id: Int, - @Json(name = "title") val title: String, - @Json(name = "sourceName") val sourceName: String, - @Json(name = "language") val language: String, - @Json(name = "publishedAt") val publishedAt: String, - @Json(name = "url") val url: String, - @Json(name = "imageUrl") val imageUrl: String? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt index 230c3f42fb..c3f8338224 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt @@ -17,4 +17,21 @@ data class NewsDetailsResponse( @Json(name = "shortContent") val shortContent: String, @Json(name = "content") val content: String, @Json(name = "originalArticles") val originalArticles: List, +) + +@JsonClass(generateAdapter = true) +data class NewsOriginalArticleDto( + @Json(name = "id") val id: Int, + @Json(name = "title") val title: String, + @Json(name = "source") val source: Source, + @Json(name = "language") val language: String, + @Json(name = "publishedAt") val publishedAt: String, + @Json(name = "url") val url: String, + @Json(name = "imageUrl") val imageUrl: String? = null, +) + +@JsonClass(generateAdapter = true) +data class Source( + @Json(name = "id") val id: Int, + @Json(name = "name") val name: String, ) \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt b/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt index 6f34dd3ed4..7f63a42814 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt @@ -4,11 +4,7 @@ import com.tangem.datasource.api.news.models.response.NewsArticleDto import com.tangem.datasource.api.news.models.response.NewsDetailsResponse import com.tangem.datasource.api.news.models.response.NewsOriginalArticleDto import com.tangem.datasource.api.news.models.response.NewsRelatedTokenDto -import com.tangem.domain.models.news.ArticleCategory -import com.tangem.domain.models.news.DetailedArticle -import com.tangem.domain.models.news.OriginalArticle -import com.tangem.domain.models.news.RelatedToken -import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.models.news.* internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle { return DetailedArticle( @@ -54,7 +50,10 @@ internal fun NewsOriginalArticleDto.toDomainOriginalArticle(): OriginalArticle { return OriginalArticle( id = id, title = title, - sourceName = sourceName, + source = Source( + id = source.id, + name = source.name, + ), locale = language, publishedAt = publishedAt, url = url, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/news/OriginalArticle.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/OriginalArticle.kt index d087148b79..83c59e20c0 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/news/OriginalArticle.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/OriginalArticle.kt @@ -8,7 +8,7 @@ import kotlinx.serialization.Serializable [REDACTED_AUTHOR] * @param id - unique identifier of the article * @param title - article title - * @param sourceName - name of original article source + * @param source - object of source name and identifier * @param locale - language of the article * @param publishedAt - date of article publishing * @param url - link to source of original article @@ -18,9 +18,15 @@ import kotlinx.serialization.Serializable data class OriginalArticle( val id: Int, val title: String, - val sourceName: String, + val source: Source, val locale: String, val publishedAt: String, val url: String, val imageUrl: String?, +) + +@Serializable +data class Source( + val id: Int, + val name: String, ) \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt index ea0dc371d9..373456dce2 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt @@ -1,5 +1,6 @@ package com.tangem.domain.news.usecase +import arrow.core.Either import com.tangem.domain.models.news.DetailedArticle import com.tangem.domain.news.repository.NewsRepository import kotlinx.coroutines.flow.Flow @@ -23,7 +24,7 @@ class ObserveNewsDetailsUseCase( /** * Prefetches the given article ids (can be called with current + next ids for pager preloading). */ - suspend fun prefetch(newsIds: Collection, language: String?) { + suspend fun prefetch(newsIds: Collection, language: String?): Either = Either.catch { repository.fetchDetailedArticles(newsIds, language) } } \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt index e6f0e3df45..7c9ffb7c0a 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt @@ -24,4 +24,7 @@ sealed interface FeedEntryRoute { @Serializable data object MarketTokenList : FeedEntryRoute + + @Serializable + data class NewsDetail(val articleId: Int, val preselectedArticlesId: List) : FeedEntryRoute } \ No newline at end of file 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 dead2e7970..75523f67d4 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 @@ -15,10 +15,13 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent +import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent import com.tangem.features.feed.entry.components.FeedEntryComponent import com.tangem.features.feed.entry.components.FeedEntryRoute import com.tangem.features.feed.model.feed.FeedModelClickIntents @@ -55,6 +58,9 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( source = "Market", ), onBackClicked = { onChildBack() }, + onArticleClick = { articleId, preselectedArticlesId -> + onArticleClick(articleId, preselectedArticlesId) + }, ), ), ) @@ -73,8 +79,16 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( ) } - override fun onArticleClick(articleId: Int) { - innerRouter.push(FeedEntryChildFactory.Child.NewsDetails) + override fun onArticleClick(articleId: Int, preselectedArticlesId: List) { + innerRouter.push( + FeedEntryChildFactory.Child.NewsDetails( + params = DefaultNewsDetailsComponent.Params( + articleId = articleId, + onBackClicked = { onChildBack() }, + preselectedArticlesId = preselectedArticlesId, + ), + ), + ) } override fun onOpenAllNews() { @@ -121,20 +135,25 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { - val bottomSheetState = remember { - derivedStateOf { BottomSheetState.EXPANDED } - } + val background = TangemTheme.colors.background.tertiary + CompositionLocalProvider( + LocalMainBottomSheetColor provides remember { mutableStateOf(background) }, + ) { + val bottomSheetState = remember { + derivedStateOf { BottomSheetState.EXPANDED } + } - BackHandler { - router.pop() - } + BackHandler { + router.pop() + } - EntryContent( - bottomSheetState = bottomSheetState, - stackState = stack.subscribeAsState(), - onHeaderSizeChange = {}, - isOpenedInBottomSheet = false, - ) + EntryContent( + bottomSheetState = bottomSheetState, + stackState = stack.subscribeAsState(), + onHeaderSizeChange = {}, + isOpenedInBottomSheet = false, + ) + } } private fun onChildBack() { @@ -157,6 +176,9 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( ) }, onBackClicked = { router.pop() }, + onArticleClick = { articleId, preselectedArticlesId -> + clickIntents.onArticleClick(articleId, preselectedArticlesId) + }, ), ) FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList( @@ -167,6 +189,13 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( shouldAlwaysShowSearchBar = false, ), ) + is FeedEntryRoute.NewsDetail -> FeedEntryChildFactory.Child.NewsDetails( + DefaultNewsDetailsComponent.Params( + articleId = entryRoute.articleId, + onBackClicked = { router.pop() }, + preselectedArticlesId = entryRoute.preselectedArticlesId, + ), + ) null -> FeedEntryChildFactory.Child.Feed } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index da730fb1a2..0a38fba062 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -43,7 +43,7 @@ internal class FeedEntryChildFactory @Inject constructor( @Serializable @Immutable - data object NewsDetails : Child + data class NewsDetails(val params: DefaultNewsDetailsComponent.Params) : Child } fun createChild( @@ -67,9 +67,10 @@ internal class FeedEntryChildFactory @Inject constructor( params = child.params, ) } - Child.NewsDetails -> { + is Child.NewsDetails -> { DefaultNewsDetailsComponent( appComponentContext = appComponentContext, + params = child.params, ) } Child.NewsList -> { 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 ed0f3f488a..31352a5aef 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 @@ -15,7 +15,6 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.res.LocalMainBottomSheetColor -import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams @@ -87,13 +86,14 @@ internal class DefaultMarketsTokenDetailsComponent( @Composable override fun Title(bottomSheetState: State) { val state by model.state.collectAsStateWithLifecycle() + val background = LocalMainBottomSheetColor.current.value MarketsTokenDetailsTopBar( onBackClick = { params.onBackClicked() }, isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED, shouldShowPriceSubtitle = state.shouldShowPriceSubtitle, tokenName = state.tokenName, tokenPrice = state.priceText, - backgroundColor = TangemTheme.colors.background.tertiary, + backgroundColor = background, ) } @@ -131,6 +131,7 @@ internal class DefaultMarketsTokenDetailsComponent( val shouldShowPortfolio: Boolean, val analyticsParams: AnalyticsParams?, val onBackClicked: () -> Unit, + val onArticleClick: (articleId: Int, preselectedArticlesId: List) -> Unit, ) @Serializable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt index d9abe7979e..0762d2602e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt @@ -20,6 +20,8 @@ import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.domain.tokens.model.details.NavigationAction +import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM import com.tangem.features.feed.impl.R @@ -41,6 +43,7 @@ internal class TokenActionsHandler @AssistedInject constructor( private val isDemoCardUseCase: IsDemoCardUseCase, private val messageSender: UiMessageSender, private val shareManager: ShareManager, + private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase, ) { private val disabledActionsInDemoMode = buildSet { @@ -186,22 +189,37 @@ internal class TokenActionsHandler @AssistedInject constructor( val (userWalletId, cryptoCurrencyStatus) = cryptoCurrencyData.let { currencyData -> currencyData.userWallet.walletId to currencyData.status } - if (cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true) { - router.push( - AppRoute.YieldSupplyActive( + val tokenEnterStatus = yieldSupplyEnterStatusUseCase(userWalletId, cryptoCurrencyStatus).getOrNull() + val isActiveYield = cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true + + when { + tokenEnterStatus != null -> router.push( + AppRoute.CurrencyDetails( userWalletId = userWalletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - apy = yieldSupplyApy, - ), - ) - } else { - router.push( - AppRoute.YieldSupplyPromo( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - apy = yieldSupplyApy, + currency = cryptoCurrencyStatus.currency, + navigationAction = NavigationAction.YieldSupply( + isActive = isActiveYield, + ), ), ) + isActiveYield -> { + router.push( + AppRoute.YieldSupplyActive( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + apy = yieldSupplyApy, + ), + ) + } + else -> { + router.push( + AppRoute.YieldSupplyPromo( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + apy = yieldSupplyApy, + ), + ) + } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt index d9ba4e54b8..c98c012df0 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt @@ -44,7 +44,7 @@ internal sealed class QuickActionUM( data class YieldMode( private val apy: String, ) : QuickActionUM( - title = resourceReference(R.string.yield_module_start_earning), + title = resourceReference(R.string.common_yield_mode), description = resourceReference(R.string.yield_module_main_screen_promo_banner_message, wrappedList(apy)), icon = R.drawable.ic_analytics_up_mini_24, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt index b02f6a8266..f35729eaa8 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt @@ -48,7 +48,7 @@ internal data class TokenActionsBSContentUM( iconRes = R.drawable.ic_staking_24, ), YieldMode( - text = resourceReference(R.string.yield_module_start_earning), + text = resourceReference(R.string.common_yield_mode), iconRes = R.drawable.ic_analytics_up_mini_24, ), ; diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt index 49913d39ef..004f1db029 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt @@ -2,18 +2,63 @@ package com.tangem.features.feed.components.news.details import androidx.compose.runtime.Composable import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.R +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.features.feed.model.news.details.NewsDetailsModel +import com.tangem.features.feed.ui.news.details.NewsDetailsContent +import kotlinx.serialization.Serializable internal class DefaultNewsDetailsComponent( appComponentContext: AppComponentContext, + private val params: Params, ) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { - @Composable - override fun Title(bottomSheetState: State) {} + private val newsDetailsModel = getOrCreateModel(params = params) @Composable - override fun Content(bottomSheetState: State, modifier: Modifier) {} + override fun Title(bottomSheetState: State) { + val background = LocalMainBottomSheetColor.current.value + val state by newsDetailsModel.state.collectAsStateWithLifecycle() + TangemTopAppBar( + containerColor = background, + title = null, + startButton = TopAppBarButtonUM.Icon( + iconRes = R.drawable.ic_back_24, + onClicked = state.onBackClick, + isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED, + ), + endButton = TopAppBarButtonUM.Icon( + iconRes = R.drawable.ic_share_24, + onClicked = state.onShareClick, + isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED, + ), + ) + } + + @Composable + override fun Content(bottomSheetState: State, modifier: Modifier) { + val state by newsDetailsModel.state.collectAsStateWithLifecycle() + NewsDetailsContent( + state = state, + modifier = modifier, + ) + } + + @Serializable + data class Params( + val articleId: Int, + val onBackClicked: () -> Unit, + val preselectedArticlesId: List = emptyList(), + val tokenIds: List = emptyList(), + val categoryIds: List = emptyList(), + ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt index 4d988a12de..31ca5d9dcf 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt @@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.features.feed.model.feed.FeedComponentModel import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel import com.tangem.features.feed.model.market.list.MarketsListModel +import com.tangem.features.feed.model.news.details.NewsDetailsModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -29,4 +30,9 @@ internal interface ModelModule { @IntoMap @ClassKey(MarketsTokenDetailsModel::class) fun provideMarketsTokenDetailsModel(model: MarketsTokenDetailsModel): Model + + @Binds + @IntoMap + @ClassKey(NewsDetailsModel::class) + fun provideNewsDetailsModel(model: NewsDetailsModel): Model } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 91d996e6ea..2fb7016135 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -31,6 +31,7 @@ import org.joda.time.DateTime import org.joda.time.DateTimeZone import javax.inject.Inject import kotlin.collections.all +import kotlin.collections.map @Stable @ModelScoped @@ -311,7 +312,17 @@ internal class FeedComponentModel @Inject constructor( params.feedClickIntents.onMarketOpenClick(sortBy) }, onArticleClick = { articleId -> - params.feedClickIntents.onArticleClick(articleId) + val trendingArticleId = state.value.trendingArticle?.id + params.feedClickIntents.onArticleClick( + articleId = articleId, + preselectedArticlesId = listOfNotNull(trendingArticleId) + + when (state.value.news.newsUMState) { + NewsUMState.CONTENT -> state.value.news.content.map { it.id } + NewsUMState.LOADING, + NewsUMState.ERROR, + -> emptyList() + }, + ) }, onOpenAllNews = { params.feedClickIntents.onOpenAllNews() diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt index 2cc84cf12f..66d705a427 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt @@ -10,6 +10,6 @@ import com.tangem.features.feed.model.market.list.state.SortByTypeUM internal interface FeedModelClickIntents { fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency) fun onMarketOpenClick(sortBy: SortByTypeUM?) - fun onArticleClick(articleId: Int) + fun onArticleClick(articleId: Int, preselectedArticlesId: List = emptyList()) fun onOpenAllNews() } \ No newline at end of file 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 5f5943a741..3f6bce01b5 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 @@ -241,9 +241,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( onShouldShowPriceSubtitleChange = ::onShouldShowPriceSubtitleChange, relatedNews = MarketsTokenDetailsUM.RelatedNews( articles = persistentListOf(), - onArticledClicked = { - // TODO in [REDACTED_JIRA] - }, + onArticledClicked = {}, ), ), ) @@ -313,9 +311,16 @@ internal class MarketsTokenDetailsModel @Inject constructor( ), ).onRight { articles -> state.update { marketsTokenDetailsUM -> + val relatedNews = relatedNewsConverter.convert(articles) marketsTokenDetailsUM.copy( relatedNews = marketsTokenDetailsUM.relatedNews.copy( - articles = relatedNewsConverter.convert(articles), + articles = relatedNews, + onArticledClicked = { articledId -> + params.onArticleClick( + /* articledId */ articledId, + /* preselectedIds */ relatedNews.map { it.id }, + ) + }, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt index 4a9b183de1..f3967a83fc 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt @@ -4,20 +4,14 @@ import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.WrappedList -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.utils.FormattedDate -import com.tangem.core.ui.utils.getFormattedDate import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.news.ShortArticle -import com.tangem.features.feed.impl.R -import com.tangem.utils.StringsSigns +import com.tangem.features.feed.ui.utils.mapFormattedDate import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentSet -import org.joda.time.DateTime class RelatedNewsConverter : Converter, ImmutableList> { @@ -51,34 +45,4 @@ class RelatedNewsConverter : Converter, ImmutableList
TextReference.Str(value = formattedDate.date) - is FormattedDate.HoursAgo -> TextReference.PluralRes( - id = R.plurals.news_published_hours_ago, - count = formattedDate.hours, - formatArgs = wrappedList(formattedDate.hours), - ) - is FormattedDate.MinutesAgo -> TextReference.PluralRes( - id = R.plurals.news_published_minutes_ago, - count = formattedDate.minutes, - formatArgs = wrappedList(formattedDate.minutes), - ) - is FormattedDate.Today -> TextReference.Combined( - refs = WrappedList( - data = listOf( - TextReference.Res(R.string.common_today), - TextReference.Str(StringsSigns.COMA_SIGN), - TextReference.Str(StringsSigns.WHITE_SPACE), - TextReference.Str(formattedDate.time), - ), - ), - ) - } - } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt index 323fe1b119..6780d5a73f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt @@ -122,7 +122,7 @@ internal class MarketsListModel @Inject constructor( flow4 = shouldShowYieldModeMarketPromoUseCase( appCurrency = currentAppCurrency.value, interval = marketsListUMStateManager.selectedInterval.toBatchRequestInterval(), - ), + ).conflate(), ) { uiItems, isInInitialLoadingErrorState, isSearchNotFoundState, isYieldModePromo -> MarketsItemsData( items = uiItems, @@ -138,7 +138,7 @@ internal class MarketsListModel @Inject constructor( flow3 = shouldShowYieldModeMarketPromoUseCase( appCurrency = currentAppCurrency.value, interval = marketsListUMStateManager.selectedInterval.toBatchRequestInterval(), - ), + ).conflate(), ) { uiItems, isInInitialLoadingErrorState, shouldShowYieldModePromo -> MarketsItemsData( items = uiItems, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt new file mode 100644 index 0000000000..7d8791f935 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt @@ -0,0 +1,93 @@ +package com.tangem.features.feed.model.news.details + +import androidx.compose.runtime.Stable +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.navigation.url.UrlOpener +import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase +import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent +import com.tangem.features.feed.model.news.details.converter.NewsDetailsConverter +import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.indexOfFirstOrNull +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.util.Locale +import javax.inject.Inject + +@Stable +@ModelScoped +@Suppress("LongParameterList") +internal class NewsDetailsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase, + private val urlOpener: UrlOpener, + paramsContainer: ParamsContainer, +) : Model() { + + private val params = paramsContainer.require() + + private val currentLanguage = Locale.getDefault().language + + private val converter = NewsDetailsConverter( + onSourceClick = { url -> + urlOpener.openUrl(url) + }, + ) + + private val _state = MutableStateFlow( + NewsDetailsUM( + articles = persistentListOf(), + selectedArticleIndex = 0, + onShareClick = { /* [REDACTED_TODO_COMMENT] */ }, + onLikeClick = { /* [REDACTED_TODO_COMMENT] */ }, + onBackClick = params.onBackClicked, + onArticleIndexChanged = { /* [REDACTED_TODO_COMMENT] */ }, + ), + ) + + val state: StateFlow = _state.asStateFlow() + + init { + if (params.preselectedArticlesId.isNotEmpty()) { + handlePreselectedArticles() + } else { + // TODO handle pagination [REDACTED_TASK_KEY] + } + } + + private fun handlePreselectedArticles() { + modelScope.launch { + observeNewsDetailsUseCase.prefetch( + newsIds = params.preselectedArticlesId, + language = currentLanguage, + ) + + observeNewsDetailsUseCase + .invoke() + .map { articlesMap -> + params.preselectedArticlesId.mapNotNull { articleId -> + articlesMap[articleId]?.let { detailedArticle -> + converter.convert(detailedArticle) + } + } + } + .map { articles -> + articles.toImmutableList() + } + .onEach { articles -> + val selectedIndex = articles.indexOfFirstOrNull { it.id == params.articleId } ?: 0 + _state.update { newsDetailsUM -> + newsDetailsUM.copy( + articles = articles, + selectedArticleIndex = selectedIndex, + ) + } + } + .launchIn(modelScope) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt new file mode 100644 index 0000000000..6a0fa54bae --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt @@ -0,0 +1,101 @@ +package com.tangem.features.feed.model.news.details.converter + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.FormattedDate +import com.tangem.core.ui.utils.getFormattedDate +import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.news.DetailedArticle +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.news.details.state.ArticleUM +import com.tangem.features.feed.ui.news.details.state.Source +import com.tangem.features.feed.ui.news.details.state.SourceUM +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import org.joda.time.DateTime + +@Stable +internal class NewsDetailsConverter( + private val onSourceClick: (String) -> Unit, +) : Converter { + + override fun convert(value: DetailedArticle): ArticleUM { + return ArticleUM( + id = value.id, + title = value.title, + createdAt = mapFormattedDate(value.createdAt), + score = value.score, + tags = buildTags(value), + shortContent = value.shortContent, + content = value.content, + sources = buildSources(value), + ) + } + + private fun buildTags(article: DetailedArticle): ImmutableList { + val categoryLabels = article.categories.map { category -> + LabelUM(text = TextReference.Str(category.name)) + } + val tokenLabels = article.relatedTokens.map { token -> + LabelUM( + text = TextReference.Str(token.symbol), + leadingContent = LabelLeadingContentUM.Token( + iconUrl = getTokenIconUrlFromDefaultHost( + tokenId = CryptoCurrency.RawID(token.id), + ), + ), + ) + } + return (categoryLabels + tokenLabels).toImmutableList() + } + + private fun buildSources(article: DetailedArticle): ImmutableList { + return article.originalArticles.map { originalArticle -> + SourceUM( + id = originalArticle.id, + title = originalArticle.title, + source = Source(id = originalArticle.source.id, name = originalArticle.source.name), + publishedAt = mapFormattedDate(originalArticle.publishedAt), + url = originalArticle.url, + onClick = { onSourceClick(originalArticle.url) }, + ) + }.toImmutableList() + } + + private fun mapFormattedDate(createdAt: String): TextReference { + val formattedDate = getFormattedDate( + createdAt = createdAt, + now = DateTime.now(), + ) + return when (formattedDate) { + is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date) + is FormattedDate.HoursAgo -> TextReference.PluralRes( + id = R.plurals.news_published_hours_ago, + count = formattedDate.hours, + formatArgs = wrappedList(formattedDate.hours), + ) + is FormattedDate.MinutesAgo -> TextReference.PluralRes( + id = R.plurals.news_published_minutes_ago, + count = formattedDate.minutes, + formatArgs = wrappedList(formattedDate.minutes), + ) + is FormattedDate.Today -> TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(R.string.common_today), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str(formattedDate.time), + ), + ), + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index d2822c4ba1..25a206dcb3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -357,7 +357,7 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, articleConfigUM = article, onArticleClick = { feedListCallbacks.onArticleClick(article.id) }, modifier = Modifier - .height(164.dp) + .heightIn(min = 164.dp) .width(216.dp), colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), ) @@ -365,7 +365,9 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, item { ShowMoreArticlesCard( - modifier = Modifier.size(width = 216.dp, height = 164.dp), + modifier = Modifier + .width(216.dp) + .heightIn(min = 164.dp), onClick = feedListCallbacks.onOpenAllNews, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt index c2a568ad85..f5c7fe446b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt @@ -4,22 +4,16 @@ import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.WrappedList -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.utils.FormattedDate -import com.tangem.core.ui.utils.getFormattedDate import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.news.ShortArticle import com.tangem.domain.models.news.TrendingNews -import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.utils.mapFormattedDate import com.tangem.utils.Provider -import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentSet -import org.joda.time.DateTime internal class TrendingNewsStateFactory( private val currentStateProvider: Provider, @@ -111,34 +105,4 @@ internal class TrendingNewsStateFactory( } return (categoryLabels + tokenLabels).toPersistentSet() } - - private fun mapFormattedDate(createdAt: String): TextReference { - val formattedDate = getFormattedDate( - createdAt = createdAt, - now = DateTime.now(), - ) - return when (formattedDate) { - is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date) - is FormattedDate.HoursAgo -> TextReference.PluralRes( - id = R.plurals.news_published_hours_ago, - count = formattedDate.hours, - formatArgs = wrappedList(formattedDate.hours), - ) - is FormattedDate.MinutesAgo -> TextReference.PluralRes( - id = R.plurals.news_published_minutes_ago, - count = formattedDate.minutes, - formatArgs = wrappedList(formattedDate.minutes), - ) - is FormattedDate.Today -> TextReference.Combined( - refs = WrappedList( - data = listOf( - TextReference.Res(R.string.common_today), - TextReference.Str(StringsSigns.COMA_SIGN), - TextReference.Str(StringsSigns.WHITE_SPACE), - TextReference.Str(formattedDate.time), - ), - ), - ) - } - } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt index 0268627666..f3b6f8abe0 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt @@ -142,14 +142,14 @@ private fun ColumnScope.Content(state: MarketsListUM, modifier: Modifier = Modif ) } - val marketsNotification = state.marketsNotificationUM AnimatedVisibility( - state.isInSearchMode.not() && + state.list !is ListUM.LoadingError && state.isInSearchMode.not() && state.selectedSortBy != SortByTypeUM.YieldSupply, ) { + val wrappedNotification = remember(this) { state.marketsNotificationUM } val showMore = stringResourceSafe(R.string.common_show_more) - when (marketsNotification) { + when (wrappedNotification) { is MarketsNotificationUM.YieldSupplyPromo -> { val description = stringResourceSafe( R.string.markets_yield_supply_banner_description, @@ -165,7 +165,7 @@ private fun ColumnScope.Content(state: MarketsListUM, modifier: Modifier = Modif } YieldSupplyInMarketsPromoNotification( - config = marketsNotification.config.copy( + config = wrappedNotification.config.copy( subtitle = clickableDescription, ), modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), diff --git a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt similarity index 63% rename from features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsContent.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt index a2c5e93a36..61ef4bc6c9 100644 --- a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt @@ -1,9 +1,13 @@ -package com.tangem.features.news.details.impl.ui +package com.tangem.features.feed.ui.news.details import android.content.res.Configuration import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape @@ -11,57 +15,57 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.common.ui.news.ArticleHeader import com.tangem.core.ui.R import com.tangem.core.ui.components.SecondaryButtonIconStart -import com.tangem.core.ui.components.appbar.TangemTopAppBar -import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.components.buttons.common.TangemButtonSize import com.tangem.core.ui.components.pager.PagerIndicator -import com.tangem.core.ui.extensions.conditionalCompose +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.news.details.impl.MockArticlesFactory +import com.tangem.features.feed.ui.news.details.state.ArticleUM +import com.tangem.features.feed.ui.news.details.state.MockArticlesFactory +import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM +import com.tangem.features.feed.ui.news.details.state.SourceUM -// TODO [REDACTED_TASK_KEY] make internal @Composable -fun NewsDetailsContent( - state: NewsDetailsUM, - onBackClick: () -> Unit, - modifier: Modifier = Modifier, - isBottomSheetMode: Boolean = false, -) { +internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modifier) { + val background = LocalMainBottomSheetColor.current.value val pagerState = rememberPagerState( initialPage = state.selectedArticleIndex, pageCount = { state.articles.size }, ) + LaunchedEffect(state.selectedArticleIndex) { + if (pagerState.currentPage != state.selectedArticleIndex) { + pagerState.scrollToPage(state.selectedArticleIndex) + } + } + + LaunchedEffect(pagerState.currentPage) { + if (pagerState.currentPage != state.selectedArticleIndex) { + state.onArticleIndexChanged(pagerState.currentPage) + } + } + Column( modifier = modifier .fillMaxSize() - .background(TangemTheme.colors.background.tertiary) - .conditionalCompose(isBottomSheetMode) { systemBarsPadding() }, + .background(background), ) { - TangemTopAppBar( - title = null, - startButton = TopAppBarButtonUM.Icon( - iconRes = R.drawable.ic_back_24, - onClicked = onBackClick, - ), - endButton = TopAppBarButtonUM.Icon( - iconRes = R.drawable.ic_share_24, - onClicked = state.onShareClick, - ), - ) - Box( - modifier = Modifier.fillMaxSize(), - ) { + Box(modifier = Modifier.fillMaxSize()) { if (state.articles.isNotEmpty()) { HorizontalPager( state = pagerState, @@ -73,20 +77,13 @@ fun NewsDetailsContent( onLikeClick = state.onLikeClick, ) } - if (state.articles.size > 1) { - Column( + PagerIndicator( + pagerState = pagerState, modifier = Modifier .align(Alignment.BottomCenter) - .fillMaxWidth(), - ) { - PagerIndicator( - pagerState = pagerState, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(bottom = 16.dp), - ) - } + .windowInsetsPadding(WindowInsets.navigationBars), + ) } } } @@ -97,27 +94,27 @@ fun NewsDetailsContent( @Composable private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onLikeClick: () -> Unit) { val density = LocalDensity.current - val pagerHeight = 48.dp - val contentPadding = 56.dp LazyColumn( - modifier = modifier.padding(horizontal = 16.dp), - contentPadding = PaddingValues( - bottom = contentPadding + pagerHeight + WindowInsets.navigationBars.getBottom(density).dp, - ), + modifier = modifier, + contentPadding = PaddingValues(bottom = 56.dp + WindowInsets.navigationBars.getBottom(density).dp), ) { item { ArticleHeader( title = article.title, - createdAt = article.createdAt, + createdAt = article.createdAt.resolveReference(), score = article.score, tags = article.tags, - modifier = Modifier.padding(top = 16.dp), + modifier = Modifier + .padding(top = 16.dp) + .padding(horizontal = 16.dp), ) if (article.shortContent.isNotEmpty()) { QuickRecap( content = article.shortContent, - modifier = Modifier.padding(top = 32.dp), + modifier = Modifier + .padding(top = 32.dp) + .padding(horizontal = 16.dp), ) } @@ -125,14 +122,17 @@ private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onL text = article.content, style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, - modifier = Modifier.padding(top = 16.dp), + modifier = Modifier + .padding(top = 16.dp) + .padding(horizontal = 16.dp), ) - Spacer(modifier = Modifier.height(24.dp)) + SpacerH(24.dp) SecondaryButtonIconStart( + modifier = Modifier.padding(horizontal = 16.dp), iconResId = R.drawable.ic_heart_20, - text = "Like", // TODO [REDACTED_TASK_KEY] export to strings + text = stringResourceSafe(R.string.news_like), size = TangemButtonSize.RoundedAction, onClick = onLikeClick, ) @@ -140,14 +140,16 @@ private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onL // TODO [REDACTED_TASK_KEY] add related tokens block if (article.sources.isNotEmpty()) { - Spacer(modifier = Modifier.height(24.dp)) - Row { + SpacerH(24.dp) + Row( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { Text( - text = "Sources", // TODO [REDACTED_TASK_KEY] export to strings + text = stringResourceSafe(R.string.news_sources), style = TangemTheme.typography.h3, color = TangemTheme.colors.text.primary1, ) - Spacer(modifier = Modifier.width(8.dp)) Text( text = "${article.sources.size}", style = TangemTheme.typography.h3, @@ -159,23 +161,19 @@ private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onL if (article.sources.isNotEmpty()) { item { - val sourcesPagerState = rememberPagerState( - pageCount = { article.sources.size }, - ) - Spacer(modifier = Modifier.height(12.dp)) - HorizontalPager( - state = sourcesPagerState, - modifier = Modifier - .fillMaxWidth(), - pageSpacing = 12.dp, - contentPadding = PaddingValues(horizontal = 0.dp), - ) { page -> - SourceItem( - source = article.sources[page], - modifier = Modifier.fillMaxWidth(), - ) + LazyRow( + modifier = Modifier.padding(vertical = 12.dp), + state = rememberLazyListState(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = article.sources, + key = SourceUM::id, + ) { source -> + SourceItem(source = source) + } } - Spacer(modifier = Modifier.height(12.dp)) } } } @@ -206,7 +204,7 @@ private fun QuickRecap(content: String, modifier: Modifier = Modifier) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Quick recap", // TODO [REDACTED_TASK_KEY] export to strings + text = stringResourceSafe(R.string.news_quick_recap), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.accent, ) @@ -225,10 +223,13 @@ private fun QuickRecap(content: String, modifier: Modifier = Modifier) { private fun SourceItem(source: SourceUM, modifier: Modifier = Modifier) { Column( modifier = modifier + .widthIn(max = 216.dp) + .heightIn(min = 132.dp) .background( - color = TangemTheme.colors.background.primary, + color = TangemTheme.colors.background.action, shape = RoundedCornerShape(12.dp), ) + .clickable(onClick = source.onClick) .padding(12.dp), ) { Row( @@ -239,25 +240,30 @@ private fun SourceItem(source: SourceUM, modifier: Modifier = Modifier) { painter = painterResource(id = R.drawable.ic_explore_16), contentDescription = null, tint = TangemTheme.colors.icon.informative, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(16.dp), ) - Spacer(modifier = Modifier.width(4.dp)) + SpacerW(4.dp) Text( - text = source.sourceName, + text = source.source.name, style = TangemTheme.typography.caption1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, color = TangemTheme.colors.text.tertiary, ) } if (source.title.isNotEmpty()) { + SpacerH(4.dp) Text( text = source.title, style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.primary1, + maxLines = 3, + overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = 12.dp), ) } Text( - text = source.publishedAt, + text = source.publishedAt.resolveReference(), style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, ) @@ -273,10 +279,10 @@ private fun PreviewNewsDetailsContent() { state = NewsDetailsUM( articles = MockArticlesFactory.createMockArticles(), selectedArticleIndex = 0, - onShareClick = { }, - onLikeClick = { }, + onShareClick = {}, + onLikeClick = {}, + onBackClick = {}, ), - onBackClick = { }, ) } } \ No newline at end of file diff --git a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/MockArticlesFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt similarity index 80% rename from features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/MockArticlesFactory.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt index ac35bb6ee4..03b4990886 100644 --- a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/MockArticlesFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt @@ -1,22 +1,19 @@ -package com.tangem.features.news.details.impl +package com.tangem.features.feed.ui.news.details.state import com.tangem.core.ui.components.label.entity.LabelSize import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference -import com.tangem.features.news.details.impl.ui.ArticleUM -import com.tangem.features.news.details.impl.ui.SourceUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList -// TODO [REDACTED_TASK_KEY] remove mock data @Suppress("MaximumLineLength", "LongMethod") -object MockArticlesFactory { +internal object MockArticlesFactory { fun createMockArticles(): ImmutableList = listOf( ArticleUM( id = 1, title = "SEC delays decisions on ETH-staking ETFs and spot XRP/SOL funds", - createdAt = "20 Jun, 21:45", + createdAt = TextReference.Str("20 Jun, 21:45"), score = 6.5f, tags = listOf( LabelUM(text = TextReference.Str("Regulation"), size = LabelSize.BIG), @@ -27,23 +24,31 @@ object MockArticlesFactory { SourceUM( id = 1, title = "Deeper liquidity could drive crypto market beyond \$6T", - sourceName = "cointelegraph", - publishedAt = "1h ago", + source = Source( + id = 11, + name = "Coin-telegraph", + ), + publishedAt = TextReference.Str("1h ago"), url = "https://cointelegraph.com", + onClick = {}, ), SourceUM( id = 2, title = "Top gainers and losers in crypto this week", - sourceName = "Investing", - publishedAt = "2h ago", + source = Source( + id = 10, + name = "Investing", + ), + publishedAt = TextReference.Str("2h ago"), url = "https://investing.com", + onClick = {}, ), ).toPersistentList(), ), ArticleUM( id = 2, title = "Bitcoin ETFs log 4th straight day of inflows (+\$550M)", - createdAt = "20 Jun, 20:15", + createdAt = TextReference.Str("20 Jun, 20:15"), score = 8.2f, tags = listOf( LabelUM(text = TextReference.Str("BTC"), size = LabelSize.BIG), @@ -55,16 +60,20 @@ object MockArticlesFactory { SourceUM( id = 3, title = "Bitcoin ETFs see massive inflows", - sourceName = "Bloomberg", - publishedAt = "3h ago", + source = Source( + id = 12, + name = "Bloomberg", + ), + publishedAt = TextReference.Str("3h ago"), url = "https://bloomberg.com", + onClick = {}, ), ).toPersistentList(), ), ArticleUM( id = 3, title = "Ethereum network upgrade scheduled for Q2 2025", - createdAt = "20 Jun, 18:30", + createdAt = TextReference.Str("20 Jun, 18:30"), score = 7.8f, tags = listOf( LabelUM(text = TextReference.Str("ETH"), size = LabelSize.BIG), @@ -76,16 +85,20 @@ object MockArticlesFactory { SourceUM( id = 4, title = "Ethereum core devs announce upgrade", - sourceName = "CoinDesk", - publishedAt = "5h ago", + source = Source( + id = 15, + name = "CoinDesk", + ), + publishedAt = TextReference.Str("5h ago"), url = "https://coindesk.com", + onClick = {}, ), ).toPersistentList(), ), ArticleUM( id = 4, title = "Solana surpasses Ethereum in daily transaction volume", - createdAt = "20 Jun, 16:00", + createdAt = TextReference.Str("20 Jun, 16:00"), score = 9.1f, tags = listOf( LabelUM(text = TextReference.Str("SOL"), size = LabelSize.BIG), @@ -97,16 +110,20 @@ object MockArticlesFactory { SourceUM( id = 5, title = "Solana transactions hit record", - sourceName = "The Block", - publishedAt = "7h ago", + source = Source( + id = 18, + name = "Times", + ), + publishedAt = TextReference.Str("7h ago"), url = "https://theblock.co", + onClick = {}, ), ).toPersistentList(), ), ArticleUM( id = 5, title = "DeFi protocol launches innovative yield farming strategy", - createdAt = "20 Jun, 14:20", + createdAt = TextReference.Str("20 Jun, 14:20"), score = 6.9f, tags = listOf( LabelUM(text = TextReference.Str("DeFi"), size = LabelSize.BIG), @@ -118,7 +135,7 @@ object MockArticlesFactory { ArticleUM( id = 6, title = "Crypto regulation bill advances in US Senate", - createdAt = "20 Jun, 12:45", + createdAt = TextReference.Str("20 Jun, 12:45"), score = 8.7f, tags = listOf( LabelUM(text = TextReference.Str("Regulation"), size = LabelSize.BIG), @@ -131,7 +148,7 @@ object MockArticlesFactory { ArticleUM( id = 7, title = "Major bank announces crypto custody services", - createdAt = "20 Jun, 10:30", + createdAt = TextReference.Str("20 Jun, 10:30"), score = 7.3f, tags = listOf( LabelUM(text = TextReference.Str("Adoption"), size = LabelSize.BIG), @@ -143,7 +160,7 @@ object MockArticlesFactory { ArticleUM( id = 8, title = "NFT marketplace reports 300% increase in trading volume", - createdAt = "20 Jun, 08:15", + createdAt = TextReference.Str("20 Jun, 08:15"), score = 5.8f, tags = listOf( LabelUM(text = TextReference.Str("NFT"), size = LabelSize.BIG), @@ -155,7 +172,7 @@ object MockArticlesFactory { ArticleUM( id = 9, title = "Layer 2 solution achieves 100,000 TPS milestone", - createdAt = "19 Jun, 22:00", + createdAt = TextReference.Str("19 Jun, 22:00"), score = 8.5f, tags = listOf( LabelUM(text = TextReference.Str("Technology"), size = LabelSize.BIG), @@ -168,7 +185,7 @@ object MockArticlesFactory { ArticleUM( id = 10, title = "Stablecoin market cap reaches new all-time high", - createdAt = "19 Jun, 19:30", + createdAt = TextReference.Str("19 Jun, 19:30"), score = 7.6f, tags = listOf( LabelUM(text = TextReference.Str("Stablecoins"), size = LabelSize.BIG), diff --git a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt similarity index 52% rename from features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsUM.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt index 889fcc202a..a7524862e0 100644 --- a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt @@ -1,21 +1,22 @@ -package com.tangem.features.news.details.impl.ui +package com.tangem.features.feed.ui.news.details.state import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList -// TODO [REDACTED_TASK_KEY] make internal -data class NewsDetailsUM( +internal data class NewsDetailsUM( val articles: ImmutableList, val selectedArticleIndex: Int, val onShareClick: () -> Unit, val onLikeClick: () -> Unit, + val onBackClick: () -> Unit, + val onArticleIndexChanged: (Int) -> Unit = {}, ) -// TODO [REDACTED_TASK_KEY] make internal -data class ArticleUM( +internal data class ArticleUM( val id: Int, val title: String, - val createdAt: String, + val createdAt: TextReference, val score: Float, val tags: ImmutableList, val shortContent: String, @@ -23,11 +24,16 @@ data class ArticleUM( val sources: ImmutableList, ) -// TODO [REDACTED_TASK_KEY] make internal -data class SourceUM( +internal data class SourceUM( val id: Int, val title: String, - val sourceName: String, - val publishedAt: String, + val source: Source, + val publishedAt: TextReference, val url: String, + val onClick: () -> Unit, +) + +internal data class Source( + val id: Int, + val name: String, ) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt new file mode 100644 index 0000000000..b1052994f0 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt @@ -0,0 +1,40 @@ +package com.tangem.features.feed.ui.utils + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.FormattedDate +import com.tangem.core.ui.utils.getFormattedDate +import com.tangem.features.feed.impl.R +import com.tangem.utils.StringsSigns +import org.joda.time.DateTime + +internal fun mapFormattedDate(createdAt: String): TextReference { + val formattedDate = getFormattedDate( + createdAt = createdAt, + now = DateTime.now(), + ) + return when (formattedDate) { + is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date) + is FormattedDate.HoursAgo -> TextReference.PluralRes( + id = R.plurals.news_published_hours_ago, + count = formattedDate.hours, + formatArgs = wrappedList(formattedDate.hours), + ) + is FormattedDate.MinutesAgo -> TextReference.PluralRes( + id = R.plurals.news_published_minutes_ago, + count = formattedDate.minutes, + formatArgs = wrappedList(formattedDate.minutes), + ) + is FormattedDate.Today -> TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(R.string.common_today), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str(formattedDate.time), + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/news/news-details/api/build.gradle.kts b/features/news/news-details/api/build.gradle.kts deleted file mode 100644 index a9c29a2049..0000000000 --- a/features/news/news-details/api/build.gradle.kts +++ /dev/null @@ -1,20 +0,0 @@ -plugins { - alias(deps.plugins.android.library) - alias(deps.plugins.kotlin.android) - id("configuration") -} - -android { - namespace = "com.tangem.features.news.details.api" -} - -dependencies { - implementation(deps.compose.foundation) - - /* Project - Core */ - implementation(projects.core.decompose) - implementation(projects.core.ui) - - /* Compose */ - implementation(deps.compose.runtime) -} diff --git a/features/news/news-details/api/src/main/kotlin/com/tangem/features/news/details/api/NewsDetailsComponent.kt b/features/news/news-details/api/src/main/kotlin/com/tangem/features/news/details/api/NewsDetailsComponent.kt deleted file mode 100644 index 56d379faf0..0000000000 --- a/features/news/news-details/api/src/main/kotlin/com/tangem/features/news/details/api/NewsDetailsComponent.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.features.news.details.api - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState -import com.tangem.core.ui.decompose.ComposableContentComponent - -interface NewsDetailsComponent : ComposableContentComponent { - - data class Params(val selectedArticleId: Int = 0) - - @Composable - fun BottomSheetContent( - bottomSheetState: State, - onHeaderSizeChange: (Dp) -> Unit, - modifier: Modifier, - ) - - interface Factory : ComponentFactory -} \ No newline at end of file diff --git a/features/news/news-details/impl/build.gradle.kts b/features/news/news-details/impl/build.gradle.kts deleted file mode 100644 index a9953868af..0000000000 --- a/features/news/news-details/impl/build.gradle.kts +++ /dev/null @@ -1,43 +0,0 @@ -plugins { - alias(deps.plugins.android.library) - alias(deps.plugins.kotlin.android) - alias(deps.plugins.kotlin.kapt) - alias(deps.plugins.hilt.android) - id("configuration") -} - -android { - namespace = "com.tangem.features.news.details.impl" -} - -dependencies { - /* AndroidX */ - implementation(deps.lifecycle.compose) - implementation(deps.androidx.activity.compose) - - /** Compose */ - implementation(deps.compose.foundation) - implementation(deps.compose.ui) - implementation(deps.compose.ui.tooling) - implementation(deps.compose.material3) - - /** Core modules */ - implementation(projects.core.ui) - implementation(projects.core.utils) - implementation(projects.core.decompose) - implementation(projects.common.ui) - implementation(projects.common.routing) - - /** Feature modules */ - implementation(projects.features.news.newsDetails.api) - implementation(projects.domain.models) - - /** Other dependencies */ - implementation(deps.kotlin.immutable.collections) - implementation(deps.arrow.core) - implementation(deps.timber) - - /** DI */ - implementation(deps.hilt.android) - kapt(deps.hilt.kapt) -} diff --git a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/DefaultNewsDetailsComponent.kt b/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/DefaultNewsDetailsComponent.kt deleted file mode 100644 index 98c8989e54..0000000000 --- a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/DefaultNewsDetailsComponent.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.tangem.features.news.details.impl - -import androidx.activity.compose.BackHandler -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState -import com.tangem.features.news.details.api.NewsDetailsComponent -import com.tangem.features.news.details.impl.ui.NewsDetailsContent -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -internal class DefaultNewsDetailsComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted params: NewsDetailsComponent.Params, -) : NewsDetailsComponent, AppComponentContext by context { - - private val model: NewsDetailsModel = getOrCreateModel(params) - - @Composable - override fun BottomSheetContent( - bottomSheetState: State, - onHeaderSizeChange: (Dp) -> Unit, - modifier: Modifier, - ) { - val uiState by model.uiState.collectAsStateWithLifecycle() - val bsState by bottomSheetState - - BackHandler(enabled = bsState == BottomSheetState.EXPANDED) { - navigateBack() - } - - NewsDetailsContent( - state = uiState, - onBackClick = ::navigateBack, - modifier = modifier, - isBottomSheetMode = true, - ) - } - - @Composable - override fun Content(modifier: Modifier) { - val uiState by model.uiState.collectAsStateWithLifecycle() - NewsDetailsContent( - state = uiState, - onBackClick = model::onBackClick, - modifier = modifier, - isBottomSheetMode = false, - ) - } - - private fun navigateBack() = router.pop() - - @AssistedFactory - interface Factory : NewsDetailsComponent.Factory { - override fun create( - context: AppComponentContext, - params: NewsDetailsComponent.Params, - ): DefaultNewsDetailsComponent - } -} \ No newline at end of file diff --git a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/NewsDetailsModel.kt b/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/NewsDetailsModel.kt deleted file mode 100644 index 3ff89dde9d..0000000000 --- a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/NewsDetailsModel.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.tangem.features.news.details.impl - -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.navigation.Router -import com.tangem.features.news.details.api.NewsDetailsComponent -import com.tangem.features.news.details.impl.ui.NewsDetailsUM -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.extensions.indexOfFirstOrNull -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject - -@ModelScoped -internal class NewsDetailsModel @Inject constructor( - paramsContainer: ParamsContainer, - override val dispatchers: CoroutineDispatcherProvider, - private val router: Router, -) : Model() { - - private val mockedArticles = MockArticlesFactory.createMockArticles() - - private val params = paramsContainer.require() - - private val _uiState = MutableStateFlow( - NewsDetailsUM( - /* [REDACTED_TODO_COMMENT] */ - articles = mockedArticles, - selectedArticleIndex = mockedArticles.indexOfFirstOrNull { it.id == params.selectedArticleId } ?: 0, - onShareClick = { /* [REDACTED_TODO_COMMENT] */ }, - onLikeClick = { /* [REDACTED_TODO_COMMENT] */ }, - ), - ) - val uiState: StateFlow = _uiState.asStateFlow() - - fun onBackClick() { - router.pop() - } -} \ No newline at end of file diff --git a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/di/NewsDetailsModule.kt b/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/di/NewsDetailsModule.kt deleted file mode 100644 index f0fcac783e..0000000000 --- a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/di/NewsDetailsModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.features.news.details.impl.di - -import com.tangem.features.news.details.api.NewsDetailsComponent -import com.tangem.features.news.details.impl.DefaultNewsDetailsComponent -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal interface NewsDetailsModule { - - @Binds - @Singleton - fun bindNewsDetailsComponentFactory(factory: DefaultNewsDetailsComponent.Factory): NewsDetailsComponent.Factory -} \ No newline at end of file diff --git a/features/tester/impl/build.gradle.kts b/features/tester/impl/build.gradle.kts index 92a8671ee4..57fe70672c 100644 --- a/features/tester/impl/build.gradle.kts +++ b/features/tester/impl/build.gradle.kts @@ -59,10 +59,6 @@ dependencies { /** Feature Apis */ implementation(projects.features.tester.api) implementation(projects.features.pushNotifications.api) - implementation(projects.features.news.newsDetails.api) - implementation(projects.features.news.newsDetails.impl) - implementation(projects.features.news.newsList.api) - implementation(projects.features.news.newsList.impl) /* SDK */ implementation(tangemDeps.blockchain) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt index 333c9fd9e6..e59702c4e2 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt @@ -14,9 +14,6 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.tangem.common.routing.AppRouter import com.tangem.core.navigation.finisher.AppFinisher import com.tangem.core.ui.UiDependencies -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.screen.ComposeActivity import com.tangem.feature.tester.presentation.accounts.ui.AccountsScreen @@ -38,14 +35,7 @@ import com.tangem.feature.tester.presentation.providers.ui.BlockchainProvidersSc import com.tangem.feature.tester.presentation.providers.viewmodel.BlockchainProvidersViewModel import com.tangem.feature.tester.presentation.testpush.ui.TestPushScreen import com.tangem.feature.tester.presentation.testpush.viewmodel.TestPushViewModel -import com.tangem.feature.tester.presentation.news.ui.NewsScreen -import com.tangem.feature.tester.presentation.news.viewmodel.NewsViewModel -import com.tangem.features.news.details.impl.MockArticlesFactory -import com.tangem.features.news.details.impl.ui.ArticleUM -import com.tangem.features.news.details.impl.ui.NewsDetailsContent -import com.tangem.features.news.details.impl.ui.NewsDetailsUM import dagger.hilt.android.AndroidEntryPoint -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentSetOf import javax.inject.Inject @@ -92,7 +82,6 @@ internal class TesterActivity : ComposeActivity() { ButtonUM.TESTER_ACTIONS, ButtonUM.TEST_PUSHES, ButtonUM.ACCOUNTS, - ButtonUM.NEWS, ), onButtonClick = { buttonUM -> val route = when (buttonUM) { @@ -103,7 +92,6 @@ internal class TesterActivity : ComposeActivity() { ButtonUM.TESTER_ACTIONS -> TesterScreen.TESTER_ACTIONS ButtonUM.TEST_PUSHES -> TesterScreen.TEST_PUSHES ButtonUM.ACCOUNTS -> TesterScreen.ACCOUNTS - ButtonUM.NEWS -> TesterScreen.NEWS } innerTesterRouter.open(route) @@ -174,64 +162,6 @@ internal class TesterActivity : ComposeActivity() { AccountsScreen(state) } - - composable(route = TesterScreen.NEWS.name) { - val viewModel = hiltViewModel().apply { - setupNavigation(innerTesterRouter) - } - val state by viewModel.uiState.collectAsStateWithLifecycle() - - NewsScreen(state) - } - - composable(route = TesterScreen.NEWS_DETAILS.name) { - NewsDetailsContent( - state = NewsDetailsUM( - articles = MockArticlesFactory.createMockArticles(), - selectedArticleIndex = 0, - onLikeClick = { }, - onShareClick = { }, - ), - onBackClick = { innerTesterRouter.back() }, - ) - } - - composable(route = TesterScreen.NEWS_DETAILS_BOTTOM_SHEET.name) { - NewsDetailsBottomSheetTest( - onDismiss = { innerTesterRouter.back() }, - ) - } - } - } - - @Composable - private fun NewsDetailsBottomSheetTest(onDismiss: () -> Unit) { - data class NewsDetailsBottomSheetContent( - val articles: ImmutableList, - ) : TangemBottomSheetConfigContent - - val content = NewsDetailsBottomSheetContent( - articles = MockArticlesFactory.createMockArticles(), - ) - - TangemBottomSheet( - config = TangemBottomSheetConfig( - isShown = true, - onDismissRequest = onDismiss, - content = content, - ), - containerColor = TangemTheme.colors.background.tertiary, - ) { sheetContent -> - NewsDetailsContent( - state = NewsDetailsUM( - articles = sheetContent.articles, - selectedArticleIndex = 0, - onLikeClick = { }, - onShareClick = { }, - ), - onBackClick = onDismiss, - isBottomSheetMode = true, - ) } } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuUM.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuUM.kt index 00a019e9c4..fa30058309 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuUM.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuUM.kt @@ -25,6 +25,5 @@ data class TesterMenuUM( TESTER_ACTIONS(R.string.tester_actions), TEST_PUSHES(R.string.test_push), ACCOUNTS(R.string.accounts), - NEWS(R.string.news), } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt index 8d06310e39..e87d942c19 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt @@ -14,7 +14,4 @@ internal enum class TesterScreen { BLOCKCHAIN_PROVIDERS, TEST_PUSHES, ACCOUNTS, - NEWS, - NEWS_DETAILS, - NEWS_DETAILS_BOTTOM_SHEET, } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/state/NewsUM.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/state/NewsUM.kt deleted file mode 100644 index 43c8c9efca..0000000000 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/state/NewsUM.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.feature.tester.presentation.news.state - -import androidx.annotation.StringRes -import com.tangem.feature.tester.impl.R -import kotlinx.collections.immutable.ImmutableSet - -data class NewsUM( - val onBackClick: () -> Unit, - val buttons: ImmutableSet, - val onButtonClick: (ButtonUM) -> Unit, -) { - - enum class ButtonUM(@StringRes val textResId: Int) { - NEWS_DETAILS(R.string.news_details), - NEWS_DETAILS_BOTTOM_SHEET(R.string.news_details_bottom_sheet), - } -} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/ui/NewsScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/ui/NewsScreen.kt deleted file mode 100644 index 2f0b9c690c..0000000000 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/ui/NewsScreen.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.tangem.feature.tester.presentation.news.ui - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.PrimaryButton -import com.tangem.core.ui.components.appbar.AppBarWithBackButton -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.feature.tester.impl.R -import com.tangem.feature.tester.presentation.news.state.NewsUM -import kotlinx.collections.immutable.ImmutableSet - -@OptIn(ExperimentalFoundationApi::class) -@Composable -internal fun NewsScreen(state: NewsUM, modifier: Modifier = Modifier) { - BackHandler(onBack = state.onBackClick) - - LazyColumn( - modifier = modifier - .fillMaxSize() - .background(TangemTheme.colors.background.primary), - ) { - stickyHeader { AppBar(onBackClick = state.onBackClick) } - - NewsButtons(buttons = state.buttons, onButtonClick = state.onButtonClick) - } -} - -@Composable -private fun AppBar(onBackClick: () -> Unit) { - AppBarWithBackButton( - onBackClick = onBackClick, - text = stringResourceSafe(id = R.string.news), - containerColor = TangemTheme.colors.background.primary, - ) -} - -@Suppress("FunctionNaming") -private fun LazyListScope.NewsButtons( - buttons: ImmutableSet, - onButtonClick: (NewsUM.ButtonUM) -> Unit, -) { - items(buttons.toList()) { button -> - PrimaryButton( - text = stringResourceSafe(button.textResId), - onClick = { onButtonClick(button) }, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - .fillMaxWidth(), - ) - } -} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/viewmodel/NewsViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/viewmodel/NewsViewModel.kt deleted file mode 100644 index f39c639ff5..0000000000 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/viewmodel/NewsViewModel.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.tangem.feature.tester.presentation.news.viewmodel - -import androidx.lifecycle.ViewModel -import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter -import com.tangem.feature.tester.presentation.navigation.TesterScreen -import com.tangem.feature.tester.presentation.news.state.NewsUM -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.collections.immutable.persistentSetOf -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import javax.inject.Inject - -@HiltViewModel -internal class NewsViewModel @Inject constructor() : ViewModel() { - - private val _uiState = MutableStateFlow(createInitialState()) - val uiState: StateFlow = _uiState - - private var router: InnerTesterRouter? = null - - fun setupNavigation(router: InnerTesterRouter) { - this.router = router - } - - private fun createInitialState(): NewsUM { - return NewsUM( - onBackClick = ::onBackClick, - buttons = persistentSetOf( - NewsUM.ButtonUM.NEWS_DETAILS, - NewsUM.ButtonUM.NEWS_DETAILS_BOTTOM_SHEET, - ), - onButtonClick = ::onButtonClick, - ) - } - - private fun onBackClick() { - router?.back() - } - - private fun onButtonClick(button: NewsUM.ButtonUM) { - when (button) { - NewsUM.ButtonUM.NEWS_DETAILS -> router?.open(TesterScreen.NEWS_DETAILS) - NewsUM.ButtonUM.NEWS_DETAILS_BOTTOM_SHEET -> router?.open(TesterScreen.NEWS_DETAILS_BOTTOM_SHEET) - } - } -} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 53bbc68e17..83aadc8341 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -302,12 +302,6 @@ include(":features:yield-supply:impl") include(":features:feed:api") include(":features:feed:impl") - -include(":features:news:news-details:api") -include(":features:news:news-details:impl") - -include(":features:news:news-list:api") -include(":features:news:news-list:impl") // endregion Feature modules // region Domain modules From 13f1db77399da3076d7efacd7dd4c40b29f80cda Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Dec 2025 13:06:23 +0100 Subject: [PATCH 034/172] Updated on 2026-08-14 --- .../news/usecase/GetNewsCategoriesUseCase.kt | 5 +- .../components/DefaultFeedEntryComponent.kt | 1 + .../feed/components/FeedEntryChildFactory.kt | 5 ++ .../news/list/DefaultNewsListComponent.kt | 36 ++++++++ .../tangem/features/feed/di/ModelModule.kt | 6 ++ .../feed/model/news/list/NewsListModel.kt | 82 +++++++++++++++++++ .../feed/ui/news/list}/NewsListContent.kt | 22 ++--- .../feed/ui/news/list/state}/NewsListUM.kt | 3 +- features/news/news-list/api/.gitignore | 1 - features/news/news-list/api/build.gradle.kts | 20 ----- .../news/list/api/NewsListComponent.kt | 23 ------ features/news/news-list/impl/.gitignore | 1 - features/news/news-list/impl/build.gradle.kts | 46 ----------- .../list/impl/DefaultNewsListComponent.kt | 62 -------------- .../features/news/list/impl/NewsListModel.kt | 81 ------------------ .../news/list/impl/di/NewsListModule.kt | 18 ---- 16 files changed, 143 insertions(+), 269 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt rename features/{news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui => feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list}/NewsListContent.kt (87%) rename features/{news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui => feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state}/NewsListUM.kt (82%) delete mode 100644 features/news/news-list/api/.gitignore delete mode 100644 features/news/news-list/api/build.gradle.kts delete mode 100644 features/news/news-list/api/src/main/kotlin/com/tangem/features/news/list/api/NewsListComponent.kt delete mode 100644 features/news/news-list/impl/.gitignore delete mode 100644 features/news/news-list/impl/build.gradle.kts delete mode 100644 features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/DefaultNewsListComponent.kt delete mode 100644 features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/NewsListModel.kt delete mode 100644 features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/di/NewsListModule.kt diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt index 35e1f8f0bb..aa8b308d94 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt @@ -1,5 +1,6 @@ package com.tangem.domain.news.usecase +import arrow.core.Either import com.tangem.domain.models.news.ArticleCategory import com.tangem.domain.news.repository.NewsRepository @@ -15,7 +16,7 @@ class GetNewsCategoriesUseCase( /** * Fetches categories from the repository. */ - suspend operator fun invoke(): List { - return repository.getCategories() + suspend operator fun invoke(): Either> = Either.catch { + repository.getCategories() } } \ No newline at end of file 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 75523f67d4..28f4ccb71c 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 @@ -111,6 +111,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( router = innerRouter, ), feedEntryClickIntents = clickIntents, + onBackClicked = { onChildBack() }, ) }, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index 0a38fba062..cfc874b8b1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -50,6 +50,7 @@ internal class FeedEntryChildFactory @Inject constructor( child: Child, appComponentContext: AppComponentContext, feedEntryClickIntents: FeedEntryClickIntents, + onBackClicked: () -> Unit, ): ComposableModularBottomSheetContentComponent { return when (child) { is Child.TokenDetails -> { @@ -76,6 +77,10 @@ internal class FeedEntryChildFactory @Inject constructor( Child.NewsList -> { DefaultNewsListComponent( appComponentContext = appComponentContext, + params = DefaultNewsListComponent.Params( + onArticleClicked = { feedEntryClickIntents.onArticleClick(articleId = it) }, + onBackClick = onBackClicked, + ), ) } Child.Feed -> { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt index 992bd9120c..2cec3b93c7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt @@ -2,20 +2,56 @@ package com.tangem.features.feed.components.news.list import androidx.compose.runtime.Composable import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.R +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.features.feed.model.news.list.NewsListModel +import com.tangem.features.feed.ui.news.list.NewsListContent +import kotlinx.serialization.Serializable internal class DefaultNewsListComponent( appComponentContext: AppComponentContext, + private val params: Params, ) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { + private val newsListModel = getOrCreateModel(params = params) + @Composable override fun Title(bottomSheetState: State) { + val background = LocalMainBottomSheetColor.current.value + val state by newsListModel.state.collectAsStateWithLifecycle() + TangemTopAppBar( + containerColor = background, + title = stringResourceSafe(R.string.common_news), + startButton = TopAppBarButtonUM.Icon( + iconRes = R.drawable.ic_back_24, + onClicked = state.onBackClick, + isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED, + ), + ) } @Composable override fun Content(bottomSheetState: State, modifier: Modifier) { + val state by newsListModel.state.collectAsStateWithLifecycle() + NewsListContent( + state = state, + modifier = modifier, + ) } + + @Serializable + data class Params( + val onArticleClicked: (Int) -> Unit, + val onBackClick: () -> Unit, + ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt index 31ca5d9dcf..209d30fe44 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt @@ -6,6 +6,7 @@ import com.tangem.features.feed.model.feed.FeedComponentModel import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel import com.tangem.features.feed.model.market.list.MarketsListModel import com.tangem.features.feed.model.news.details.NewsDetailsModel +import com.tangem.features.feed.model.news.list.NewsListModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -35,4 +36,9 @@ internal interface ModelModule { @IntoMap @ClassKey(NewsDetailsModel::class) fun provideNewsDetailsModel(model: NewsDetailsModel): Model + + @Binds + @IntoMap + @ClassKey(NewsListModel::class) + fun provideNewsListModel(model: NewsListModel): Model } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt new file mode 100644 index 0000000000..ccaf7b8c33 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt @@ -0,0 +1,82 @@ +package com.tangem.features.feed.model.news.list + +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.components.chip.entity.ChipUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase +import com.tangem.features.feed.components.news.list.DefaultNewsListComponent +import com.tangem.features.feed.ui.news.list.state.NewsListUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +internal class NewsListModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val getNewsCategoriesUseCase: GetNewsCategoriesUseCase, + paramsContainer: ParamsContainer, +) : Model() { + + private val params = paramsContainer.require() + + private val _state = MutableStateFlow( + NewsListUM( + selectedCategoryId = 0, + filters = persistentListOf(), + articles = persistentListOf(), + onArticleClick = params.onArticleClicked, + onBackClick = params.onBackClick, + ), + ) + val state = _state.asStateFlow() + + init { + modelScope.launch(dispatchers.default) { + val filterChips = getNewsCategoriesUseCase + .invoke() + .fold( + ifLeft = { + persistentListOf() + }, + ifRight = { categories -> + categories.map { articleCategory -> + ChipUM( + id = articleCategory.id, + text = TextReference.Str(articleCategory.name), + isSelected = false, + onClick = { + onCategoryClick(articleCategory.id) + }, + ) + }.toImmutableList() + }, + ) + _state.update { currentState -> + currentState.copy(filters = filterChips) + } + } + } + + private fun onCategoryClick(categoryId: Int) { + _state.update { currentState -> + currentState.copy( + selectedCategoryId = categoryId, + filters = updateFilterChips(categoryId), + ) + } + } + + private fun updateFilterChips(categoryId: Int): ImmutableList { + return state.value.filters.map { chip -> + chip.copy(isSelected = chip.id == categoryId) + }.toImmutableList() + } +} \ No newline at end of file diff --git a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui/NewsListContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt similarity index 87% rename from features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui/NewsListContent.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt index ea8a333901..d9b5b00948 100644 --- a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui/NewsListContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.news.list.impl.ui +package com.tangem.features.feed.ui.news.list import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -11,32 +11,27 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.common.ui.news.ArticleCard import com.tangem.common.ui.news.ArticleConfigUM -import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.chip.Chip import com.tangem.core.ui.components.chip.entity.ChipUM import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.ui.news.list.state.NewsListUM import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableSet @Composable -internal fun NewsListContent(state: NewsListUM, onBackClick: () -> Unit, modifier: Modifier = Modifier) { +internal fun NewsListContent(state: NewsListUM, modifier: Modifier = Modifier) { + val background = LocalMainBottomSheetColor.current.value Column( modifier = modifier .fillMaxSize() - .background(color = TangemTheme.colors.background.tertiary), + .background(background), ) { - AppBarWithBackButton( - text = stringResourceSafe(R.string.common_news), - onBackClick = onBackClick, - ) - LazyRow( contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -48,9 +43,8 @@ internal fun NewsListContent(state: NewsListUM, onBackClick: () -> Unit, modifie Chip(state = filter) } } - LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(16.dp), ) { items( @@ -145,8 +139,8 @@ private fun NewsListContentPreview() { filters = filters, articles = articles, onArticleClick = {}, + onBackClick = {}, ), - onBackClick = {}, ) } } \ No newline at end of file diff --git a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui/NewsListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt similarity index 82% rename from features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui/NewsListUM.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt index d3e853c285..c1f6174111 100644 --- a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/ui/NewsListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.news.list.impl.ui +package com.tangem.features.feed.ui.news.list.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.news.ArticleConfigUM @@ -11,4 +11,5 @@ data class NewsListUM( val filters: ImmutableList, val articles: ImmutableList, val onArticleClick: (Int) -> Unit, + val onBackClick: () -> Unit, ) \ No newline at end of file diff --git a/features/news/news-list/api/.gitignore b/features/news/news-list/api/.gitignore deleted file mode 100644 index 796b96d1c4..0000000000 --- a/features/news/news-list/api/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/features/news/news-list/api/build.gradle.kts b/features/news/news-list/api/build.gradle.kts deleted file mode 100644 index 2de4c641e7..0000000000 --- a/features/news/news-list/api/build.gradle.kts +++ /dev/null @@ -1,20 +0,0 @@ -plugins { - alias(deps.plugins.android.library) - alias(deps.plugins.kotlin.android) - id("configuration") -} - -android { - namespace = "com.tangem.features.news.list.api" -} - -dependencies { - implementation(deps.compose.foundation) - - /* Project - Core */ - implementation(projects.core.decompose) - implementation(projects.core.ui) - - /* Compose */ - implementation(deps.compose.runtime) -} \ No newline at end of file diff --git a/features/news/news-list/api/src/main/kotlin/com/tangem/features/news/list/api/NewsListComponent.kt b/features/news/news-list/api/src/main/kotlin/com/tangem/features/news/list/api/NewsListComponent.kt deleted file mode 100644 index 225a75a329..0000000000 --- a/features/news/news-list/api/src/main/kotlin/com/tangem/features/news/list/api/NewsListComponent.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.features.news.list.api - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState -import com.tangem.core.ui.decompose.ComposableContentComponent - -interface NewsListComponent : ComposableContentComponent { - - data class Params(val selectedFilter: String? = null) - - @Composable - fun BottomSheetContent( - bottomSheetState: State, - onHeaderSizeChange: (Dp) -> Unit, - modifier: Modifier, - ) - - interface Factory : ComponentFactory -} \ No newline at end of file diff --git a/features/news/news-list/impl/.gitignore b/features/news/news-list/impl/.gitignore deleted file mode 100644 index 796b96d1c4..0000000000 --- a/features/news/news-list/impl/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/features/news/news-list/impl/build.gradle.kts b/features/news/news-list/impl/build.gradle.kts deleted file mode 100644 index baf05ca033..0000000000 --- a/features/news/news-list/impl/build.gradle.kts +++ /dev/null @@ -1,46 +0,0 @@ -plugins { - alias(deps.plugins.android.library) - alias(deps.plugins.kotlin.android) - alias(deps.plugins.kotlin.kapt) - alias(deps.plugins.hilt.android) - id("configuration") -} - -android { - namespace = "com.tangem.features.news.list.impl" -} - -dependencies { - /* AndroidX */ - implementation(deps.lifecycle.compose) - implementation(deps.androidx.activity.compose) - - /** Compose */ - implementation(deps.compose.foundation) - implementation(deps.compose.ui) - implementation(deps.compose.ui.tooling) - implementation(deps.compose.material3) - - /** Core modules */ - implementation(projects.core.ui) - implementation(projects.core.utils) - implementation(projects.core.decompose) - implementation(projects.common.ui) - implementation(projects.common.routing) - - /** Feature modules */ - implementation(projects.features.news.newsList.api) - - /** Domain modules */ - implementation(projects.domain.models) - implementation(projects.domain.news) - - /** Other dependencies */ - implementation(deps.kotlin.immutable.collections) - implementation(deps.arrow.core) - implementation(deps.timber) - - /** DI */ - implementation(deps.hilt.android) - kapt(deps.hilt.kapt) -} \ No newline at end of file diff --git a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/DefaultNewsListComponent.kt b/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/DefaultNewsListComponent.kt deleted file mode 100644 index 1ed37bdc4e..0000000000 --- a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/DefaultNewsListComponent.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.tangem.features.news.list.impl - -import androidx.activity.compose.BackHandler -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState -import com.tangem.features.news.list.api.NewsListComponent -import com.tangem.features.news.list.impl.ui.NewsListContent -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -internal class DefaultNewsListComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted params: NewsListComponent.Params, -) : NewsListComponent, AppComponentContext by context { - - private val model: NewsListModel = getOrCreateModel(params) - - @Composable - override fun BottomSheetContent( - bottomSheetState: State, - onHeaderSizeChange: (Dp) -> Unit, - modifier: Modifier, - ) { - val uiState by model.uiState.collectAsStateWithLifecycle() - val bsState by bottomSheetState - - BackHandler(enabled = bsState == BottomSheetState.EXPANDED) { - navigateBack() - } - - NewsListContent( - state = uiState, - onBackClick = ::navigateBack, - modifier = modifier, - ) - } - - @Composable - override fun Content(modifier: Modifier) { - val uiState by model.uiState.collectAsStateWithLifecycle() - NewsListContent( - state = uiState, - onBackClick = model::onBackClick, - modifier = modifier, - ) - } - - private fun navigateBack() = router.pop() - - @AssistedFactory - interface Factory : NewsListComponent.Factory { - override fun create(context: AppComponentContext, params: NewsListComponent.Params): DefaultNewsListComponent - } -} \ No newline at end of file diff --git a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/NewsListModel.kt b/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/NewsListModel.kt deleted file mode 100644 index 18c010aaac..0000000000 --- a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/NewsListModel.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.tangem.features.news.list.impl - -import com.tangem.core.decompose.di.ModelScoped -import com.tangem.core.decompose.model.Model -import com.tangem.core.decompose.navigation.Router -import com.tangem.core.ui.components.chip.entity.ChipUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase -import com.tangem.features.news.list.impl.ui.NewsListUM -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -@ModelScoped -internal class NewsListModel @Inject constructor( - override val dispatchers: CoroutineDispatcherProvider, - private val getNewsCategoriesUseCase: GetNewsCategoriesUseCase, - private val router: Router, -) : Model() { - - val uiState: StateFlow - field = MutableStateFlow( - NewsListUM( - selectedCategoryId = 0, - filters = persistentListOf(), - articles = persistentListOf(), - onArticleClick = ::onArticleClick, - ), - ) - - init { - modelScope.launch(dispatchers.default) { - val filterChips = getNewsCategoriesUseCase - .invoke() - .map { articleCategory -> - ChipUM( - id = articleCategory.id, - text = TextReference.Str(articleCategory.name), - isSelected = false, - onClick = { - onCategoryClick(articleCategory.id) - }, - ) - } - .toImmutableList() - uiState.update { currentState -> - currentState.copy(filters = filterChips) - } - } - } - - fun onBackClick() { - router.pop() - } - - private fun onArticleClick(articleId: Int) { - // TODO [REDACTED_TASK_KEY] - articleId - } - - private fun onCategoryClick(categoryId: Int) { - uiState.update { currentState -> - currentState.copy( - selectedCategoryId = categoryId, - filters = updateFilterChips(categoryId), - ) - } - } - - private fun updateFilterChips(categoryId: Int): ImmutableList { - return uiState.value.filters.map { chip -> - chip.copy(isSelected = chip.id == categoryId) - }.toImmutableList() - } -} \ No newline at end of file diff --git a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/di/NewsListModule.kt b/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/di/NewsListModule.kt deleted file mode 100644 index b3d323d053..0000000000 --- a/features/news/news-list/impl/src/main/kotlin/com/tangem/features/news/list/impl/di/NewsListModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.features.news.list.impl.di - -import com.tangem.features.news.list.api.NewsListComponent -import com.tangem.features.news.list.impl.DefaultNewsListComponent -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal interface NewsListModule { - - @Binds - @Singleton - fun bindNewsListComponentFactory(factory: DefaultNewsListComponent.Factory): NewsListComponent.Factory -} \ No newline at end of file From 92034d6106dfe4185c042ddfc3cdbd540cb254c3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Dec 2025 13:06:56 +0100 Subject: [PATCH 035/172] Updated on 2026-08-14 --- .../model/news/details/NewsDetailsModel.kt | 26 +++++++++++++++++-- .../details/converter/NewsDetailsConverter.kt | 1 + .../news/details/state/MockArticlesFactory.kt | 10 +++++++ .../ui/news/details/state/NewsDetailsUM.kt | 1 + 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt index 7d8791f935..b749723815 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable 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.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent @@ -25,6 +26,7 @@ internal class NewsDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase, private val urlOpener: UrlOpener, + private val shareManager: ShareManager, paramsContainer: ParamsContainer, ) : Model() { @@ -42,10 +44,10 @@ internal class NewsDetailsModel @Inject constructor( NewsDetailsUM( articles = persistentListOf(), selectedArticleIndex = 0, - onShareClick = { /* [REDACTED_TODO_COMMENT] */ }, + onShareClick = {}, onLikeClick = { /* [REDACTED_TODO_COMMENT] */ }, onBackClick = params.onBackClicked, - onArticleIndexChanged = { /* [REDACTED_TODO_COMMENT] */ }, + onArticleIndexChanged = ::onArticleIndexChanged, ), ) @@ -59,6 +61,20 @@ internal class NewsDetailsModel @Inject constructor( } } + private fun onArticleIndexChanged(newIndex: Int) { + val currentArticle = state.value.articles.getOrNull(newIndex) + _state.update { currentState -> + currentState.copy( + selectedArticleIndex = newIndex, + onShareClick = { + currentArticle?.let { + shareManager.shareText(it.newsUrl) + } + }, + ) + } + } + private fun handlePreselectedArticles() { modelScope.launch { observeNewsDetailsUseCase.prefetch( @@ -80,10 +96,16 @@ internal class NewsDetailsModel @Inject constructor( } .onEach { articles -> val selectedIndex = articles.indexOfFirstOrNull { it.id == params.articleId } ?: 0 + val currentArticle = articles.getOrNull(selectedIndex) _state.update { newsDetailsUM -> newsDetailsUM.copy( articles = articles, selectedArticleIndex = selectedIndex, + onShareClick = { + currentArticle?.let { + shareManager.shareText(it.newsUrl) + } + }, ) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt index 6a0fa54bae..939c67a294 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt @@ -36,6 +36,7 @@ internal class NewsDetailsConverter( shortContent = value.shortContent, content = value.content, sources = buildSources(value), + newsUrl = value.newsUrl, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt index 03b4990886..97aec724e5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt @@ -44,6 +44,7 @@ internal object MockArticlesFactory { onClick = {}, ), ).toPersistentList(), + newsUrl = "", ), ArticleUM( id = 2, @@ -69,6 +70,7 @@ internal object MockArticlesFactory { onClick = {}, ), ).toPersistentList(), + newsUrl = "", ), ArticleUM( id = 3, @@ -94,6 +96,7 @@ internal object MockArticlesFactory { onClick = {}, ), ).toPersistentList(), + newsUrl = "", ), ArticleUM( id = 4, @@ -119,6 +122,7 @@ internal object MockArticlesFactory { onClick = {}, ), ).toPersistentList(), + newsUrl = "", ), ArticleUM( id = 5, @@ -131,6 +135,7 @@ internal object MockArticlesFactory { shortContent = "New DeFi protocol introduced innovative yield farming approach.", content = "A newly launched protocol unveiled innovative yield farming mechanism.\n\nAPY rates range from 15% to 30%.", sources = persistentListOf(), + newsUrl = "", ), ArticleUM( id = 6, @@ -144,6 +149,7 @@ internal object MockArticlesFactory { shortContent = "Comprehensive cryptocurrency regulation bill passed Senate Banking Committee.", content = "The US Senate Banking Committee advanced landmark crypto regulation bill.\n\nKey provisions include asset definitions.", sources = persistentListOf(), + newsUrl = "", ), ArticleUM( id = 7, @@ -156,6 +162,7 @@ internal object MockArticlesFactory { shortContent = "World's largest bank announced cryptocurrency custody services.", content = "Major financial institution announced comprehensive crypto custody services.\n\nSupporting Bitcoin and Ethereum initially.", sources = persistentListOf(), + newsUrl = "", ), ArticleUM( id = 8, @@ -168,6 +175,7 @@ internal object MockArticlesFactory { shortContent = "Leading NFT marketplace experienced dramatic surge in trading activity.", content = "Prominent NFT marketplace reported 300% increase in trading volume.\n\nNew features include lower fees.", sources = persistentListOf(), + newsUrl = "", ), ArticleUM( id = 9, @@ -181,6 +189,7 @@ internal object MockArticlesFactory { shortContent = "New Layer 2 scaling solution achieved 100,000 TPS in testing.", content = "Layer 2 solution processed 100,000 transactions per second.\n\nUsing zero-knowledge proof technology.", sources = persistentListOf(), + newsUrl = "", ), ArticleUM( id = 10, @@ -194,6 +203,7 @@ internal object MockArticlesFactory { shortContent = "Total stablecoin market capitalization surpassed \$180 billion.", content = "Stablecoin market cap reached \$180 billion all-time high.\n\nDriven by DeFi activity and institutional adoption.", sources = persistentListOf(), + newsUrl = "", ), ).toPersistentList() } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt index a7524862e0..d0049ca02b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt @@ -22,6 +22,7 @@ internal data class ArticleUM( val shortContent: String, val content: String, val sources: ImmutableList, + val newsUrl: String, ) internal data class SourceUM( From 625cf611f2af847cc4085351e1569e84efefc73e Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Dec 2025 10:51:54 +0100 Subject: [PATCH 036/172] Updated on 2026-08-14 --- app/src/main/AndroidManifest.xml | 11 ++++ .../tangem/tap/routing/utils/ChildFactory.kt | 10 +++ .../tap/routing/utils/DeepLinkFactory.kt | 22 +++++-- .../tap/routing/utils/DeepLinkFactoryTest.kt | 10 +++ .../com/tangem/common/routing/AppRoute.kt | 3 + .../deeplink/NewsDetailsDeepLinkHandler.kt | 11 ++++ .../DefaultNewsDetailsDeepLinkHandler.kt | 61 +++++++++++++++++++ .../feed/deeplink/di/FeedDeepLinkModule.kt | 20 ++++++ .../ui/news/details/NewsDetailsContent.kt | 10 ++- 9 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/NewsDetailsDeepLinkHandler.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDetailsDeepLinkHandler.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ab376d6124..bb289cd06c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -290,6 +290,17 @@ android:host="tangem.com" android:path="/pay-app" /> + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 820b018fc2..83d4c9f04b 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -727,6 +727,16 @@ internal class ChildFactory @Inject constructor( componentFactory = yieldSupplyActiveComponentFactory, ) } + is AppRoute.NewsDetails -> { + createComponentChild( + context = context, + params = FeedEntryRoute.NewsDetail( + articleId = route.newsId, + preselectedArticlesId = listOf(route.newsId), + ), + componentFactory = feedEntryComponentFactory, + ) + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 097b57b518..27c065e869 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -6,6 +6,8 @@ import com.tangem.common.routing.DeepLinkRoute import com.tangem.common.routing.DeepLinkScheme import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler +import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler @@ -50,6 +52,8 @@ internal class DeepLinkFactory @Inject constructor( private val swapDeepLink: SwapDeepLinkHandler.Factory, private val promoDeepLink: PromoDeeplinkHandler.Factory, private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory, + private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory, + private val feedFeatureToggle: FeedFeatureToggle, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -102,7 +106,7 @@ internal class DeepLinkFactory @Inject constructor( private fun launchDeepLink(deeplinkUri: Uri, coroutineScope: CoroutineScope, isFromOnNewIntent: Boolean) { when (deeplinkUri.scheme) { - DeepLinkScheme.Https.scheme -> handleHttpDeepLinks(deeplinkUri) + DeepLinkScheme.Https.scheme -> handleHttpDeepLinks(deeplinkUri, coroutineScope) DeepLinkScheme.Tangem.scheme -> handleTangemDeepLinks(deeplinkUri, coroutineScope, isFromOnNewIntent) DeepLinkScheme.WalletConnect.scheme -> walletConnectDeepLink.create(deeplinkUri) else -> { @@ -116,10 +120,18 @@ internal class DeepLinkFactory @Inject constructor( } } - private fun handleHttpDeepLinks(deeplinkUri: Uri) { - if (deeplinkUri.host == DeepLinkRoute.PayApp.host && deeplinkUri.path?.startsWith("/pay-app") == true) { - onboardVisaDeepLink.create(deeplinkUri) - return + private fun handleHttpDeepLinks(deeplinkUri: Uri, coroutineScope: CoroutineScope) { + if (deeplinkUri.host == DeepLinkRoute.PayApp.host) { + when { + deeplinkUri.path?.startsWith("/pay-app") == true -> { + onboardVisaDeepLink.create(deeplinkUri) + return + } + deeplinkUri.path?.startsWith("/news") == true && feedFeatureToggle.isFeedEnabled -> { + newsDetailsDeepLink.create(coroutineScope, deeplinkUri) + return + } + } } } diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index ea548398d0..19782fca91 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -4,6 +4,8 @@ import android.net.Uri import com.tangem.common.routing.AppRoute import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler +import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler @@ -81,6 +83,12 @@ class DeepLinkFactoryTest { private val cardSdkProvider = mockk(relaxed = true) { every { sdk.uiVisibility() } returns MutableStateFlow(false) } + + private val newsDeeplink = mockk(relaxed = true) { + every { create(any(), any()) } returns mockk() + } + private val feedFeatureToggle = mockk() + private val mockedUri = mockk(relaxed = true) private val isFromOnNewIntent: Boolean = false @@ -103,6 +111,8 @@ class DeepLinkFactoryTest { swapDeepLink = swapDeepLinkFactory, promoDeepLink = promoDeepLinkFactory, onboardVisaDeepLink = onboardVisaDeepLink, + newsDetailsDeepLink = newsDeeplink, + feedFeatureToggle = feedFeatureToggle, ) @OptIn(ExperimentalCoroutinesApi::class) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index dbb07560d9..ae5ccdd4d1 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -468,4 +468,7 @@ sealed class AppRoute(val path: String) : Route { val cryptoCurrency: CryptoCurrency, val apy: String, ) : AppRoute(path = "/yield_supply_active/${userWalletId.stringValue}/${cryptoCurrency.symbol}") + + @Serializable + data class NewsDetails(val newsId: Int) : AppRoute(path = "/news_details/$newsId") } \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/NewsDetailsDeepLinkHandler.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/NewsDetailsDeepLinkHandler.kt new file mode 100644 index 0000000000..f6ce381120 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/NewsDetailsDeepLinkHandler.kt @@ -0,0 +1,11 @@ +package com.tangem.features.feed.entry.deeplink + +import android.net.Uri +import kotlinx.coroutines.CoroutineScope + +interface NewsDetailsDeepLinkHandler { + + interface Factory { + fun create(coroutineScope: CoroutineScope, deeplinkUri: Uri): NewsDetailsDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDetailsDeepLinkHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDetailsDeepLinkHandler.kt new file mode 100644 index 0000000000..8409d68360 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDetailsDeepLinkHandler.kt @@ -0,0 +1,61 @@ +package com.tangem.features.feed.deeplink + +import android.net.Uri +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +internal class DefaultNewsDetailsDeepLinkHandler @AssistedInject constructor( + @Assisted private val scope: CoroutineScope, + @Assisted private val deeplinkUri: Uri, + private val appRouter: AppRouter, +) : NewsDetailsDeepLinkHandler { + + init { + handleDeepLink() + } + + private fun handleDeepLink() { + scope.launch { + val articleId = extractArticleIdFromUri(deeplinkUri) + if (articleId == null) { + Timber.e( + """ + Failed to extract article ID from deep link + |- Received URI: $deeplinkUri + """.trimIndent(), + ) + return@launch + } + + appRouter.push( + AppRoute.NewsDetails(newsId = articleId), + ) + } + } + + /** + * Parsing URI with format https://tangem.com/news/{category}/{id}-{slug} to get id + */ + private fun extractArticleIdFromUri(uri: Uri): Int? { + val path = uri.path ?: return null + val pathSegments = path.split("/").filter { it.isNotBlank() } + if (pathSegments.isEmpty() || pathSegments[0] != "news" || pathSegments.size < 2) { + return null + } + val lastSegment = pathSegments.last() + val idPart = lastSegment.substringBefore("-") + return idPart.toIntOrNull() + } + + @AssistedFactory + interface Factory : NewsDetailsDeepLinkHandler.Factory { + override fun create(coroutineScope: CoroutineScope, deeplinkUri: Uri): DefaultNewsDetailsDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt new file mode 100644 index 0000000000..d00b30cc79 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.feed.deeplink.di + +import com.tangem.features.feed.deeplink.DefaultNewsDetailsDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface FeedDeepLinkModule { + + @Binds + @Singleton + fun bindNewsDetailsDeepLinkHandlerFactory( + impl: DefaultNewsDetailsDeepLinkHandler.Factory, + ): NewsDetailsDeepLinkHandler.Factory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt index 61ef4bc6c9..adb45733c1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt @@ -27,6 +27,7 @@ import com.tangem.common.ui.news.ArticleHeader import com.tangem.core.ui.R import com.tangem.core.ui.components.SecondaryButtonIconStart import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerHMax import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.components.buttons.common.TangemButtonSize import com.tangem.core.ui.components.pager.PagerIndicator @@ -171,7 +172,10 @@ private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onL items = article.sources, key = SourceUM::id, ) { source -> - SourceItem(source = source) + SourceItem( + source = source, + modifier = Modifier.fillParentMaxHeight(), + ) } } } @@ -259,9 +263,11 @@ private fun SourceItem(source: SourceUM, modifier: Modifier = Modifier) { color = TangemTheme.colors.text.primary1, maxLines = 3, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(bottom = 12.dp), ) } + + SpacerHMax() + Text( text = source.publishedAt.resolveReference(), style = TangemTheme.typography.caption2, From 0c033feefea998afeab98058d43d068f8914a6b1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Dec 2025 20:50:17 +0500 Subject: [PATCH 037/172] Updated on 2026-08-14 --- .../tangem/core/ui/ds/badge/TangemBadge.kt | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt new file mode 100644 index 0000000000..1c9fd7cf14 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt @@ -0,0 +1,321 @@ +package com.tangem.core.ui.ds.badge + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +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.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +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.R +import com.tangem.core.ui.ds.badge.TangemBadgeSize.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * Tangem badge component to display a small piece of information with optional icon. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8441-83535&m=dev) + * + * @param text TextReference for the badge label. + * @param modifier Modifier to be applied to the badge. + * @param iconRes Drawable resource ID for the icon to be displayed in the badge. + * @param size [TangemBadgeSize] defining the size of the badge. + * @param shape [TangemBadgeShape] defining the shape of the badge. + * @param color [TangemBadgeColor] defining the color scheme of the badge. + * @param type [TangemBadgeType] defining the style of the badge. + * @param iconPosition [TangemBadgeIconPosition] defining icon position of the badge. + * +[REDACTED_AUTHOR] + */ +@Composable +fun TangemBadge( + text: TextReference, + modifier: Modifier = Modifier, + @DrawableRes iconRes: Int? = null, + size: TangemBadgeSize = X9, + shape: TangemBadgeShape = TangemBadgeShape.Default, + color: TangemBadgeColor = TangemBadgeColor.Gray, + type: TangemBadgeType = TangemBadgeType.Solid, + iconPosition: TangemBadgeIconPosition = TangemBadgeIconPosition.Start, +) { + val iconColor = getIconColor(type = type, color = color) + Row( + horizontalArrangement = Arrangement.spacedBy(size.toContentPadding()), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .heightIn(min = size.toHeightDp()) + .clip(shape.toShape(size)) + .getBackgroundColor(type = type, color = color, shape = shape.toShape(size)) + .padding(size.toPaddingDp(position = iconPosition)), + ) { + AnimatedVisibility( + visible = iconRes != null && iconPosition == TangemBadgeIconPosition.Start, + modifier = Modifier.size(size = size.toContentSize()), + label = "Start Icon Visibility", + ) { + val wrappedIconRes = remember(this) { requireNotNull(iconRes) } + Icon( + painter = painterResource(id = wrappedIconRes), + contentDescription = null, + tint = iconColor, + ) + } + Text( + text = text.resolveReference(), + style = size.toTextStyle(), + maxLines = 1, + color = getTextColor(type = type, color = color), + ) + + AnimatedVisibility( + visible = iconRes != null && iconPosition == TangemBadgeIconPosition.End, + modifier = Modifier.size(size = size.toContentSize()), + label = "End Icon Visibility", + ) { + val wrappedIconRes = remember(this) { requireNotNull(iconRes) } + Icon( + painter = painterResource(id = wrappedIconRes), + contentDescription = null, + tint = iconColor, + ) + } + } +} + +/** + * Tangem badge shape options. + */ +enum class TangemBadgeShape { + Default, + Rounded, + ; + + @ReadOnlyComposable + @Composable + internal fun toShape(size: TangemBadgeSize) = RoundedCornerShape( + when (this) { + Rounded -> when (size) { + X4, + X6, + -> TangemTheme.dimens2.x4 + X9 -> TangemTheme.dimens2.x25 + } + Default -> when (size) { + X4 -> TangemTheme.dimens2.x1 + X6, + X9, + -> 6.dp + } + }, + ) +} + +/** + * Tangem badge size options. + */ +enum class TangemBadgeSize { + X4, + X6, + X9, + ; + + @ReadOnlyComposable + @Composable + internal fun toHeightDp() = when (this) { + X4 -> TangemTheme.dimens2.x4 + X6 -> TangemTheme.dimens2.x6 + X9 -> TangemTheme.dimens2.x9 + } + + @ReadOnlyComposable + @Composable + internal fun toPaddingDp(position: TangemBadgeIconPosition) = when (this) { + X4 -> when (position) { + TangemBadgeIconPosition.Start -> PaddingValues(start = 4.dp, end = 6.dp) + TangemBadgeIconPosition.End -> PaddingValues(start = 6.dp, end = 4.dp) + } + X6 -> when (position) { + TangemBadgeIconPosition.Start -> PaddingValues(start = 8.dp, end = 12.dp) + TangemBadgeIconPosition.End -> PaddingValues(start = 12.dp, end = 8.dp) + } + X9 -> when (position) { + TangemBadgeIconPosition.Start -> PaddingValues(start = 12.dp, end = 16.dp) + TangemBadgeIconPosition.End -> PaddingValues(start = 16.dp, end = 12.dp) + } + } + + @ReadOnlyComposable + @Composable + internal fun toContentSize() = when (this) { + X4 -> TangemTheme.dimens2.x3 + X6, + X9, + -> TangemTheme.dimens2.x4 + } + + @ReadOnlyComposable + @Composable + internal fun toContentPadding() = when (this) { + X4 -> TangemTheme.dimens2.x0_5 + X6, + X9, + -> TangemTheme.dimens2.x1 + } + + @ReadOnlyComposable + @Composable + internal fun toTextStyle() = when (this) { + X4 -> TangemTheme.typography2.captionSemibold11 + X6 -> TangemTheme.typography2.captionSemibold12 + X9 -> TangemTheme.typography2.bodySemibold16 + } +} + +/** + * Position of the icon in the Tangem badge. + */ +enum class TangemBadgeIconPosition { + Start, + End, +} + +/** + * Tangem badge type options. + */ +enum class TangemBadgeType { + Solid, + Tinted, + Outline, +} + +/** + * Tangem badge color options. + */ +enum class TangemBadgeColor { + Blue, + Red, + Gray, +} + +@ReadOnlyComposable +@Composable +private fun getIconColor(type: TangemBadgeType, color: TangemBadgeColor) = when (color) { + TangemBadgeColor.Gray -> TangemTheme.colors2.markers.iconGray + TangemBadgeColor.Blue -> when (type) { + TangemBadgeType.Outline, + TangemBadgeType.Tinted, + -> TangemTheme.colors2.markers.iconBlue + TangemBadgeType.Solid -> TangemTheme.colors2.graphic.neutral.primaryInvertedConstant + } + TangemBadgeColor.Red -> when (type) { + TangemBadgeType.Outline, + TangemBadgeType.Tinted, + -> TangemTheme.colors2.markers.iconRed + TangemBadgeType.Solid -> TangemTheme.colors2.graphic.neutral.primaryInvertedConstant + } +} + +@ReadOnlyComposable +@Composable +private fun getTextColor(type: TangemBadgeType, color: TangemBadgeColor) = when (color) { + TangemBadgeColor.Gray -> TangemTheme.colors2.markers.textGray + TangemBadgeColor.Blue -> when (type) { + TangemBadgeType.Outline, + TangemBadgeType.Tinted, + -> TangemTheme.colors2.markers.textBlue + TangemBadgeType.Solid -> TangemTheme.colors2.text.neutral.primaryInvertedConstant + } + TangemBadgeColor.Red -> when (type) { + TangemBadgeType.Outline, + TangemBadgeType.Tinted, + -> TangemTheme.colors2.markers.textRed + TangemBadgeType.Solid -> TangemTheme.colors2.text.neutral.primaryInvertedConstant + } +} + +@ReadOnlyComposable +@Composable +private fun Modifier.getBackgroundColor(type: TangemBadgeType, color: TangemBadgeColor, shape: Shape) = when (type) { + TangemBadgeType.Solid -> background( + when (color) { + TangemBadgeColor.Gray -> TangemTheme.colors2.markers.backgroundSolidGray + TangemBadgeColor.Blue -> TangemTheme.colors2.markers.backgroundSolidBlue + TangemBadgeColor.Red -> TangemTheme.colors2.markers.backgroundSolidRed + }, + ) + TangemBadgeType.Tinted -> background( + when (color) { + TangemBadgeColor.Gray -> TangemTheme.colors2.markers.backgroundTintedGray + TangemBadgeColor.Blue -> TangemTheme.colors2.markers.backgroundTintedBlue + TangemBadgeColor.Red -> TangemTheme.colors2.markers.backgroundTintedRed + }, + ) + TangemBadgeType.Outline -> { + border( + color = when (color) { + TangemBadgeColor.Gray -> TangemTheme.colors2.markers.borderGray + TangemBadgeColor.Blue -> TangemTheme.colors2.markers.borderTintedBlue + TangemBadgeColor.Red -> TangemTheme.colors2.markers.borderTintedRed + }, + shape = shape, + width = 1.dp, + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemBadge_Preview(@PreviewParameter(TangemBadgePreviewProvider::class) params: TangemBadgeColor) { + TangemThemePreviewRedesign { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + repeat(2) { yIndex -> + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + repeat(TangemBadgeType.entries.size) { index -> + TangemBadge( + text = stringReference("Title"), + iconRes = R.drawable.ic_information_24, + type = TangemBadgeType.entries[index], + color = params, + shape = TangemBadgeShape.entries[yIndex % 2], + iconPosition = TangemBadgeIconPosition.entries[yIndex % 2], + ) + } + } + } + } + } +} + +private class TangemBadgePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemBadgeColor.Gray, + TangemBadgeColor.Blue, + TangemBadgeColor.Red, + ) +} +// endregion \ No newline at end of file From 721ab042e0401e58f2580fd23d49e33081a9c051 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Dec 2025 20:54:19 +0500 Subject: [PATCH 038/172] Updated on 2026-08-14 --- .../tangem/core/ui/ds/topbar/TangemTopBar.kt | 239 ++++++++++++++++++ .../core/ui/ds/topbar/TangemTopBarInner.kt | 90 +++++++ 2 files changed, 329 insertions(+) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBarInner.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt new file mode 100644 index 0000000000..b96dd8d788 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt @@ -0,0 +1,239 @@ +package com.tangem.core.ui.ds.topbar + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextStyle +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 com.tangem.core.ui.R +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * A top bar composable that displays a title and optional start and end icons. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8435-74860&m=dev) + * + * @param title The title text to be displayed in the center of the top bar. + * @param modifier Modifier to be applied to the top bar. + * @param subtitle Optional subtitle text to be displayed below the title. + * @param startIconRes Optional drawable resource ID for the start icon. + * @param onStartContentClick Optional click action for the start icon. + * @param endIconRes Optional drawable resource ID for the end icon. + * @param onEndContentClick Optional click action for the end icon. + * @param isGhostButtons Flag to determine if ghost button styling should be applied. + * +[REDACTED_AUTHOR] + */ +@Composable +fun TangemTopBar( + modifier: Modifier = Modifier, + title: TextReference? = null, + subtitle: TextReference? = null, + @DrawableRes startIconRes: Int? = null, + onStartContentClick: (() -> Unit)? = null, + @DrawableRes endIconRes: Int? = null, + onEndContentClick: (() -> Unit)? = null, + @DrawableRes titleIconRes: Int? = null, + titleStyle: TextStyle = TangemTheme.typography2.headingSemibold17, + isGhostButtons: Boolean = false, +) { + TangemTopBarInner( + modifier = modifier, + content = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5), + ) { + TangemTopBarTitle(title = title, titleIconRes = titleIconRes, titleStyle = titleStyle) + AnimatedVisibility( + visible = subtitle != null, + label = "Subtitle Visibility", + ) { + val wrappedSubtitle = remember(this) { requireNotNull(subtitle) } + Text( + text = wrappedSubtitle.resolveAnnotatedReference(), + color = TangemTheme.colors2.text.neutral.secondary, + style = TangemTheme.typography2.bodyRegular15, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } + } + }, + startContent = if (startIconRes != null) { + { TangemTopBarIcon(iconRes = startIconRes) } + } else { + null + }, + onStartContentClick = onStartContentClick, + endContent = if (endIconRes != null) { + { TangemTopBarIcon(iconRes = endIconRes) } + } else { + null + }, + onEndContentClick = onEndContentClick, + isGhostButtons = isGhostButtons, + ) +} + +@Composable +private fun TangemTopBarTitle(title: TextReference?, @DrawableRes titleIconRes: Int?, titleStyle: TextStyle) { + AnimatedVisibility( + visible = title != null, + label = "Title Visibility", + ) { + val wrappedTitle = remember(this) { requireNotNull(title) } + + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedVisibility( + visible = titleIconRes != null, + label = "Title Icon Visibility", + ) { + val wrappedTitleIconRes = remember(this) { + requireNotNull(titleIconRes) + } + Icon( + imageVector = ImageVector.vectorResource(id = wrappedTitleIconRes), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primary, + modifier = Modifier.size(TangemTheme.dimens2.x4), + ) + } + + Text( + text = wrappedTitle.resolveAnnotatedReference(), + color = TangemTheme.colors2.text.neutral.primary, + style = titleStyle, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } + } +} + +@Composable +private fun TangemTopBarIcon(@DrawableRes iconRes: Int) { + Icon( + imageVector = ImageVector.vectorResource(id = iconRes), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primary, + modifier = Modifier.fillMaxSize(), + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 375) +@Preview(showBackground = true, widthDp = 375, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemTopBar_Preview(@PreviewParameter(PreviewProvider::class) params: TangemTopBarPreviewData) { + TangemThemePreviewRedesign { + TangemTopBar( + title = params.title, + subtitle = params.subtitle, + startIconRes = params.startIconRes, + endIconRes = params.endIconRes, + titleIconRes = params.titleIconRes, + isGhostButtons = params.isGhostButtons, + onStartContentClick = {}, + onEndContentClick = {}, + modifier = Modifier.background(TangemTheme.colors2.surface.level1), + ) + } +} + +private class TangemTopBarPreviewData( + val title: TextReference? = null, + val subtitle: TextReference? = null, + val isGhostButtons: Boolean = false, + val titleIconRes: Int? = null, + val startIconRes: Int? = null, + val endIconRes: Int? = null, +) + +private class PreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemTopBarPreviewData( + title = stringReference("Title"), + startIconRes = R.drawable.ic_tangem_24, + endIconRes = R.drawable.ic_more_vertical_24, + isGhostButtons = true, + ), + TangemTopBarPreviewData( + title = stringReference("Title"), + subtitle = stringReference("Subtitle"), + startIconRes = R.drawable.ic_tangem_24, + endIconRes = R.drawable.ic_more_vertical_24, + isGhostButtons = true, + ), + TangemTopBarPreviewData( + title = stringReference("Title"), + subtitle = stringReference("Subtitle"), + titleIconRes = R.drawable.ic_tangem_24, + startIconRes = R.drawable.ic_tangem_24, + endIconRes = R.drawable.ic_more_vertical_24, + isGhostButtons = true, + ), + TangemTopBarPreviewData( + subtitle = stringReference("Subtitle"), + titleIconRes = R.drawable.ic_tangem_24, + startIconRes = R.drawable.ic_tangem_24, + endIconRes = R.drawable.ic_more_vertical_24, + isGhostButtons = true, + ), + TangemTopBarPreviewData( + title = stringReference("Title"), + endIconRes = R.drawable.ic_more_vertical_24, + isGhostButtons = true, + ), + TangemTopBarPreviewData( + title = stringReference("Title"), + startIconRes = R.drawable.ic_tangem_24, + isGhostButtons = true, + ), + TangemTopBarPreviewData( + title = combinedReference( + stringReference("$ 46,112"), + styledStringReference( + value = ".30", + spanStyleReference = { + TangemTheme.typography.caption1.copy(TangemTheme.colors2.text.neutral.secondary) + .toSpanStyle() + }, + ), + ), + ), + TangemTopBarPreviewData( + title = combinedReference( + stringReference("$ 46,112"), + styledStringReference( + value = ".30", + spanStyleReference = { + TangemTheme.typography.caption1.copy(TangemTheme.colors2.text.neutral.secondary) + .toSpanStyle() + }, + ), + ), + startIconRes = R.drawable.ic_tangem_24, + endIconRes = R.drawable.ic_more_vertical_24, + ), + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBarInner.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBarInner.kt new file mode 100644 index 0000000000..1014c640e4 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBarInner.kt @@ -0,0 +1,90 @@ +package com.tangem.core.ui.ds.topbar + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.conditional +import com.tangem.core.ui.extensions.conditionalCompose +import com.tangem.core.ui.res.TangemTheme + +/** + * Internal top bar composable that arranges optional start, center, and end content. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8435-74860&m=dev) + * + * @param modifier Modifier to be applied to the top bar. + * @param content Center content of the top bar. + * @param startContent Optional start content of the top bar. + * @param onStartContentClick Optional click action for the start content. + * @param endContent Optional end content of the top bar. + * @param onEndContentClick Optional click action for the end content. + * @param isGhostButtons Flag to determine if ghost button styling should be applied. + * +[REDACTED_AUTHOR] + */ +@Composable +internal fun TangemTopBarInner( + modifier: Modifier = Modifier, + content: (@Composable () -> Unit)? = null, + startContent: (@Composable () -> Unit)? = null, + onStartContentClick: (() -> Unit)? = null, + endContent: (@Composable () -> Unit)? = null, + onEndContentClick: (() -> Unit)? = null, + isGhostButtons: Boolean = false, +) { + Box( + modifier = modifier + .height(TangemTheme.dimens2.x16) + .fillMaxWidth() + .padding(TangemTheme.dimens2.x4, TangemTheme.dimens2.x3), + ) { + val iconModifier = Modifier + .size(TangemTheme.dimens2.x10) + .clip(RoundedCornerShape(TangemTheme.dimens2.x25)) + .background(TangemTheme.colors2.button.backgroundSecondary) + + AnimatedVisibility( + visible = startContent != null, + modifier = Modifier.align(Alignment.CenterStart), + label = "Start Content Visibility", + ) { + Box( + modifier = iconModifier + .conditional(onStartContentClick != null) { + clickableSingle { onStartContentClick?.invoke() } + } + .conditionalCompose(isGhostButtons) { padding(TangemTheme.dimens2.x1) }, + ) { + startContent?.invoke() + } + } + + AnimatedVisibility( + visible = content != null, + modifier = Modifier.align(Alignment.Center), + ) { + content?.invoke() + } + + AnimatedVisibility( + visible = endContent != null, + modifier = Modifier.align(Alignment.CenterEnd), + label = "End Content Visibility", + ) { + Box( + modifier = iconModifier + .conditional(onEndContentClick != null) { + clickableSingle { onEndContentClick?.invoke() } + } + .conditionalCompose(isGhostButtons) { padding(TangemTheme.dimens2.x1) }, + ) { + endContent?.invoke() + } + } + } +} \ No newline at end of file From 7d599256d1220cb3d81db9ef3cb3f006901d37f9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Dec 2025 15:05:35 +0200 Subject: [PATCH 039/172] Updated on 2026-08-14 --- .../MockP2PEthPoolAccountResponseFactory.kt | 4 +- .../datasource/api/ethpool/P2PEthPoolApi.kt | 23 +- .../request/P2PEthPoolDepositRequest.kt | 19 -- .../request/P2PEthPoolTransactionRequest.kt | 23 ++ .../request/P2PEthPoolUnstakeRequest.kt | 20 -- .../request/P2PEthPoolWithdrawRequest.kt | 15 -- .../response/P2PEthPoolAccountResponse.kt | 6 +- .../response/P2PEthPoolDepositResponse.kt | 22 -- ...se.kt => P2PEthPoolTransactionResponse.kt} | 14 +- .../response/P2PEthPoolUnstakeResponse.kt | 22 -- .../com/tangem/datasource/di/NetworkModule.kt | 8 + .../staking/DefaultP2PEthPoolRepository.kt | 224 ++++++++---------- ...t => P2PEthPoolStakingAccountConverter.kt} | 29 ++- .../P2PEthPoolStakingBalanceConverter.kt | 46 +--- .../staking/store/BaseStakingBalancesStore.kt | 35 +++ .../staking/store/P2PEthPoolBalancesStore.kt | 24 +- .../staking/store/StakingBalancesStore.kt | 26 +- .../staking/P2PEthPoolStakingAccount.kt | 2 +- .../staking/P2PEthPoolStakingAccountExt.kt | 126 ++++++---- .../model/ethpool/P2PEthPoolAccount.kt | 44 ---- .../staking/model/P2PEthPoolIntegration.kt | 13 +- .../repositories/P2PEthPoolRepository.kt | 22 +- .../StakingBalanceEntryConverter.kt | 12 +- .../converters/YieldBalancesConverter.kt | 11 +- .../helpers/P2PEthPoolTransactionCreator.kt | 13 +- .../TokenDetailsStakingInfoConverter.kt | 6 +- .../com/tangem/lib/crypto/BlockchainUtils.kt | 11 +- 27 files changed, 366 insertions(+), 454 deletions(-) delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolDepositRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolTransactionRequest.kt delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolUnstakeRequest.kt delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolWithdrawRequest.kt delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolDepositResponse.kt rename core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/{P2PEthPoolWithdrawResponse.kt => P2PEthPoolTransactionResponse.kt} (57%) delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnstakeResponse.kt rename data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/{P2PEthPoolAccountConverter.kt => P2PEthPoolStakingAccountConverter.kt} (54%) create mode 100644 data/staking/src/main/java/com/tangem/data/staking/store/BaseStakingBalancesStore.kt delete mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt diff --git a/common/test/src/main/java/com/tangem/common/test/data/staking/MockP2PEthPoolAccountResponseFactory.kt b/common/test/src/main/java/com/tangem/common/test/data/staking/MockP2PEthPoolAccountResponseFactory.kt index 2eafba7703..4dc0b16d03 100644 --- a/common/test/src/main/java/com/tangem/common/test/data/staking/MockP2PEthPoolAccountResponseFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/data/staking/MockP2PEthPoolAccountResponseFactory.kt @@ -35,7 +35,7 @@ object MockP2PEthPoolAccountResponseFactory { availableToUnstake = stakedAmount, availableToWithdraw = BigDecimal.ZERO, exitQueue = P2PEthPoolExitQueueDTO( - total = 0.0, + total = BigDecimal.ZERO, requests = emptyList(), ), ) @@ -55,7 +55,7 @@ object MockP2PEthPoolAccountResponseFactory { availableToUnstake = BigDecimal.ZERO, availableToWithdraw = BigDecimal.ZERO, exitQueue = P2PEthPoolExitQueueDTO( - total = 0.0, + total = BigDecimal.ZERO, requests = emptyList(), ), ) 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 cf3ab2f3e5..99de156115 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 @@ -2,9 +2,7 @@ package com.tangem.datasource.api.ethpool import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest -import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolDepositRequest -import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolUnstakeRequest -import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolWithdrawRequest +import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolTransactionRequest import com.tangem.datasource.api.ethpool.models.response.* import retrofit2.http.* @@ -31,13 +29,13 @@ interface P2PEthPoolApi { * Create unsigned transaction for depositing ETH into a vault. * * @param network Ethereum pool network: "mainnet" or "hoodi" - * @param body Deposit parameters (delegator address, vault address, amount) + * @param body Transaction parameters (delegator address, vault address, amount) */ @POST("api/v1/staking/pool/{network}/staking/deposit") suspend fun createDepositTransaction( @Path("network") network: String, - @Body body: P2PEthPoolDepositRequest, - ): ApiResponse> + @Body body: P2PEthPoolTransactionRequest, + ): ApiResponse> /** * Prepare unstake transaction @@ -45,13 +43,13 @@ interface P2PEthPoolApi { * Create unsigned transaction to initiate unstaking process. * * @param network Ethereum pool network: "mainnet" or "hoodi" - * @param body Unstake parameters (staker public key, stake transaction hash) + * @param body Transaction parameters (delegator address, vault address, amount) */ @POST("api/v1/staking/pool/{network}/staking/unstake") suspend fun createUnstakeTransaction( @Path("network") network: String, - @Body body: P2PEthPoolUnstakeRequest, - ): ApiResponse> + @Body body: P2PEthPoolTransactionRequest, + ): ApiResponse> /** * Prepare withdrawal transaction @@ -59,13 +57,13 @@ interface P2PEthPoolApi { * Create unsigned transaction to withdraw available funds from exit queue. * * @param network Ethereum pool network: "mainnet" or "hoodi" - * @param body Withdrawal parameters (staker address) + * @param body Transaction parameters (delegator address, vault address, amount) */ @POST("api/v1/staking/pool/{network}/staking/withdraw") suspend fun createWithdrawTransaction( @Path("network") network: String, - @Body body: P2PEthPoolWithdrawRequest, - ): ApiResponse> + @Body body: P2PEthPoolTransactionRequest, + ): ApiResponse> /** * Broadcast signed transaction @@ -107,6 +105,7 @@ interface P2PEthPoolApi { * @param vaultAddress Ethereum address of the vault * @param period Optional period filter (30, 60, or 90 days) */ + // TODO p2p not used, consider removing this method @GET("api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}/rewards") suspend fun getRewards( @Path("network") network: String, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolDepositRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolDepositRequest.kt deleted file mode 100644 index e4b3200add..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolDepositRequest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.tangem.datasource.api.ethpool.models.request - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -/** - * Request body for creating deposit transaction - * - * Used in: POST /api/v1/staking/pool/{network}/staking/deposit - */ -@JsonClass(generateAdapter = true) -data class P2PEthPoolDepositRequest( - @Json(name = "delegatorAddress") - val delegatorAddress: String, - @Json(name = "vaultAddress") - val vaultAddress: String, - @Json(name = "amount") - val amount: Double, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolTransactionRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolTransactionRequest.kt new file mode 100644 index 0000000000..500fc969be --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolTransactionRequest.kt @@ -0,0 +1,23 @@ +package com.tangem.datasource.api.ethpool.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigDecimal + +/** + * Unified request body for creating staking transactions (deposit, unstake, withdraw) + * + * Used in: + * - POST /api/v1/staking/pool/{network}/staking/deposit + * - POST /api/v1/staking/pool/{network}/staking/unstake + * - POST /api/v1/staking/pool/{network}/staking/withdraw + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolTransactionRequest( + @Json(name = "delegatorAddress") + val delegatorAddress: String, + @Json(name = "vaultAddress") + val vaultAddress: String, + @Json(name = "amount") + val amount: BigDecimal, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolUnstakeRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolUnstakeRequest.kt deleted file mode 100644 index 0e56cd5112..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolUnstakeRequest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.datasource.api.ethpool.models.request - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -/** - * Request body for creating unstake transaction - * - * Used in: POST /api/v1/staking/pool/{network}/staking/unstake - * - * Note: Documentation seems to contain Bitcoin-related fields (possibly copy-paste error). - * Using as-is per specification. - */ -@JsonClass(generateAdapter = true) -data class P2PEthPoolUnstakeRequest( - @Json(name = "stakerPublicKey") - val stakerPublicKey: String, - @Json(name = "stakeTransactionHash") - val stakeTransactionHash: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolWithdrawRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolWithdrawRequest.kt deleted file mode 100644 index d0a7aac5cf..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolWithdrawRequest.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.datasource.api.ethpool.models.request - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -/** - * Request body for creating withdrawal transaction - * - * Used in: POST /api/v1/staking/pool/{network}/staking/withdraw - */ -@JsonClass(generateAdapter = true) -data class P2PEthPoolWithdrawRequest( - @Json(name = "stakerAddress") - val stakerAddress: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt index 526c3c7d16..647750c927 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt @@ -34,7 +34,7 @@ data class P2PEthPoolStakeDTO( @JsonClass(generateAdapter = true) data class P2PEthPoolExitQueueDTO( @Json(name = "total") - val total: Double, + val total: BigDecimal, @Json(name = "requests") val requests: List, ) @@ -44,11 +44,11 @@ data class P2PEthPoolExitRequestDTO( @Json(name = "ticket") val ticket: String, @Json(name = "totalAssets") - val totalAssets: Double, + val totalAssets: BigDecimal, @Json(name = "timestamp") val timestamp: Long, @Json(name = "withdrawalTimestamp") - val withdrawalTimestamp: Long, + val withdrawalTimestamp: Long?, @Json(name = "isClaimable") val isClaimable: Boolean, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolDepositResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolDepositResponse.kt deleted file mode 100644 index ed922bf827..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolDepositResponse.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.datasource.api.ethpool.models.response - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import org.joda.time.DateTime - -/** - * Response for POST /api/v1/staking/pool/{network}/staking/deposit - */ -@JsonClass(generateAdapter = true) -data class P2PEthPoolDepositResponse( - @Json(name = "amount") - val amount: Double, - @Json(name = "vaultAddress") - val vaultAddress: String, - @Json(name = "delegatorAddress") - val delegatorAddress: String, - @Json(name = "unsignedTransaction") - val unsignedTransaction: P2PEthPoolUnsignedTxDTO, - @Json(name = "createdAt") - val createdAt: DateTime, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolWithdrawResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolTransactionResponse.kt similarity index 57% rename from core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolWithdrawResponse.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolTransactionResponse.kt index 1d5ff5bb4a..9c44476b5c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolWithdrawResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolTransactionResponse.kt @@ -3,14 +3,20 @@ package com.tangem.datasource.api.ethpool.models.response import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import org.joda.time.DateTime +import java.math.BigDecimal /** - * Response for POST /api/v1/staking/pool/{network}/staking/withdraw + * Unified response for staking transactions (deposit, unstake, withdraw) + * + * Response for: + * - POST /api/v1/staking/pool/{network}/staking/deposit + * - POST /api/v1/staking/pool/{network}/staking/unstake + * - POST /api/v1/staking/pool/{network}/staking/withdraw */ @JsonClass(generateAdapter = true) -data class P2PEthPoolWithdrawResponse( +data class P2PEthPoolTransactionResponse( @Json(name = "amount") - val amount: Double, + val amount: BigDecimal, @Json(name = "vaultAddress") val vaultAddress: String, @Json(name = "delegatorAddress") @@ -20,5 +26,5 @@ data class P2PEthPoolWithdrawResponse( @Json(name = "createdAt") val createdAt: DateTime, @Json(name = "tickets") - val tickets: List, + val tickets: List? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnstakeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnstakeResponse.kt deleted file mode 100644 index a3e2391339..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnstakeResponse.kt +++ /dev/null @@ -1,22 +0,0 @@ -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}/staking/unstake - * - * Note: Contains Bitcoin-related fields (likely documentation error). - * Using as-is per specification. - */ -@JsonClass(generateAdapter = true) -data class P2PEthPoolUnstakeResponse( - @Json(name = "stakerPublicKey") - val stakerPublicKey: String, - @Json(name = "stakeTransactionHash") - val stakeTransactionHash: String, - @Json(name = "unstakeTransactionHex") - val unstakeTransactionHex: String, // unsigned - @Json(name = "unstakeFee") - val unstakeFee: Double, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 511a2dab3d..d3f942e798 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -38,6 +38,8 @@ internal object NetworkModule { private const val TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS = 60L private const val STAKE_KIT_API_TIMEOUT_SECONDS = 60L + private const val P2P_ETH_POOL_API_TIMEOUT_SECONDS = 60L + @Provides @Singleton fun provideApiConfigManager( @@ -82,6 +84,12 @@ internal object NetworkModule { return retrofitApiBuilder.build( apiConfigId = ApiConfig.ID.P2PEthPool, applyTimeoutAnnotations = false, + timeouts = Timeouts( + callTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, + connectTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, + readTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, + writeTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, + ), ) } diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt index 46eae8ecb3..4ffe515037 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -2,21 +2,32 @@ package com.tangem.data.staking import arrow.core.Either import arrow.core.getOrElse +import arrow.core.raise.Raise import arrow.core.raise.either import arrow.core.raise.ensure -import com.tangem.data.staking.converters.ethpool.* +import com.tangem.data.staking.converters.ethpool.P2PEthPoolBroadcastResultConverter +import com.tangem.data.staking.converters.ethpool.P2PEthPoolErrorConverter +import com.tangem.data.staking.converters.ethpool.P2PEthPoolRewardConverter +import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingAccountConverter +import com.tangem.data.staking.converters.ethpool.P2PEthPoolUnsignedTxConverter +import com.tangem.data.staking.converters.ethpool.P2PEthPoolVaultConverter import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.ethpool.P2PEthPoolApi import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest -import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolDepositRequest -import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolUnstakeRequest -import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolWithdrawRequest +import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolTransactionRequest +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolTransactionResponse import com.tangem.datasource.local.token.P2PEthPoolVaultsStore +import com.tangem.domain.models.staking.P2PEthPoolStakingAccount import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingOption -import com.tangem.domain.staking.model.ethpool.* -import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult +import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork +import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward +import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx +import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.repositories.P2PEthPoolRepository import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow @@ -36,11 +47,30 @@ internal class DefaultP2PEthPoolRepository( ) : P2PEthPoolRepository { private val vaultConverter = P2PEthPoolVaultConverter - private val accountInfoConverter = P2PEthPoolAccountConverter + private val accountConverter = P2PEthPoolStakingAccountConverter private val rewardConverter = P2PEthPoolRewardConverter private val broadcastResultConverter = P2PEthPoolBroadcastResultConverter private val errorConverter = P2PEthPoolErrorConverter + /** + * Handles P2PEthPool API response with error checking and result extraction. + * Reduces duplication across all API call methods. + */ + private inline fun Raise.handleApiResponse( + response: ApiResponse>, + transform: (T) -> R, + ): R = when (response) { + is ApiResponse.Success -> { + val data = response.data + ensure(data.error == null) { + errorConverter.convertFromErrorDetails(requireNotNull(data.error)) + } + val result = requireNotNull(data.result) { "Result is null in successful response" } + transform(result) + } + is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + } + override suspend fun fetchVaults(network: P2PEthPoolNetwork) { val vaults = if (stakingFeatureToggles.isEthStakingEnabled) { getVaults(network).getOrElse { error -> @@ -56,16 +86,8 @@ internal class DefaultP2PEthPoolRepository( override suspend fun getVaults(network: P2PEthPoolNetwork): Either> = either { withContext(dispatchers.io) { - when (val response = p2pEthPoolApi.getVaults(network.value)) { - is ApiResponse.Success -> { - val data = response.data - ensure(data.error == null) { - errorConverter.convertFromErrorDetails(requireNotNull(data.error)) - } - val result = requireNotNull(data.result) { "Result is null in successful response" } - result.vaults.map { vaultConverter.convert(it) } - } - is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + handleApiResponse(p2pEthPoolApi.getVaults(network.value)) { result -> + result.vaults.map { vaultConverter.convert(it) } } } } @@ -75,81 +97,60 @@ internal class DefaultP2PEthPoolRepository( delegatorAddress: String, vaultAddress: String, amount: String, - ): Either = either { - withContext(dispatchers.io) { - val requestBody = P2PEthPoolDepositRequest( - delegatorAddress = delegatorAddress, - vaultAddress = vaultAddress, - amount = amount.toDoubleOrNull() ?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")), - ) - val response = p2pEthPoolApi.createDepositTransaction(network.value, requestBody) - when (response) { - is ApiResponse.Success -> { - val data = response.data - ensure(data.error == null) { - errorConverter.convertFromErrorDetails(requireNotNull(data.error)) - } - val result = requireNotNull(data.result) { "Result is null in successful response" } - P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction) - } - is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) - } - } - } + ): Either = createStakingTransaction( + network = network, + delegatorAddress = delegatorAddress, + vaultAddress = vaultAddress, + amount = amount, + apiCall = p2pEthPoolApi::createDepositTransaction, + ) override suspend fun createUnstakeTransaction( network: P2PEthPoolNetwork, - stakerPublicKey: String, - stakeTransactionHash: String, - ): Either = either { - withContext(dispatchers.io) { - val requestBody = P2PEthPoolUnstakeRequest( - stakerPublicKey = stakerPublicKey, - stakeTransactionHash = stakeTransactionHash, - ) - val response = p2pEthPoolApi.createUnstakeTransaction(network.value, requestBody) - when (response) { - is ApiResponse.Success -> { - val data = response.data - ensure(data.error == null) { - errorConverter.convertFromErrorDetails(requireNotNull(data.error)) - } - val result = requireNotNull(data.result) { "Result is null in successful response" } - // Note: API returns only hex string for unstake, not full transaction structure - P2PEthPoolUnsignedTx( - serializeTx = result.unstakeTransactionHex, - to = "", // Will be parsed from hex by wallet - data = result.unstakeTransactionHex, - value = java.math.BigDecimal.ZERO, - nonce = 0, - chainId = network.chainId, - gasLimit = java.math.BigDecimal.ZERO, - maxFeePerGas = java.math.BigDecimal.ZERO, - maxPriorityFeePerGas = java.math.BigDecimal.ZERO, - ) - } - is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) - } - } - } + delegatorAddress: String, + vaultAddress: String, + amount: String, + ): Either = createStakingTransaction( + network = network, + delegatorAddress = delegatorAddress, + vaultAddress = vaultAddress, + amount = amount, + apiCall = p2pEthPoolApi::createUnstakeTransaction, + ) override suspend fun createWithdrawTransaction( network: P2PEthPoolNetwork, - stakerAddress: String, + delegatorAddress: String, + vaultAddress: String, + amount: String, + ): Either = createStakingTransaction( + network = network, + delegatorAddress = delegatorAddress, + vaultAddress = vaultAddress, + amount = amount, + apiCall = p2pEthPoolApi::createWithdrawTransaction, + ) + + private suspend fun createStakingTransaction( + network: P2PEthPoolNetwork, + delegatorAddress: String, + vaultAddress: String, + amount: String, + apiCall: + suspend ( + String, + P2PEthPoolTransactionRequest, + ) -> ApiResponse>, ): Either = either { withContext(dispatchers.io) { - val requestBody = P2PEthPoolWithdrawRequest(stakerAddress = stakerAddress) - val response = p2pEthPoolApi.createWithdrawTransaction(network.value, requestBody) - when (response) { - is ApiResponse.Success -> { - val data = response.data - ensure(data.error == null) { - errorConverter.convertFromErrorDetails(requireNotNull(data.error)) - } - val result = requireNotNull(data.result) { "Result is null in successful response" } - P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction) - } - is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + val requestBody = P2PEthPoolTransactionRequest( + delegatorAddress = delegatorAddress, + vaultAddress = vaultAddress, + amount = amount.toBigDecimalOrNull() + ?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")), + ) + handleApiResponse(apiCall(network.value, requestBody)) { result -> + P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction) } } } @@ -160,17 +161,8 @@ internal class DefaultP2PEthPoolRepository( ): Either = either { withContext(dispatchers.io) { val requestBody = P2PEthPoolBroadcastRequest(signedTransaction = signedTransaction) - val response = p2pEthPoolApi.broadcastTransaction(network.value, requestBody) - when (response) { - is ApiResponse.Success -> { - val data = response.data - ensure(data.error == null) { - errorConverter.convertFromErrorDetails(requireNotNull(data.error)) - } - val result = requireNotNull(data.result) { "Result is null in successful response" } - broadcastResultConverter.convert(result) - } - is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + handleApiResponse(p2pEthPoolApi.broadcastTransaction(network.value, requestBody)) { result -> + broadcastResultConverter.convert(result) } } } @@ -179,19 +171,12 @@ internal class DefaultP2PEthPoolRepository( network: P2PEthPoolNetwork, delegatorAddress: String, vaultAddress: String, - ): Either = either { + ): Either = either { withContext(dispatchers.io) { - val response = p2pEthPoolApi.getAccountInfo(network.value, delegatorAddress, vaultAddress) - when (response) { - is ApiResponse.Success -> { - val data = response.data - ensure(data.error == null) { - errorConverter.convertFromErrorDetails(requireNotNull(data.error)) - } - val result = requireNotNull(data.result) { "Result is null in successful response" } - accountInfoConverter.convert(result) - } - is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + handleApiResponse( + p2pEthPoolApi.getAccountInfo(network.value, delegatorAddress, vaultAddress), + ) { result -> + accountConverter.convert(result) } } } @@ -203,22 +188,15 @@ internal class DefaultP2PEthPoolRepository( period: Int?, ): Either> = either { withContext(dispatchers.io) { - val response = p2pEthPoolApi.getRewards( - network = network.value, - delegatorAddress = delegatorAddress, - vaultAddress = vaultAddress, - period = period, - ) - when (response) { - is ApiResponse.Success -> { - val data = response.data - ensure(data.error == null) { - errorConverter.convertFromErrorDetails(requireNotNull(data.error)) - } - val result = requireNotNull(data.result) { "Result is null in successful response" } - result.rewards.map { rewardConverter.convert(it) } - } - is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + handleApiResponse( + p2pEthPoolApi.getRewards( + network = network.value, + delegatorAddress = delegatorAddress, + vaultAddress = vaultAddress, + period = period, + ), + ) { result -> + result.rewards.map { rewardConverter.convert(it) } } } } diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingAccountConverter.kt similarity index 54% rename from data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt rename to data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingAccountConverter.kt index 3fd0209523..5661bf65ad 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingAccountConverter.kt @@ -4,17 +4,20 @@ import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountRespon import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitQueueDTO import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitRequestDTO import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolStakeDTO -import com.tangem.domain.staking.model.ethpool.* +import com.tangem.domain.models.staking.P2PEthPoolExitQueue +import com.tangem.domain.models.staking.P2PEthPoolExitRequest +import com.tangem.domain.models.staking.P2PEthPoolStake +import com.tangem.domain.models.staking.P2PEthPoolStakingAccount import com.tangem.utils.converter.Converter -import org.joda.time.Instant +import kotlinx.datetime.Instant /** - * Converter from P2PEthPool Account Info Response to Domain model + * Converts P2PEthPool Account API response to domain [P2PEthPoolStakingAccount]. */ -internal object P2PEthPoolAccountConverter : Converter { +internal object P2PEthPoolStakingAccountConverter : Converter { - override fun convert(value: P2PEthPoolAccountResponse): P2PEthPoolAccount { - return P2PEthPoolAccount( + override fun convert(value: P2PEthPoolAccountResponse): P2PEthPoolStakingAccount { + return P2PEthPoolStakingAccount( delegatorAddress = value.delegatorAddress, vaultAddress = value.vaultAddress, stake = convertStake(value.stake), @@ -24,26 +27,26 @@ internal object P2PEthPoolAccountConverter : Converter BigDecimal.ZERO || account.exitQueue.total > BigDecimal.ZERO || @@ -45,28 +39,4 @@ internal object P2PEthPoolStakingBalanceConverter { ) } } - - private fun convertStake(dto: P2PEthPoolStakeDTO): P2PEthPoolStake { - return P2PEthPoolStake( - assets = dto.assets, - totalEarnedAssets = dto.totalEarnedAssets, - ) - } - - private fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PEthPoolExitQueue { - return P2PEthPoolExitQueue( - total = dto.total.toBigDecimal(), - requests = dto.requests.map(::convertExitRequest), - ) - } - - private fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PEthPoolExitRequest { - return P2PEthPoolExitRequest( - ticket = dto.ticket, - totalAssets = dto.totalAssets.toBigDecimal(), - timestamp = Instant.fromEpochSeconds(dto.timestamp), - withdrawalTimestamp = Instant.fromEpochSeconds(dto.withdrawalTimestamp), - isClaimable = dto.isClaimable, - ) - } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/BaseStakingBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/BaseStakingBalancesStore.kt new file mode 100644 index 0000000000..a9763e801e --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/store/BaseStakingBalancesStore.kt @@ -0,0 +1,35 @@ +package com.tangem.data.staking.store + +import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.models.staking.StakingID +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow + +/** + * Base interface for staking balances stores. + * + * Defines common read/query operations shared by all staking provider stores. + */ +interface BaseStakingBalancesStore { + + /** Get flow of staking balances for a wallet */ + fun get(userWalletId: UserWalletId): Flow> + + /** Get a single staking balance synchronously */ + suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): StakingBalance? + + /** Get all staking balances for a wallet synchronously */ + suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set? + + /** Refresh a single staking balance from cache */ + suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID) + + /** Refresh multiple staking balances from cache */ + suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set) + + /** Store error state for staking balances */ + suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set) + + /** Clear staking balances */ + suspend fun clear(userWalletId: UserWalletId, stakingIds: Set) +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt index 06d300af6e..ecd7442ce6 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt @@ -1,31 +1,19 @@ package com.tangem.data.staking.store import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse -import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId -import kotlinx.coroutines.flow.Flow /** - * Store for P2PEthPool staking balances + * Store for P2PEthPool staking balances. + * + * Extends [BaseStakingBalancesStore] with P2PEthPool-specific storage operations. */ -interface P2PEthPoolBalancesStore { - - fun get(userWalletId: UserWalletId): Flow> - - suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): StakingBalance? - - suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set? - - suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID) - - suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set) +interface P2PEthPoolBalancesStore : BaseStakingBalancesStore { + /** Store actual P2PEthPool account balances */ suspend fun storeActual(userWalletId: UserWalletId, values: Set) + /** Store empty state for accounts with no active positions */ suspend fun storeEmpty(userWalletId: UserWalletId, stakingIds: Set) - - suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set) - - suspend fun clear(userWalletId: UserWalletId, stakingIds: Set) } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/StakingBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/StakingBalancesStore.kt index 5d730dd5b7..aff991ff47 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/StakingBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/StakingBalancesStore.kt @@ -1,27 +1,15 @@ package com.tangem.data.staking.store import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO -import com.tangem.domain.models.staking.StakingBalance -import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId -import kotlinx.coroutines.flow.Flow -/** Store of StakeKit [StakingBalance] */ -interface StakingBalancesStore { - - fun get(userWalletId: UserWalletId): Flow> - - suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): StakingBalance? - - suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set? - - suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID) - - suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set) +/** + * Store for StakeKit staking balances. + * + * Extends [BaseStakingBalancesStore] with StakeKit-specific storage operations. + */ +interface StakingBalancesStore : BaseStakingBalancesStore { + /** Store actual StakeKit yield balances */ suspend fun storeActual(userWalletId: UserWalletId, values: Set) - - suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set) - - suspend fun clear(userWalletId: UserWalletId, stakingIds: Set) } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccount.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccount.kt index d196a17cbc..a6d8489e14 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccount.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccount.kt @@ -32,6 +32,6 @@ data class P2PEthPoolExitRequest( val ticket: String, val totalAssets: SerializedBigDecimal, val timestamp: Instant, - val withdrawalTimestamp: Instant, + val withdrawalTimestamp: Instant?, val isClaimable: Boolean, ) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccountExt.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccountExt.kt index c189a5465f..69db40bd72 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccountExt.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccountExt.kt @@ -5,60 +5,82 @@ import java.math.BigDecimal fun P2PEthPoolStakingAccount.toStakingBalanceEntries(vaultName: String? = null): List { return buildList { if (stake.assets > BigDecimal.ZERO) { - add( - StakingBalanceEntry( - id = vaultAddress, - type = StakingEntryType.STAKED, - amount = stake.assets, - validator = ValidatorInfo(address = vaultAddress, name = vaultName), - date = null, - actions = StakingEntryActions.P2PEthPool( - ticket = null, - estimatedWithdrawalDate = null, - isClaimable = false, - ), - isPending = false, - rawCurrencyId = null, - ), - ) + add(createStakedEntry(vaultAddress, stake.assets, vaultName)) } - - exitQueue.requests.forEach { request -> - add( - StakingBalanceEntry( - id = "${vaultAddress}_${request.ticket}", - type = StakingEntryType.UNSTAKING, - amount = request.totalAssets, - validator = ValidatorInfo(address = vaultAddress, name = vaultName), - date = request.withdrawalTimestamp, - actions = StakingEntryActions.P2PEthPool( - ticket = request.ticket, - estimatedWithdrawalDate = request.withdrawalTimestamp, - isClaimable = request.isClaimable, - ), - isPending = false, - rawCurrencyId = null, - ), - ) - } - + exitQueue.requests.filter { !it.isClaimable }.forEach { add(createUnstakingEntry(vaultAddress, it, vaultName)) } if (availableToWithdraw > BigDecimal.ZERO) { - add( - StakingBalanceEntry( - id = "${vaultAddress}_withdrawable", - type = StakingEntryType.WITHDRAWABLE, - amount = availableToWithdraw, - validator = ValidatorInfo(address = vaultAddress, name = vaultName), - date = null, - actions = StakingEntryActions.P2PEthPool( - ticket = null, - estimatedWithdrawalDate = null, - isClaimable = true, - ), - isPending = false, - rawCurrencyId = null, - ), - ) + add(createWithdrawableEntry(vaultAddress, availableToWithdraw, vaultName)) + } + if (stake.totalEarnedAssets > BigDecimal.ZERO) { + add(createRewardsEntry(vaultAddress, stake.totalEarnedAssets, vaultName)) } } +} + +private fun createStakedEntry(vaultAddress: String, amount: BigDecimal, vaultName: String?): StakingBalanceEntry { + return StakingBalanceEntry( + id = vaultAddress, + type = StakingEntryType.STAKED, + amount = amount, + validator = ValidatorInfo(address = vaultAddress, name = vaultName), + date = null, + actions = StakingEntryActions.P2PEthPool(ticket = null, estimatedWithdrawalDate = null, isClaimable = false), + isPending = false, + rawCurrencyId = null, + ) +} + +private fun createUnstakingEntry( + vaultAddress: String, + request: P2PEthPoolExitRequest, + vaultName: String?, +): StakingBalanceEntry { + return StakingBalanceEntry( + id = "${vaultAddress}_${request.ticket}", + type = StakingEntryType.UNSTAKING, + amount = request.totalAssets, + validator = ValidatorInfo(address = vaultAddress, name = vaultName), + date = request.withdrawalTimestamp, + actions = StakingEntryActions.P2PEthPool( + ticket = request.ticket, + estimatedWithdrawalDate = request.withdrawalTimestamp, + isClaimable = false, + ), + isPending = false, + rawCurrencyId = null, + ) +} + +private fun createWithdrawableEntry( + vaultAddress: String, + amount: BigDecimal, + vaultName: String?, +): StakingBalanceEntry { + return StakingBalanceEntry( + id = "${vaultAddress}_withdrawable", + type = StakingEntryType.WITHDRAWABLE, + amount = amount, + validator = ValidatorInfo(address = vaultAddress, name = vaultName), + date = null, + actions = StakingEntryActions.P2PEthPool( + ticket = null, + estimatedWithdrawalDate = null, + isClaimable = true, + ), + isPending = false, + rawCurrencyId = null, + ) +} + +private fun createRewardsEntry(vaultAddress: String, amount: BigDecimal, vaultName: String?): StakingBalanceEntry { + return StakingBalanceEntry( + id = "${vaultAddress}_rewards", + type = StakingEntryType.REWARDS, + amount = amount, + validator = ValidatorInfo(address = vaultAddress, name = vaultName), + date = null, + actions = StakingEntryActions.P2PEthPool(ticket = null, estimatedWithdrawalDate = null, isClaimable = false), + isPending = false, + rawCurrencyId = null, + ) } \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt deleted file mode 100644 index 56476007cb..0000000000 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.tangem.domain.staking.model.ethpool - -import com.tangem.domain.models.serialization.SerializedBigDecimal -import org.joda.time.Instant - -/** - * Account staking information - * Contains detailed balance and exit queue information - */ -data class P2PEthPoolAccount( - val delegatorAddress: String, - val vaultAddress: String, - val stake: P2PEthPoolStake, - val availableToUnstake: SerializedBigDecimal, - val availableToWithdraw: SerializedBigDecimal, - val exitQueue: P2PEthPoolExitQueue, -) - -/** - * Current stake information - */ -data class P2PEthPoolStake( - val assets: SerializedBigDecimal, - val totalEarnedAssets: SerializedBigDecimal, -) - -/** - * Exit queue information - */ -data class P2PEthPoolExitQueue( - val total: SerializedBigDecimal, - val requests: List, -) - -/** - * Individual exit request in the queue - */ -data class P2PEthPoolExitRequest( - val ticket: String, - val totalAssets: SerializedBigDecimal, - val timestamp: Instant, - val withdrawalTimestamp: Instant, - val isClaimable: Boolean, -) \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt index 8842e9281c..a47b643544 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt @@ -15,7 +15,7 @@ import java.math.BigDecimal */ class P2PEthPoolIntegration( override val integrationId: StakingIntegrationID, - vaults: List, + private val vaults: List, ) : StakingIntegration { // Basic @@ -46,7 +46,7 @@ class P2PEthPoolIntegration( amountRequirement = StakingAmountRequirement( isRequired = true, minimum = DEFAULT_MINIMUM_STAKE, - maximum = null, + maximum = calculateMaximumStakeAmount(), ), isPartialAmountDisabled = false, ) @@ -75,6 +75,15 @@ class P2PEthPoolIntegration( override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = token + private fun calculateMaximumStakeAmount(): BigDecimal? { + return vaults + .mapNotNull { vault -> + val availableCapacity = vault.capacity - vault.totalAssets + if (availableCapacity > BigDecimal.ZERO) availableCapacity else null + } + .maxOrNull() + } + companion object { private const val MIN_COOLDOWN_DAYS = 1 private const val MAX_COOLDOWN_DAYS = 4 diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt index 04892f5414..7b4e4568e2 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt @@ -1,8 +1,8 @@ package com.tangem.domain.staking.repositories import arrow.core.Either +import com.tangem.domain.models.staking.P2PEthPoolStakingAccount import com.tangem.domain.staking.model.StakingAvailability -import com.tangem.domain.staking.model.ethpool.P2PEthPoolAccount import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward @@ -54,14 +54,16 @@ interface P2PEthPoolRepository { * to withdraw the funds. * * @param network P2PEthPool network (MAINNET or TESTNET) - * @param stakerPublicKey Staker's public key (note: API doc may have Bitcoin terminology) - * @param stakeTransactionHash Original stake transaction hash + * @param delegatorAddress User's wallet address + * @param vaultAddress Vault contract address + * @param amount Amount of ETH to unstake * @return Either error or unsigned transaction */ suspend fun createUnstakeTransaction( network: P2PEthPoolNetwork, - stakerPublicKey: String, - stakeTransactionHash: String, + delegatorAddress: String, + vaultAddress: String, + amount: String, ): Either /** @@ -70,12 +72,16 @@ interface P2PEthPoolRepository { * Only works when funds are available (after exit queue wait period). * * @param network P2PEthPool network (MAINNET or TESTNET) - * @param stakerAddress User's wallet address + * @param delegatorAddress User's wallet address + * @param vaultAddress Vault contract address + * @param amount Amount of ETH to withdraw * @return Either error or unsigned transaction with withdrawal tickets */ suspend fun createWithdrawTransaction( network: P2PEthPoolNetwork, - stakerAddress: String, + delegatorAddress: String, + vaultAddress: String, + amount: String, ): Either /** @@ -104,7 +110,7 @@ interface P2PEthPoolRepository { network: P2PEthPoolNetwork, delegatorAddress: String, vaultAddress: String, - ): Either + ): Either /** * Get rewards history for account and vault diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt index 3cfec3cb1c..a46b3a7c79 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt @@ -27,7 +27,6 @@ import com.tangem.lib.crypto.BlockchainUtils import com.tangem.lib.crypto.BlockchainUtils.isTon import com.tangem.utils.Provider import com.tangem.utils.converter.Converter -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.datetime.Instant import java.math.BigDecimal @@ -197,7 +196,11 @@ internal class StakingBalanceEntryConverter( private fun StakingBalanceEntry.getPendingActions(): List { return when (val actions = this.actions) { is StakingEntryActions.StakeKit -> actions.pendingActions - is StakingEntryActions.P2PEthPool -> persistentListOf() + is StakingEntryActions.P2PEthPool -> if (type == StakingEntryType.WITHDRAWABLE) { + listOf(STUB_WITHDRAW_ACTION) + } else { + emptyList() + } } } @@ -223,6 +226,11 @@ internal class StakingBalanceEntryConverter( } private companion object { + val STUB_WITHDRAW_ACTION = PendingAction( + StakingActionType.WITHDRAW, + passthrough = "", + args = null, + ) const val DAY_IN_MILLIS = 24 * 60 * 60 * 1000 } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt index 3fcfbe6f06..5e73aad950 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt @@ -81,14 +81,7 @@ internal class YieldBalancesConverter( private fun getRewardBlockType(stakingBalance: StakingBalance?): RewardBlockType { val blockchainId = cryptoCurrencyStatus.currency.network.rawId - - if (stakingBalance is StakingBalance.Data.P2PEthPool) { - return if (isStakingRewardUnavailable(blockchainId)) { - RewardBlockType.RewardUnavailable.DefaultRewardUnavailable - } else { - RewardBlockType.NoRewards - } - } + val isCoin = cryptoCurrencyStatus.currency.id.isCoin val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit val rewards = stakeKitBalance?.balance?.items @@ -98,7 +91,7 @@ internal class YieldBalancesConverter( val isRewardsClaimable = rewards?.isNotEmpty() == true return when { - isStakingRewardUnavailable(blockchainId) -> { + isStakingRewardUnavailable(blockchainId, isCoin) -> { if (BlockchainUtils.isSolana(blockchainId)) { RewardBlockType.RewardUnavailable.SolanaRewardUnavailable } else { diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt index ed2e4f77df..09ee8a1b5c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt @@ -75,13 +75,20 @@ internal class P2PEthPoolTransactionCreator @Inject constructor( ) } is StakingActionCommonType.Exit -> { - p2pEthPoolRepository.createWithdrawTransaction( + p2pEthPoolRepository.createUnstakeTransaction( network = network, - stakerAddress = sourceAddress, + delegatorAddress = sourceAddress, + vaultAddress = vaultAddress, + amount = amount.toPlainString(), ) } is StakingActionCommonType.Pending -> { - Either.Left(StakingError.DomainError("Pending actions not supported for P2PEthPool")) + p2pEthPoolRepository.createWithdrawTransaction( + network = network, + delegatorAddress = sourceAddress, + vaultAddress = vaultAddress, + amount = amount.toPlainString(), + ) } } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt index d93f8c4469..81f0f9314e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt @@ -174,8 +174,12 @@ internal class TokenDetailsStakingInfoConverter( private fun getRewardText(status: CryptoCurrencyStatus, stakingRewardAmount: BigDecimal?): TextReference { val blockchainId = status.currency.network.rawId + val isCoin = status.currency.id.isCoin + val rewardBlockType = when { - isStakingRewardUnavailable(blockchainId) -> RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + isStakingRewardUnavailable(blockchainId, isCoin) -> { + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + } stakingRewardAmount.isNullOrZero() -> RewardBlockType.NoRewards else -> RewardBlockType.Rewards } diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt index b51011e498..e43e2690a6 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt @@ -115,6 +115,11 @@ object BlockchainUtils { return blockchain == Blockchain.BSC || blockchain == Blockchain.BSCTestnet } + fun isEthereum(blockchainId: String): Boolean { + val blockchain = Blockchain.fromId(blockchainId) + return blockchain == Blockchain.Ethereum || blockchain == Blockchain.EthereumTestnet + } + data class BlockchainInfo( val blockchainId: String, val name: String, @@ -155,8 +160,10 @@ object BlockchainUtils { return blockchain != Blockchain.Cardano } - fun isStakingRewardUnavailable(blockchainId: String): Boolean { - return isSolana(blockchainId) || isBSC(blockchainId) || isTon(blockchainId) + fun isStakingRewardUnavailable(blockchainId: String, isCoin: Boolean): Boolean { + val isP2PEthPool = isEthereum(blockchainId) && isCoin + + return isSolana(blockchainId) || isBSC(blockchainId) || isTon(blockchainId) || isP2PEthPool } /** Checks if the blockchain uses case-insensitive contract addresses */ From 028b21b112469336a76c5411112711438e9094fe Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Dec 2025 14:08:06 +0100 Subject: [PATCH 040/172] Updated on 2026-08-14 --- .../common/ui/news/ArticleLoadingCard.kt | 3 +- core/res/src/main/res/values/strings.xml | 1 + .../components/DefaultFeedEntryComponent.kt | 2 + .../details/DefaultNewsDetailsComponent.kt | 5 +- .../model/news/details/NewsDetailsModel.kt | 202 +++++++++++++++--- .../details/converter/NewsDetailsConverter.kt | 2 + .../converter/RelatedTokenConverter.kt | 104 +++++++++ .../TokenMarketInfoToParamsConverter.kt | 28 +++ .../factory/NewsDetailsStateFactory.kt | 62 ++++++ .../ui/news/details/NewsDetailsContent.kt | 142 +++++++----- .../details/components/RelatedTokensBlock.kt | 73 +++++++ .../news/details/state/MockArticlesFactory.kt | 15 ++ .../ui/news/details/state/NewsDetailsUM.kt | 21 +- 13 files changed, 574 insertions(+), 86 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/RelatedTokenConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/TokenMarketInfoToParamsConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsStateFactory.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleLoadingCard.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleLoadingCard.kt index 4ef5f1ce75..300d41f104 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleLoadingCard.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleLoadingCard.kt @@ -41,8 +41,9 @@ fun TrendingLoadingArticle(modifier: Modifier = Modifier) { } @Composable -fun DefaultLoadingArticle() { +fun DefaultLoadingArticle(modifier: Modifier = Modifier) { BlockCard( + modifier = modifier, colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), ) { Column(modifier = Modifier.padding(12.dp)) { diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 2e2e66c3af..bd4e096fbd 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -817,6 +817,7 @@ Quick recap Related News + Related tokens Sources Stay in the loop NFC is not available on your device 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 28f4ccb71c..172c7d6108 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 @@ -86,6 +86,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( articleId = articleId, onBackClicked = { onChildBack() }, preselectedArticlesId = preselectedArticlesId, + onTokenClick = { token, currency -> onMarketItemClick(token, currency) }, ), ), ) @@ -195,6 +196,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( articleId = entryRoute.articleId, onBackClicked = { router.pop() }, preselectedArticlesId = entryRoute.preselectedArticlesId, + onTokenClick = { token, currency -> clickIntents.onMarketItemClick(token, currency) }, ), ) null -> FeedEntryChildFactory.Child.Feed diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt index 004f1db029..fe6ca72911 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt @@ -13,6 +13,8 @@ import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.model.news.details.NewsDetailsModel import com.tangem.features.feed.ui.news.details.NewsDetailsContent import kotlinx.serialization.Serializable @@ -57,8 +59,7 @@ internal class DefaultNewsDetailsComponent( data class Params( val articleId: Int, val onBackClicked: () -> Unit, + val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), val preselectedArticlesId: List = emptyList(), - val tokenIds: List = emptyList(), - val categoryIds: List = emptyList(), ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt index b749723815..17397d9042 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt @@ -1,19 +1,38 @@ package com.tangem.features.feed.model.news.details import androidx.compose.runtime.Stable +import arrow.core.getOrElse 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.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.GetTokenMarketInfoUseCase +import com.tangem.domain.markets.GetTokenPriceChartUseCase +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent import com.tangem.features.feed.model.news.details.converter.NewsDetailsConverter +import com.tangem.features.feed.model.news.details.converter.RelatedTokenConverter +import com.tangem.features.feed.model.news.details.converter.TokenMarketInfoToParamsConverter +import com.tangem.features.feed.model.news.details.factory.NewsDetailsStateFactory +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.news.RelatedToken +import com.tangem.features.feed.ui.news.details.state.ArticleUM import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM +import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM +import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.indexOfFirstOrNull import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.util.Locale @@ -24,21 +43,31 @@ import javax.inject.Inject @Suppress("LongParameterList") internal class NewsDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase, private val urlOpener: UrlOpener, private val shareManager: ShareManager, + private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, + private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, paramsContainer: ParamsContainer, ) : Model() { private val params = paramsContainer.require() - private val currentLanguage = Locale.getDefault().language + private val relatedTokensCache = mutableMapOf() - private val converter = NewsDetailsConverter( - onSourceClick = { url -> - urlOpener.openUrl(url) - }, - ) + private val newsDetailsConverter = NewsDetailsConverter(onSourceClick = urlOpener::openUrl) + private val tokenMarketInfoToParamsConverter = TokenMarketInfoToParamsConverter() + + private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .flowOn(dispatchers.default) + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) private val _state = MutableStateFlow( NewsDetailsUM( @@ -51,6 +80,14 @@ internal class NewsDetailsModel @Inject constructor( ), ) + private val stateFactory by lazy(LazyThreadSafetyMode.NONE) { + NewsDetailsStateFactory( + currentStateProvider = Provider { _state.value }, + shareManager = shareManager, + onStateUpdate = { newState -> _state.update { newState } }, + ) + } + val state: StateFlow = _state.asStateFlow() init { @@ -62,17 +99,9 @@ internal class NewsDetailsModel @Inject constructor( } private fun onArticleIndexChanged(newIndex: Int) { + stateFactory.updateSelectedArticleIndex(newIndex) val currentArticle = state.value.articles.getOrNull(newIndex) - _state.update { currentState -> - currentState.copy( - selectedArticleIndex = newIndex, - onShareClick = { - currentArticle?.let { - shareManager.shareText(it.newsUrl) - } - }, - ) - } + currentArticle?.let(::loadRelatedTokens) } private fun handlePreselectedArticles() { @@ -87,7 +116,7 @@ internal class NewsDetailsModel @Inject constructor( .map { articlesMap -> params.preselectedArticlesId.mapNotNull { articleId -> articlesMap[articleId]?.let { detailedArticle -> - converter.convert(detailedArticle) + newsDetailsConverter.convert(detailedArticle) } } } @@ -96,20 +125,137 @@ internal class NewsDetailsModel @Inject constructor( } .onEach { articles -> val selectedIndex = articles.indexOfFirstOrNull { it.id == params.articleId } ?: 0 + stateFactory.updateArticles(articles, selectedIndex) val currentArticle = articles.getOrNull(selectedIndex) - _state.update { newsDetailsUM -> - newsDetailsUM.copy( - articles = articles, - selectedArticleIndex = selectedIndex, - onShareClick = { - currentArticle?.let { - shareManager.shareText(it.newsUrl) - } - }, - ) - } + currentArticle?.let(::loadRelatedTokens) } .launchIn(modelScope) } } + + private fun loadRelatedTokens(article: ArticleUM) { + modelScope.launch(dispatchers.default) { + val cachedTokens = getCachedRelatedTokens(article.id) + if (cachedTokens != null) { + stateFactory.updateRelatedTokens(cachedTokens) + return@launch + } + + stateFactory.updateRelatedTokens(RelatedTokensUM.Loading) + + val relatedTokens = article.relatedTokens.take(RELATED_TOKEN_MAX_COUNT) + if (relatedTokens.isEmpty()) { + handleEmptyRelatedTokens(article.id) + return@launch + } + + val appCurrency = currentAppCurrency.value + val tokenDataList = loadTokensData(relatedTokens, appCurrency) + + if (tokenDataList.isEmpty()) { + handleEmptyTokenData(article.id) + return@launch + } + + val resultState = createRelatedTokensState(tokenDataList, appCurrency) + relatedTokensCache[article.id] = resultState + stateFactory.updateRelatedTokens(resultState) + } + } + + private fun getCachedRelatedTokens(articleId: Int): RelatedTokensUM? { + return relatedTokensCache[articleId]?.takeIf { it !is RelatedTokensUM.Loading } + } + + private fun handleEmptyRelatedTokens(articleId: Int) { + val errorState = RelatedTokensUM.LoadingError + relatedTokensCache[articleId] = errorState + stateFactory.updateRelatedTokens(errorState) + } + + private suspend fun CoroutineScope.loadTokensData( + relatedTokens: List, + appCurrency: AppCurrency, + ): List> { + val relatedTokenConverter = RelatedTokenConverter(appCurrency = appCurrency) + + return relatedTokens.map { token -> + async(dispatchers.default) { + loadSingleTokenData(token, appCurrency, relatedTokenConverter) + } + }.awaitAll().filterNotNull() + } + + private suspend fun loadSingleTokenData( + token: RelatedToken, + appCurrency: AppCurrency, + relatedTokenConverter: RelatedTokenConverter, + ): Pair? { + val tokenId = CryptoCurrency.RawID(token.id) + val tokenInfoResult = getTokenMarketInfoUseCase( + appCurrency = appCurrency, + tokenId = tokenId, + tokenSymbol = token.symbol, + ) + + return tokenInfoResult.fold( + ifLeft = { null }, + ifRight = { tokenInfo -> + val chart = loadTokenChart(tokenId, token.symbol, appCurrency) + val tokenItem = relatedTokenConverter.convert(tokenInfo to chart) + val tokenParams = tokenMarketInfoToParamsConverter.convert(tokenInfo) + tokenItem to tokenParams + }, + ) + } + + private suspend fun loadTokenChart( + tokenId: CryptoCurrency.RawID, + tokenSymbol: String, + appCurrency: AppCurrency, + ): com.tangem.domain.markets.TokenChart? { + val chartResult = getTokenPriceChartUseCase( + appCurrency = appCurrency, + interval = PriceChangeInterval.H24, + tokenId = tokenId, + tokenSymbol = tokenSymbol, + preview = true, + ) + return chartResult.getOrElse { null } + } + + private fun handleEmptyTokenData(articleId: Int) { + val errorState = RelatedTokensUM.LoadingError + relatedTokensCache[articleId] = errorState + stateFactory.updateRelatedTokens(errorState) + } + + private fun createRelatedTokensState( + tokenDataList: List>, + appCurrency: AppCurrency, + ): RelatedTokensUM.Content { + val tokenItems = tokenDataList.map { it.first } + val onTokenClick = createTokenClickHandler(tokenDataList, appCurrency) + + return stateFactory.createRelatedTokensContent( + items = tokenItems.toImmutableList(), + onTokenClick = onTokenClick, + ) + } + + private fun createTokenClickHandler( + tokenDataList: List>, + appCurrency: AppCurrency, + ): (MarketsListItemUM) -> Unit { + return { item -> + val tokenData = tokenDataList.find { it.first.id == item.id } + tokenData?.second?.let { tokenParams -> + params.onTokenClick(tokenParams, appCurrency) + } + } + } + + companion object { + internal const val RELATED_TOKEN_MAX_COUNT = 5 + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt index 939c67a294..a877901aa0 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt @@ -37,6 +37,7 @@ internal class NewsDetailsConverter( content = value.content, sources = buildSources(value), newsUrl = value.newsUrl, + relatedTokens = value.relatedTokens.toImmutableList(), ) } @@ -66,6 +67,7 @@ internal class NewsDetailsConverter( publishedAt = mapFormattedDate(originalArticle.publishedAt), url = originalArticle.url, onClick = { onSourceClick(originalArticle.url) }, + imageUrl = originalArticle.imageUrl, ) }.toImmutableList() } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/RelatedTokenConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/RelatedTokenConverter.kt new file mode 100644 index 0000000000..27c51c52e0 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/RelatedTokenConverter.kt @@ -0,0 +1,104 @@ +package com.tangem.features.feed.model.news.details.converter + +import androidx.compose.runtime.Stable +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter +import com.tangem.common.ui.charts.state.sorted +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.format.bigdecimal.* +import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenChart +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal +import java.math.RoundingMode + +@Stable +internal class RelatedTokenConverter(private val appCurrency: AppCurrency) : + Converter, MarketsListItemUM> { + + private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false) + + override fun convert(value: Pair): MarketsListItemUM { + val (tokenInfo, chart) = value + val tokenId = CryptoCurrency.RawID(tokenInfo.id) + + return MarketsListItemUM( + id = tokenId, + name = tokenInfo.name, + currencySymbol = tokenInfo.symbol, + ratingPosition = tokenInfo.metrics?.marketRating?.toString(), + marketCap = getMarketCap(tokenInfo), + iconUrl = getTokenIconUrlFromDefaultHost(tokenId), + price = getCurrentPrice(tokenInfo), + trendPercentText = getTrendPercent(tokenInfo), + trendType = getTrendType(tokenInfo), + chartData = getChartData(chart), + isUnder100kMarketCap = tokenInfo.metrics?.marketCap?.let { it < BigDecimal(MARKET_CAP_100K) } == true, + stakingRate = null, + updateTimestamp = null, + ) + } + + private fun getMarketCap(tokenInfo: TokenMarketInfo): String? { + val value = tokenInfo.metrics?.marketCap?.takeIf { it != BigDecimal.ZERO } ?: return null + + return value.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ).compact( + threeDigitsMethod = true, + ) + } + } + + private fun getCurrentPrice(tokenInfo: TokenMarketInfo): MarketsListItemUM.Price { + val priceText = tokenInfo.quotes.currentPrice.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ).price() + } + + return MarketsListItemUM.Price( + text = priceText, + changeType = null, + ) + } + + private fun getChartData(chart: TokenChart?): MarketChartRawData? { + return chart?.let { ct -> + priceAndTimePointValuesConverter.convert( + MarketChartData.Data( + y = ct.priceY.toImmutableList(), + x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(), + ).sorted(), + ) + } + } + + @Suppress("MagicNumber") + private fun getTrendType(tokenInfo: TokenMarketInfo): PriceChangeType { + val scaled = tokenInfo.quotes.h24ChangePercent?.setScale(4, RoundingMode.HALF_UP) + return when { + scaled == null -> PriceChangeType.NEUTRAL + scaled > BigDecimal.ZERO -> PriceChangeType.UP + scaled < BigDecimal.ZERO -> PriceChangeType.DOWN + else -> PriceChangeType.NEUTRAL + } + } + + private fun getTrendPercent(tokenInfo: TokenMarketInfo): String { + return tokenInfo.quotes.h24ChangePercent?.format { percent() } ?: "0%" + } + + companion object { + private const val MARKET_CAP_100K = "100000" + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/TokenMarketInfoToParamsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/TokenMarketInfoToParamsConverter.kt new file mode 100644 index 0000000000..89e0a020ca --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/TokenMarketInfoToParamsConverter.kt @@ -0,0 +1,28 @@ +package com.tangem.features.feed.model.news.details.converter + +import androidx.compose.runtime.Stable +import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.utils.converter.Converter + +@Stable +internal class TokenMarketInfoToParamsConverter : Converter { + + override fun convert(value: TokenMarketInfo): TokenMarketParams { + val tokenId = CryptoCurrency.RawID(value.id) + return TokenMarketParams( + id = tokenId, + name = value.name, + symbol = value.symbol, + tokenQuotes = TokenMarketParams.Quotes( + currentPrice = value.quotes.currentPrice, + h24Percent = value.quotes.h24ChangePercent, + weekPercent = value.quotes.weekChangePercent, + monthPercent = value.quotes.monthChangePercent, + ), + imageUrl = getTokenIconUrlFromDefaultHost(tokenId), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsStateFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsStateFactory.kt new file mode 100644 index 0000000000..00553ec601 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsStateFactory.kt @@ -0,0 +1,62 @@ +package com.tangem.features.feed.model.news.details.factory + +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.navigation.share.ShareManager +import com.tangem.features.feed.ui.news.details.state.ArticleUM +import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM +import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM +import com.tangem.utils.Provider +import kotlinx.collections.immutable.toImmutableList + +internal class NewsDetailsStateFactory( + private val currentStateProvider: Provider, + private val shareManager: ShareManager, + private val onStateUpdate: (NewsDetailsUM) -> Unit, +) { + + fun updateArticles(articles: List, selectedIndex: Int) { + val currentState = currentStateProvider() + val currentArticle = articles.getOrNull(selectedIndex) + onStateUpdate( + currentState.copy( + articles = articles.toImmutableList(), + selectedArticleIndex = selectedIndex, + onShareClick = { + currentArticle?.let { + shareManager.shareText(it.newsUrl) + } + }, + ), + ) + } + + fun updateSelectedArticleIndex(newIndex: Int) { + val currentState = currentStateProvider() + val currentArticle = currentState.articles.getOrNull(newIndex) + onStateUpdate( + currentState.copy( + selectedArticleIndex = newIndex, + onShareClick = { + currentArticle?.let { + shareManager.shareText(it.newsUrl) + } + }, + ), + ) + } + + fun updateRelatedTokens(relatedTokens: RelatedTokensUM) { + val currentState = currentStateProvider() + onStateUpdate(currentState.copy(relatedTokensUM = relatedTokens)) + } + + fun createRelatedTokensContent( + items: List, + onTokenClick: (MarketsListItemUM) -> Unit, + ): RelatedTokensUM.Content { + return RelatedTokensUM.Content( + items = items.toImmutableList(), + onTokenClick = onTokenClick, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt index adb45733c1..eca1d5c4ee 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt @@ -16,19 +16,23 @@ import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import coil.request.CachePolicy +import coil.request.ImageRequest import com.tangem.common.ui.news.ArticleHeader import com.tangem.core.ui.R -import com.tangem.core.ui.components.SecondaryButtonIconStart -import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.SpacerHMax -import com.tangem.core.ui.components.SpacerW +import com.tangem.core.ui.components.* import com.tangem.core.ui.components.buttons.common.TangemButtonSize import com.tangem.core.ui.components.pager.PagerIndicator import com.tangem.core.ui.extensions.resolveReference @@ -36,10 +40,8 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.feed.ui.news.details.state.ArticleUM -import com.tangem.features.feed.ui.news.details.state.MockArticlesFactory -import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM -import com.tangem.features.feed.ui.news.details.state.SourceUM +import com.tangem.features.feed.ui.news.details.components.RelatedTokensBlock +import com.tangem.features.feed.ui.news.details.state.* @Composable internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modifier) { @@ -49,18 +51,19 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif pageCount = { state.articles.size }, ) + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .collect { page -> + state.onArticleIndexChanged(page) + } + } + LaunchedEffect(state.selectedArticleIndex) { if (pagerState.currentPage != state.selectedArticleIndex) { pagerState.scrollToPage(state.selectedArticleIndex) } } - LaunchedEffect(pagerState.currentPage) { - if (pagerState.currentPage != state.selectedArticleIndex) { - state.onArticleIndexChanged(pagerState.currentPage) - } - } - Column( modifier = modifier .fillMaxSize() @@ -76,6 +79,7 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif article = state.articles[page], modifier = Modifier.fillMaxSize(), onLikeClick = state.onLikeClick, + relatedTokensUM = state.relatedTokensUM, ) } if (state.articles.size > 1) { @@ -93,13 +97,18 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif @Suppress("LongMethod") @Composable -private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onLikeClick: () -> Unit) { +private fun ArticleDetail( + article: ArticleUM, + onLikeClick: () -> Unit, + relatedTokensUM: RelatedTokensUM, + modifier: Modifier = Modifier, +) { val density = LocalDensity.current LazyColumn( modifier = modifier, contentPadding = PaddingValues(bottom = 56.dp + WindowInsets.navigationBars.getBottom(density).dp), ) { - item { + item("content") { ArticleHeader( title = article.title, createdAt = article.createdAt.resolveReference(), @@ -138,7 +147,14 @@ private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onL onClick = onLikeClick, ) - // TODO [REDACTED_TASK_KEY] add related tokens block + RelatedTokensBlock( + relatedTokensUM = relatedTokensUM, + onItemClick = when (relatedTokensUM) { + is RelatedTokensUM.Content -> relatedTokensUM.onTokenClick + else -> null + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) if (article.sources.isNotEmpty()) { SpacerH(24.dp) @@ -161,7 +177,7 @@ private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onL } if (article.sources.isNotEmpty()) { - item { + item("sources") { LazyRow( modifier = Modifier.padding(vertical = 12.dp), state = rememberLazyListState(), @@ -227,47 +243,64 @@ private fun QuickRecap(content: String, modifier: Modifier = Modifier) { private fun SourceItem(source: SourceUM, modifier: Modifier = Modifier) { Column( modifier = modifier - .widthIn(max = 216.dp) - .heightIn(min = 132.dp) - .background( - color = TangemTheme.colors.background.action, - shape = RoundedCornerShape(12.dp), - ) + .sizeIn(maxWidth = 256.dp, minHeight = 132.dp) + .background(color = TangemTheme.colors.background.action, shape = RoundedCornerShape(12.dp)) .clickable(onClick = source.onClick) .padding(12.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 4.dp), - ) { - Icon( - painter = painterResource(id = R.drawable.ic_explore_16), - contentDescription = null, - tint = TangemTheme.colors.icon.informative, - modifier = Modifier.size(16.dp), - ) - SpacerW(4.dp) - Text( - text = source.source.name, - style = TangemTheme.typography.caption1, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = TangemTheme.colors.text.tertiary, - ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 4.dp)) { + Icon( + painter = painterResource(id = R.drawable.ic_explore_16), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier.size(16.dp), + ) + SpacerW(4.dp) + Text( + text = source.source.name, + style = TangemTheme.typography.caption1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = TangemTheme.colors.text.tertiary, + ) + } + if (source.title.isNotEmpty()) { + SpacerH(4.dp) + Text( + text = source.title, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (source.imageUrl != null) { + SubcomposeAsyncImage( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop, + model = ImageRequest.Builder(context = LocalContext.current) + .data(source.imageUrl) + .crossfade(enable = false) + .allowHardware(true) + .memoryCachePolicy(CachePolicy.DISABLED) + .build(), + loading = { + RectangleShimmer( + modifier = Modifier.size(40.dp), + radius = 4.dp, + ) + }, + error = {}, + contentDescription = source.source.name, + ) + } } - if (source.title.isNotEmpty()) { - SpacerH(4.dp) - Text( - text = source.title, - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.primary1, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - SpacerHMax() - Text( text = source.publishedAt.resolveReference(), style = TangemTheme.typography.caption2, @@ -288,6 +321,7 @@ private fun PreviewNewsDetailsContent() { onShareClick = {}, onLikeClick = {}, onBackClick = {}, + onArticleIndexChanged = {}, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt new file mode 100644 index 0000000000..d43ebbb5a9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt @@ -0,0 +1,73 @@ +package com.tangem.features.feed.ui.news.details.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.markets.MarketsListItem +import com.tangem.common.ui.markets.MarketsListItemPlaceholder +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.model.news.details.NewsDetailsModel.Companion.RELATED_TOKEN_MAX_COUNT +import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM + +@Composable +internal fun RelatedTokensBlock( + relatedTokensUM: RelatedTokensUM, + onItemClick: ((MarketsListItemUM) -> Unit)?, + modifier: Modifier = Modifier, +) { + val isVisible = remember(relatedTokensUM) { + when (relatedTokensUM) { + is RelatedTokensUM.Content -> relatedTokensUM.items.isNotEmpty() + RelatedTokensUM.Loading -> true + RelatedTokensUM.LoadingError -> false + } + } + + if (!isVisible) return + + Column(modifier = modifier) { + SpacerH(40.dp) + Text( + text = stringResourceSafe(R.string.news_related_tokens), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + SpacerH(12.dp) + + BlockCard( + colors = TangemBlockCardColors.copy( + containerColor = TangemTheme.colors.background.action, + ), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + when (relatedTokensUM) { + is RelatedTokensUM.Content -> { + relatedTokensUM.items.fastForEach { marketsListItemUM -> + MarketsListItem( + model = marketsListItemUM, + onClick = { onItemClick?.invoke(marketsListItemUM) }, + ) + } + } + RelatedTokensUM.Loading -> { + repeat(RELATED_TOKEN_MAX_COUNT) { + MarketsListItemPlaceholder() + } + } + RelatedTokensUM.LoadingError -> Unit + } + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt index 97aec724e5..4720658f0a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt @@ -31,6 +31,7 @@ internal object MockArticlesFactory { publishedAt = TextReference.Str("1h ago"), url = "https://cointelegraph.com", onClick = {}, + imageUrl = null, ), SourceUM( id = 2, @@ -42,9 +43,11 @@ internal object MockArticlesFactory { publishedAt = TextReference.Str("2h ago"), url = "https://investing.com", onClick = {}, + imageUrl = null, ), ).toPersistentList(), newsUrl = "", + relatedTokens = persistentListOf(), ), ArticleUM( id = 2, @@ -68,9 +71,11 @@ internal object MockArticlesFactory { publishedAt = TextReference.Str("3h ago"), url = "https://bloomberg.com", onClick = {}, + imageUrl = null, ), ).toPersistentList(), newsUrl = "", + relatedTokens = persistentListOf(), ), ArticleUM( id = 3, @@ -94,9 +99,11 @@ internal object MockArticlesFactory { publishedAt = TextReference.Str("5h ago"), url = "https://coindesk.com", onClick = {}, + imageUrl = null, ), ).toPersistentList(), newsUrl = "", + relatedTokens = persistentListOf(), ), ArticleUM( id = 4, @@ -120,9 +127,11 @@ internal object MockArticlesFactory { publishedAt = TextReference.Str("7h ago"), url = "https://theblock.co", onClick = {}, + imageUrl = null, ), ).toPersistentList(), newsUrl = "", + relatedTokens = persistentListOf(), ), ArticleUM( id = 5, @@ -136,6 +145,7 @@ internal object MockArticlesFactory { content = "A newly launched protocol unveiled innovative yield farming mechanism.\n\nAPY rates range from 15% to 30%.", sources = persistentListOf(), newsUrl = "", + relatedTokens = persistentListOf(), ), ArticleUM( id = 6, @@ -150,6 +160,7 @@ internal object MockArticlesFactory { content = "The US Senate Banking Committee advanced landmark crypto regulation bill.\n\nKey provisions include asset definitions.", sources = persistentListOf(), newsUrl = "", + relatedTokens = persistentListOf(), ), ArticleUM( id = 7, @@ -163,6 +174,7 @@ internal object MockArticlesFactory { content = "Major financial institution announced comprehensive crypto custody services.\n\nSupporting Bitcoin and Ethereum initially.", sources = persistentListOf(), newsUrl = "", + relatedTokens = persistentListOf(), ), ArticleUM( id = 8, @@ -176,6 +188,7 @@ internal object MockArticlesFactory { content = "Prominent NFT marketplace reported 300% increase in trading volume.\n\nNew features include lower fees.", sources = persistentListOf(), newsUrl = "", + relatedTokens = persistentListOf(), ), ArticleUM( id = 9, @@ -190,6 +203,7 @@ internal object MockArticlesFactory { content = "Layer 2 solution processed 100,000 transactions per second.\n\nUsing zero-knowledge proof technology.", sources = persistentListOf(), newsUrl = "", + relatedTokens = persistentListOf(), ), ArticleUM( id = 10, @@ -204,6 +218,7 @@ internal object MockArticlesFactory { content = "Stablecoin market cap reached \$180 billion all-time high.\n\nDriven by DeFi activity and institutional adoption.", sources = persistentListOf(), newsUrl = "", + relatedTokens = persistentListOf(), ), ).toPersistentList() } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt index d0049ca02b..0fe906afa9 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt @@ -1,7 +1,10 @@ package com.tangem.features.feed.ui.news.details.state +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.news.RelatedToken import kotlinx.collections.immutable.ImmutableList internal data class NewsDetailsUM( @@ -10,7 +13,8 @@ internal data class NewsDetailsUM( val onShareClick: () -> Unit, val onLikeClick: () -> Unit, val onBackClick: () -> Unit, - val onArticleIndexChanged: (Int) -> Unit = {}, + val onArticleIndexChanged: (Int) -> Unit, + val relatedTokensUM: RelatedTokensUM = RelatedTokensUM.Loading, ) internal data class ArticleUM( @@ -23,6 +27,7 @@ internal data class ArticleUM( val content: String, val sources: ImmutableList, val newsUrl: String, + val relatedTokens: ImmutableList, ) internal data class SourceUM( @@ -32,8 +37,22 @@ internal data class SourceUM( val publishedAt: TextReference, val url: String, val onClick: () -> Unit, + val imageUrl: String?, ) +@Immutable +internal sealed interface RelatedTokensUM { + + data class Content( + val items: ImmutableList, + val onTokenClick: (MarketsListItemUM) -> Unit, + ) : RelatedTokensUM + + data object Loading : RelatedTokensUM + + data object LoadingError : RelatedTokensUM +} + internal data class Source( val id: Int, val name: String, From 348d7500548ef90d73b5cd1a1aa353f386e24cf8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Dec 2025 08:52:15 +0100 Subject: [PATCH 041/172] Updated on 2026-08-14 --- .../news/repository/DefaultNewsRepository.kt | 76 +++++--- .../feed/components/FeedEntryChildFactory.kt | 7 +- .../news/list/DefaultNewsListComponent.kt | 2 +- .../model/converter/BatchListStateManager.kt | 98 +++++++++++ ...ShortArticleToArticleConfigUMConverter.kt} | 12 +- .../feed/model/feed/FeedComponentModel.kt | 2 + .../feed/state/FeedMarketsBatchFlowManager.kt | 133 ++++---------- .../feed/state/TrendingNewsStateFactory.kt | 53 ++---- .../details/MarketsTokenDetailsModel.kt | 8 +- .../MarketsListBatchFlowManager.kt | 115 ++++--------- .../model/news/details/NewsDetailsModel.kt | 21 ++- .../feed/model/news/list/NewsListModel.kt | 116 +++++++++++-- .../statemanager/NewsListBatchFlowManager.kt | 122 +++++++++++++ .../ui/news/details/NewsDetailsContent.kt | 12 +- .../feed/ui/news/list/NewsListContent.kt | 45 +++-- .../list/components/NewsListLazyColumn.kt | 162 ++++++++++++++++++ .../feed/ui/news/list/state/NewsListUM.kt | 14 +- 17 files changed, 698 insertions(+), 300 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt rename features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/{market/details/converter/RelatedNewsConverter.kt => converter/ShortArticleToArticleConfigUMConverter.kt} (80%) rename features/feed/impl/src/main/kotlin/com/tangem/features/feed/{ui => model}/feed/state/FeedMarketsBatchFlowManager.kt (70%) rename features/feed/impl/src/main/kotlin/com/tangem/features/feed/{ui => model}/feed/state/TrendingNewsStateFactory.kt (55%) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt index 96fe7bd6d8..57d4b113f8 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -13,20 +13,13 @@ import com.tangem.domain.news.model.NewsListBatchFlow import com.tangem.domain.news.model.NewsListBatchingContext import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.repository.NewsRepository -import com.tangem.pagination.BatchFetchResult -import com.tangem.pagination.BatchListSource +import com.tangem.pagination.* import com.tangem.pagination.exception.EndOfPaginationException import com.tangem.pagination.fetcher.BatchFetcher -import com.tangem.pagination.toBatchFlow import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runSuspendCatching -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* import timber.log.Timber /** @@ -42,12 +35,14 @@ internal class DefaultNewsRepository( ) : NewsRepository { override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow { - return BatchListSource( + val newsBatchFlow = BatchListSource( fetchDispatcher = dispatchers.io, context = context, generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: INITIAL_BATCH_KEY }, batchFetcher = createBatchFetcher(batchSize), ).toBatchFlow() + + return updateViewedStatusForNewsBatch(newsBatchFlow, context.coroutineScope) } override suspend fun getNews(config: NewsListConfig, limit: Int): List { @@ -115,11 +110,13 @@ internal class DefaultNewsRepository( } override suspend fun getCategories(): List { - return newsApi.getCategories().getOrThrow().items.map { dto -> - ArticleCategory( - id = dto.id, - name = dto.name, - ) + return withContext(dispatchers.io) { + newsApi.getCategories().getOrThrow().items.map { dto -> + ArticleCategory( + id = dto.id, + name = dto.name, + ) + } } } @@ -127,6 +124,38 @@ internal class DefaultNewsRepository( newsViewedStore.updateViewed(articleIds, viewed) } + private fun updateViewedStatusForNewsBatch( + newsBatchFlow: NewsListBatchFlow, + scope: CoroutineScope, + ): NewsListBatchFlow { + return object : NewsListBatchFlow { + override val state: StateFlow>> = + combine( + newsBatchFlow.state, + newsViewedStore.getAll(), + ) { batchListState, viewedFlags -> + val updatedBatches = batchListState.data.map { batch -> + val updatedArticles = batch.data.map { article -> + val isViewed = viewedFlags[article.id] == true + article.copy(viewed = isViewed) + } + Batch(key = batch.key, data = updatedArticles) + } + BatchListState( + data = updatedBatches, + status = batchListState.status, + ) + }.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = BatchListState(emptyList(), newsBatchFlow.state.value.status), + ) + + override val updateResults: SharedFlow>>> = + newsBatchFlow.updateResults + } + } + private suspend fun fetchDetailedArticlesInternal(newsIds: Collection, language: String?) = withContext(dispatchers.io) { if (newsIds.isEmpty()) return@withContext @@ -141,7 +170,7 @@ internal class DefaultNewsRepository( if (idsToFetch.isEmpty()) return@withContext - val fetchedArticles = coroutineScope { + val fetchedArticles = supervisorScope { idsToFetch.map { newsId -> async { newsApi.getNewsDetails(newsId = newsId, language = language) @@ -160,13 +189,12 @@ internal class DefaultNewsRepository( private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?) { return withContext(dispatchers.io) { - val apiResponse = newsApi.getTrendingNews(limit = limit, language = language) - when (val result = apiResponse) { + when (val apiResponse = newsApi.getTrendingNews(limit = limit, language = language)) { is ApiResponse.Error -> { Timber.e( - result.cause.cause, + apiResponse.cause.cause, "Trending news fetch failed cause: ${ - when (val error = result.cause) { + when (val error = apiResponse.cause) { is ApiResponseError.HttpException -> error.code is ApiResponseError.NetworkException -> "NetworkException" is ApiResponseError.TimeoutException -> "TimeoutException" @@ -179,14 +207,14 @@ internal class DefaultNewsRepository( key = TRENDING_NEWS_KEY, value = TrendingNews.Error( NewsError.Unknown( - message = result.cause.message, + message = apiResponse.cause.message, code = null, ), ), ) } is ApiResponse.Success -> { - val freshArticles = result.data.items.map { it.toDomainShortArticle() } + val freshArticles = apiResponse.data.items.map { it.toDomainShortArticle() } val articles = freshArticles.take(limit) trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(articles)) TrendingNews.Data(articles) @@ -271,7 +299,7 @@ internal class DefaultNewsRepository( page = page, limit = limit, language = params.language, - snapshot = snapshotOverride, + snapshot = snapshotOverride?.takeIf { it.isNotEmpty() }, tokenIds = params.tokenIds.takeIf { it.isNotEmpty() }, categoryIds = params.categoryIds.takeIf { it.isNotEmpty() }, ).getOrThrow() diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index cfc874b8b1..5b9a6132d6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -78,7 +78,12 @@ internal class FeedEntryChildFactory @Inject constructor( DefaultNewsListComponent( appComponentContext = appComponentContext, params = DefaultNewsListComponent.Params( - onArticleClicked = { feedEntryClickIntents.onArticleClick(articleId = it) }, + onArticleClicked = { currentArticle, prefetchedArticles -> + feedEntryClickIntents.onArticleClick( + articleId = currentArticle, + preselectedArticlesId = prefetchedArticles, + ) + }, onBackClick = onBackClicked, ), ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt index 2cec3b93c7..a1ca9e03d3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt @@ -51,7 +51,7 @@ internal class DefaultNewsListComponent( @Serializable data class Params( - val onArticleClicked: (Int) -> Unit, + val onArticleClicked: (currentArticle: Int, prefetchedArticles: List) -> Unit, val onBackClick: () -> Unit, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt new file mode 100644 index 0000000000..e55c4c6fcb --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt @@ -0,0 +1,98 @@ +package com.tangem.features.feed.model.converter + +import com.tangem.pagination.Batch +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext + +internal class BatchListStateManager( + private val converter: BatchItemConverter, + private val dispatchers: CoroutineDispatcherProvider, +) { + val state = MutableStateFlow(BatchListState()) + + suspend fun update(newList: List>>, forceUpdate: Boolean) = + withContext(dispatchers.default) { + state.update { currentState -> + val uiBatches = currentState.uiBatches + val previousList = currentState.processedItems + + if (newList.isEmpty()) { + return@update BatchListState(uiBatches = emptyList(), processedItems = emptyList()) + } + + val isInitialLoading = forceUpdate || + previousList.isNullOrEmpty() || + newList.firstOrNull()?.key != previousList.firstOrNull()?.key + + val outItems = if (isInitialLoading) { + newList.map { batch -> + Batch(key = batch.key, data = batch.data.map { converter.convert(it) }) + } + } else { + if (previousList.size != newList.size) { + val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) + val newBatches = newList.filter { keysToAdd.contains(it.key) } + + uiBatches + newBatches.map { batch -> + Batch(key = batch.key, data = batch.data.map { converter.convert(it) }) + } + } else { + uiBatches.mapIndexed { batchIndex, batch -> + val prevBatch = previousList[batchIndex] + val newBatch = newList[batchIndex] + if (prevBatch == newBatch) return@mapIndexed batch + + Batch( + key = batch.key, + data = batch.data.mapIndexed { index, currentUiItem -> + val prevItem = prevBatch.data.getOrNull(index) + val newItem = newBatch.data.getOrNull(index) + + if (prevItem != null && newItem != null) { + converter.update(prevItem, currentUiItem, newItem) + } else if (newItem != null) { + converter.convert(newItem) + } else { + currentUiItem + } + }, + ) + } + } + } + + coroutineContext.ensureActive() + + BatchListState( + uiBatches = outItems, + processedItems = newList, + ) + } + } +} + +internal data class BatchListState( + val uiBatches: List>> = emptyList(), + val processedItems: List>>? = emptyList(), +) + +internal interface BatchItemConverter { + fun convert(item: Domain): UI + + fun update(prevDomain: Domain, currentUI: UI, newDomain: Domain): UI { + return convert(newDomain) + } +} + +internal fun Flow>>>.distinctBatchesContent(): Flow>>> { + return this.distinctUntilChanged { old, new -> + old.size == new.size && + old.map { it.key } == new.map { it.key } && + old.flatMap { it.data } == new.flatMap { it.data } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt similarity index 80% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt index f3967a83fc..245f6b84c7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.model.market.details.converter +package com.tangem.features.feed.model.converter import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM @@ -8,12 +8,16 @@ import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.news.ShortArticle import com.tangem.features.feed.ui.utils.mapFormattedDate +import com.tangem.utils.Provider import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentSet -class RelatedNewsConverter : Converter, ImmutableList> { +internal class ShortArticleToArticleConfigUMConverter( + private val isTrending: Provider, +) : Converter, ImmutableList> { override fun convert(value: List): ImmutableList { return value.map { shortArticle -> @@ -21,7 +25,7 @@ class RelatedNewsConverter : Converter, ImmutableList
, ImmutableList
{ + private fun buildArticleTags(article: ShortArticle): ImmutableSet { val categoryLabels = article.categories.map { category -> LabelUM(text = TextReference.Str(category.name)) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 2fb7016135..203f4dd6df 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -16,6 +16,8 @@ import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.impl.R +import com.tangem.features.feed.model.feed.state.FeedMarketsBatchFlowManager +import com.tangem.features.feed.model.feed.state.TrendingNewsStateFactory import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.feed.state.* import com.tangem.utils.Provider diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt similarity index 70% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt index 6c2014aedf..8a6c1ff9a2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt @@ -1,13 +1,12 @@ -package com.tangem.features.feed.ui.feed.state +package com.tangem.features.feed.model.feed.state import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.features.feed.model.converter.MarketsTokenItemConverter +import com.tangem.features.feed.model.converter.* import com.tangem.features.feed.model.market.list.state.MarketsListUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM -import com.tangem.pagination.Batch import com.tangem.pagination.BatchAction import com.tangem.pagination.BatchFetchResult import com.tangem.pagination.PaginationStatus @@ -18,8 +17,11 @@ import com.tangem.utils.coroutines.saveIn import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly +import kotlinx.coroutines.launch @Suppress("LongParameterList") internal class FeedMarketsBatchFlowManager( @@ -44,7 +46,7 @@ internal class FeedMarketsBatchFlowManager( } }.stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = emptyMap(), ) @@ -60,7 +62,7 @@ internal class FeedMarketsBatchFlowManager( } }.stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = emptyMap(), ) @@ -76,7 +78,7 @@ internal class FeedMarketsBatchFlowManager( } }.stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = emptyMap(), ) @@ -141,11 +143,29 @@ internal class FeedMarketsBatchFlowManager( private val dispatchers: CoroutineDispatcherProvider, ) { private val updateStateJob = JobHolder() - private val resultBatches = MutableStateFlow(ResultBatches()) - private val uiBatches = resultBatches.map { it.uiBatches } + + private val batchConverter = object : BatchItemConverter { + + private val internalConverter: MarketsTokenItemConverter + get() = MarketsTokenItemConverter( + currentTrendInterval = MarketsListUM.TrendInterval.H24, + appCurrency = currentAppCurrency(), + ) + + override fun convert(item: TokenMarket) = internalConverter.convert(item) + + override fun update(prevDomain: TokenMarket, currentUI: MarketsListItemUM, newDomain: TokenMarket) = + internalConverter.update(prevDomain, currentUI, newDomain) + } + + private val stateManager = BatchListStateManager( + converter = batchConverter, + dispatchers = dispatchers, + ) val uiItems: StateFlow> = - uiBatches + stateManager.state + .map { it.uiBatches } .map { batches -> batches.asSequence() .map { it.data } @@ -155,7 +175,7 @@ internal class FeedMarketsBatchFlowManager( .distinctUntilChanged() .stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = persistentListOf(), ) @@ -170,7 +190,7 @@ internal class FeedMarketsBatchFlowManager( .distinctUntilChanged() .stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = false, ) @@ -185,7 +205,7 @@ internal class FeedMarketsBatchFlowManager( .distinctUntilChanged() .stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = false, ) @@ -210,15 +230,11 @@ internal class FeedMarketsBatchFlowManager( init { batchFlow.state .map { it.data } - .distinctUntilChanged { a, b -> - a.size == b.size && - a.map { it.key } == b.map { it.key } && - a.map { it.data }.flatten() == b.map { it.data }.flatten() - } - .onEach { + .distinctBatchesContent() + .onEach { newList -> coroutineScope { launch { - updateState(it) + stateManager.update(newList = newList, forceUpdate = false) }.saveIn(updateStateJob) } } @@ -226,77 +242,9 @@ internal class FeedMarketsBatchFlowManager( .launchIn(modelScope) } - private suspend fun updateState(newList: List>>, forceUpdate: Boolean = false) = - withContext(dispatchers.default) { - resultBatches.update { resultBatches -> - val items = resultBatches.uiBatches - val previousList = resultBatches.processedItems - - val converter = MarketsTokenItemConverter( - currentTrendInterval = MarketsListUM.TrendInterval.H24, - appCurrency = currentAppCurrency(), - ) - - if (newList.isEmpty()) { - return@update ResultBatches(processedItems = emptyList()) - } - - val isInitialLoading = - forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key - - val outItems = if (isInitialLoading) { - newList.map { batch -> - Batch( - key = batch.key, - data = converter.convertList(batch.data), - ) - } - } else { - // As nextBatchSize = 0, we only have one batch, but keep the logic for safety - if (previousList.size != newList.size) { - val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) - val newBatches = newList.filter { keysToAdd.contains(it.key) } - - items + newBatches.map { batch -> - Batch( - key = batch.key, - data = converter.convertList(batch.data), - ) - } - } else { - items.mapIndexed { batchIndex, batch -> - val prevBatch = previousList[batchIndex] - val newBatch = newList[batchIndex] - if (prevBatch == newBatch) return@mapIndexed batch - - Batch( - key = batch.key, - data = batch.data.mapIndexed { index, marketsListItemUM -> - val prevItem = prevBatch.data.getOrNull(index) - val newItem = newBatch.data.getOrNull(index) - if (prevItem != null && newItem != null) { - converter.update(prevItem, marketsListItemUM, newItem) - } else { - newItem?.let { converter.convert(it) } ?: marketsListItemUM - } - }, - ) - } - } - } - - currentCoroutineContext().ensureActive() - - ResultBatches( - uiBatches = outItems, - processedItems = newList, - ) - } - } - fun reload(fiatPriceCurrency: String) { modelScope.launch(dispatchers.default) { - resultBatches.value = ResultBatches() + stateManager.state.value = BatchListState() actionsFlow.emit( BatchAction.Reload( requestParams = TokenMarketListConfig( @@ -366,16 +314,11 @@ internal class FeedMarketsBatchFlowManager( } fun getTokenMarketById(tokenId: CryptoCurrency.RawID): TokenMarket? { - return resultBatches.value.processedItems + return stateManager.state.value.processedItems ?.asSequence() ?.flatMap { it.data } ?.firstOrNull { it.id == tokenId } } - - private data class ResultBatches( - val uiBatches: List>> = emptyList(), - val processedItems: List>>? = null, - ) } private fun TokenMarketListConfig.Order.toSortByTypeUM(): SortByTypeUM { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/TrendingNewsStateFactory.kt similarity index 55% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/TrendingNewsStateFactory.kt index f5c7fe446b..a132fd706f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/TrendingNewsStateFactory.kt @@ -1,19 +1,14 @@ -package com.tangem.features.feed.ui.feed.state +package com.tangem.features.feed.model.feed.state -import com.tangem.common.ui.news.ArticleConfigUM -import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM -import com.tangem.core.ui.components.label.entity.LabelUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.news.ShortArticle import com.tangem.domain.models.news.TrendingNews -import com.tangem.features.feed.ui.utils.mapFormattedDate +import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter +import com.tangem.features.feed.ui.feed.state.FeedListUM +import com.tangem.features.feed.ui.feed.state.NewsUM +import com.tangem.features.feed.ui.feed.state.NewsUMState import com.tangem.utils.Provider -import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList -import kotlinx.collections.immutable.toPersistentSet internal class TrendingNewsStateFactory( private val currentStateProvider: Provider, @@ -31,7 +26,9 @@ internal class TrendingNewsStateFactory( private fun handleDataState(currentState: FeedListUM, articles: List) { val (trendingArticle, commonArticles) = separateTrendingAndCommonArticles(articles) - val commonArticlesUM = commonArticles.map { mapToArticleConfigUM(it, isTrending = false) }.toPersistentList() + val commonArticlesUM = getShortArticleConfigConverter(isTrending = false) + .convert(commonArticles) + .toPersistentList() val updatedNews = when (currentState.news.newsUMState) { NewsUMState.CONTENT -> currentState.news.copy(content = commonArticlesUM) NewsUMState.LOADING, @@ -45,7 +42,10 @@ internal class TrendingNewsStateFactory( onStateUpdate( currentState.copy( - trendingArticle = trendingArticle?.let { mapToArticleConfigUM(it, isTrending = true) }, + trendingArticle = trendingArticle?.let { article -> + getShortArticleConfigConverter(isTrending = true) + .convert(listOf(article)) + }?.firstOrNull(), news = updatedNews, ), ) @@ -77,32 +77,7 @@ internal class TrendingNewsStateFactory( } } - private fun mapToArticleConfigUM(article: ShortArticle, isTrending: Boolean): ArticleConfigUM { - return ArticleConfigUM( - id = article.id, - title = article.title, - score = article.score, - isTrending = isTrending, - tags = buildArticleTags(article), - createdAt = mapFormattedDate(article.createdAt), - isViewed = article.viewed, - ) - } - - private fun buildArticleTags(article: ShortArticle): ImmutableSet { - val categoryLabels = article.categories.map { category -> - LabelUM(text = TextReference.Str(category.name)) - } - val tokenLabels = article.relatedTokens.map { token -> - LabelUM( - text = TextReference.Str(token.symbol), - leadingContent = LabelLeadingContentUM.Token( - iconUrl = getTokenIconUrlFromDefaultHost( - tokenId = CryptoCurrency.RawID(token.id), - ), - ), - ) - } - return (categoryLabels + tokenLabels).toPersistentSet() + private fun getShortArticleConfigConverter(isTrending: Boolean): ShortArticleToArticleConfigUMConverter { + return ShortArticleToArticleConfigUMConverter(isTrending = Provider { isTrending }) } } \ No newline at end of file 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 3f6bce01b5..f81479096a 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 @@ -40,11 +40,11 @@ import com.tangem.features.feed.impl.R import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.feed.model.market.details.converter.DescriptionConverter import com.tangem.features.feed.model.market.details.converter.ExchangeItemStateConverter -import com.tangem.features.feed.model.market.details.converter.RelatedNewsConverter import com.tangem.features.feed.model.market.details.converter.TokenMarketInfoConverter import com.tangem.features.feed.model.market.details.formatter.* import com.tangem.features.feed.model.market.details.state.QuotesStateUpdater import com.tangem.features.feed.model.market.details.state.TokenNetworksState +import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM import com.tangem.lib.crypto.BlockchainUtils @@ -143,8 +143,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( // ================== ) - private val relatedNewsConverter by lazy { - RelatedNewsConverter() + private val shortArticleToArticleConfigUMConverter by lazy { + ShortArticleToArticleConfigUMConverter(isTrending = Provider { false }) } private val descriptionConverter = DescriptionConverter( @@ -311,7 +311,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( ), ).onRight { articles -> state.update { marketsTokenDetailsUM -> - val relatedNews = relatedNewsConverter.convert(articles) + val relatedNews = shortArticleToArticleConfigUMConverter.convert(articles) marketsTokenDetailsUM.copy( relatedNews = marketsTokenDetailsUM.relatedNews.copy( articles = relatedNews, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt index c4632a41af..5e87294672 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt @@ -4,12 +4,15 @@ import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.feed.model.converter.BatchItemConverter +import com.tangem.features.feed.model.converter.BatchListStateManager import com.tangem.features.feed.model.converter.MarketsTokenItemConverter +import com.tangem.features.feed.model.converter.distinctBatchesContent +import com.tangem.features.feed.model.market.list.state.MarketsListUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.model.market.list.utils.logAction import com.tangem.features.feed.model.market.list.utils.logStatus import com.tangem.features.feed.model.market.list.utils.logUpdateResults -import com.tangem.features.feed.model.market.list.state.MarketsListUM -import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.pagination.Batch import com.tangem.pagination.BatchAction import com.tangem.pagination.BatchFetchResult @@ -48,8 +51,27 @@ internal class MarketsListBatchFlowManager( batchFlowType = batchFlowType, ) + private val batchConverter = object : BatchItemConverter { + + private val internalConverter: MarketsTokenItemConverter + get() = MarketsTokenItemConverter( + currentTrendInterval = currentTrendInterval(), // Теперь тут всегда актуальное значение + appCurrency = currentAppCurrency(), + ) + + override fun convert(item: TokenMarket) = internalConverter.convert(item) + + override fun update(prevDomain: TokenMarket, currentUI: MarketsListItemUM, newDomain: TokenMarket) = + internalConverter.update(prevDomain, currentUI, newDomain) + } + + private val stateManager = BatchListStateManager( + converter = batchConverter, + dispatchers = dispatchers, + ) + private val resultBatches = MutableStateFlow(ResultBatches()) - private val uiBatches = resultBatches.map { it.uiBatches } + private val uiBatches = stateManager.state.map { it.uiBatches } val uiItems: StateFlow> get() = uiBatches @@ -128,15 +150,11 @@ internal class MarketsListBatchFlowManager( init { batchFlow.state .map { it.data } - .distinctUntilChanged { a, b -> - a.size == b.size && - a.map { it.key } == b.map { it.key } && - a.map { it.data }.flatten() == b.map { it.data }.flatten() - } - .onEach { + .distinctBatchesContent() + .onEach { newList -> coroutineScope { launch { - updateState(it) + stateManager.update(newList = newList, forceUpdate = false) }.saveIn(updateStateJob) } } @@ -159,70 +177,12 @@ internal class MarketsListBatchFlowManager( } } - private suspend fun updateState(newList: List>>, forceUpdate: Boolean = false) = - withContext(dispatchers.default) { - resultBatches.update { resultBatches -> - val items = resultBatches.uiBatches - val previousList = resultBatches.processedItems - - val converter = MarketsTokenItemConverter(currentTrendInterval(), appCurrency = currentAppCurrency()) - - if (newList.isEmpty()) { - return@update ResultBatches(processedItems = emptyList()) - } - - val isInitialLoading = - forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key - - val outItems = if (isInitialLoading) { - newList.map { batch -> - Batch( - key = batch.key, - data = converter.convertList(batch.data), - ) - } - } else { - if (previousList.size != newList.size) { - val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) - val newBatches = newList.filter { keysToAdd.contains(it.key) } - - items + newBatches.map { batch -> - Batch( - key = batch.key, - data = converter.convertList(batch.data), - ) - } - } else { - items.mapIndexed { batchIndex, batch -> - val prevBatch = previousList[batchIndex] - val newBatch = newList[batchIndex] - if (previousList == newBatch) return@mapIndexed batch - - Batch( - key = batch.key, - data = batch.data.mapIndexed { index, marketsListItemUM -> - val prevItem = prevBatch.data[index] - val newItem = newBatch.data[index] - - converter.update( - prevItem, - marketsListItemUM, - newItem, - ) - }, - ) - } - } - } - - currentCoroutineContext().ensureActive() - - ResultBatches( - uiBatches = outItems, - processedItems = newList, - ) - } - } + fun updateUIWithSameState() { + modelScope.launch(dispatchers.default) { + val current = batchFlow.state.value.data + stateManager.update(current, forceUpdate = true) + }.saveIn(updateStateJob) + } fun reload(searchText: String? = null) { modelScope.launch { @@ -250,13 +210,6 @@ internal class MarketsListBatchFlowManager( } } - fun updateUIWithSameState() { - modelScope.launch(dispatchers.default) { - val current = batchFlow.state.value.data - updateState(current, forceUpdate = true) - }.saveIn(updateStateJob) - } - fun loadCharts(batchKeys: Set, interval: MarketsListUM.TrendInterval) { if (batchKeys.isEmpty()) return diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt index 17397d9042..cde555e61c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.feed.model.news.details import androidx.compose.runtime.Stable import arrow.core.getOrElse +import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -12,16 +13,16 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.GetTokenMarketInfoUseCase import com.tangem.domain.markets.GetTokenPriceChartUseCase import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.news.RelatedToken +import com.tangem.domain.news.usecase.MarkArticleAsViewedUseCase import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent import com.tangem.features.feed.model.news.details.converter.NewsDetailsConverter import com.tangem.features.feed.model.news.details.converter.RelatedTokenConverter import com.tangem.features.feed.model.news.details.converter.TokenMarketInfoToParamsConverter import com.tangem.features.feed.model.news.details.factory.NewsDetailsStateFactory -import com.tangem.common.ui.markets.models.MarketsListItemUM -import com.tangem.domain.markets.TokenMarketParams -import com.tangem.domain.models.news.RelatedToken import com.tangem.features.feed.ui.news.details.state.ArticleUM import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM @@ -49,6 +50,7 @@ internal class NewsDetailsModel @Inject constructor( private val shareManager: ShareManager, private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, + private val markArticleAsViewedUseCase: MarkArticleAsViewedUseCase, paramsContainer: ParamsContainer, ) : Model() { @@ -101,7 +103,10 @@ internal class NewsDetailsModel @Inject constructor( private fun onArticleIndexChanged(newIndex: Int) { stateFactory.updateSelectedArticleIndex(newIndex) val currentArticle = state.value.articles.getOrNull(newIndex) - currentArticle?.let(::loadRelatedTokens) + currentArticle?.let { article -> + markArticleAsViewed(article.id) + loadRelatedTokens(article) + } } private fun handlePreselectedArticles() { @@ -126,8 +131,6 @@ internal class NewsDetailsModel @Inject constructor( .onEach { articles -> val selectedIndex = articles.indexOfFirstOrNull { it.id == params.articleId } ?: 0 stateFactory.updateArticles(articles, selectedIndex) - val currentArticle = articles.getOrNull(selectedIndex) - currentArticle?.let(::loadRelatedTokens) } .launchIn(modelScope) } @@ -255,6 +258,12 @@ internal class NewsDetailsModel @Inject constructor( } } + private fun markArticleAsViewed(articleId: Int) { + modelScope.launch(dispatchers.default) { + markArticleAsViewedUseCase.markAsViewed(articleId) + } + } + companion object { internal const val RELATED_TOKEN_MAX_COUNT = 5 } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt index ccaf7b8c33..5bdf4d1705 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt @@ -1,19 +1,28 @@ package com.tangem.features.feed.model.news.list +import com.tangem.common.ui.R 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.components.chip.entity.ChipUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase +import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase import com.tangem.features.feed.components.news.list.DefaultNewsListComponent +import com.tangem.features.feed.model.news.list.statemanager.NewsListBatchFlowManager +import com.tangem.features.feed.ui.news.list.state.NewsListState import com.tangem.features.feed.ui.news.list.state.NewsListUM +import com.tangem.pagination.PaginationStatus +import com.tangem.utils.Provider +import com.tangem.utils.SupportedLanguages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -22,24 +31,57 @@ import javax.inject.Inject internal class NewsListModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val getNewsCategoriesUseCase: GetNewsCategoriesUseCase, + private val getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, paramsContainer: ParamsContainer, ) : Model() { private val params = paramsContainer.require() + private val selectedCategoryId = MutableStateFlow(null) + private val currentLanguage = SupportedLanguages.getCurrentSupportedLanguageCode() + + private val batchFlowManager by lazy { + NewsListBatchFlowManager( + getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, + currentLanguage = Provider { currentLanguage }, + currentCategoryIds = Provider { + selectedCategoryId.value?.takeIf { it > 0 }?.let { listOf(it) }.orEmpty() + }, + modelScope = modelScope, + dispatchers = dispatchers, + ) + } private val _state = MutableStateFlow( NewsListUM( - selectedCategoryId = 0, + selectedCategoryId = DEFAULT_ALL_NEWS_CATEGORIES_ID, filters = persistentListOf(), - articles = persistentListOf(), - onArticleClick = params.onArticleClicked, + newsListState = NewsListState.Loading, + listOfArticles = persistentListOf(), + onArticleClick = { articleId -> + params.onArticleClicked( + /* currentArticle */ articleId, + /* prefetchedArticles */ getCurrentFetchedArticlesIds(), + ) + }, onBackClick = params.onBackClick, ), ) val state = _state.asStateFlow() init { + loadCategories() + observeNewsList() + batchFlowManager.reload() + } + + private fun loadCategories() { modelScope.launch(dispatchers.default) { + val allCategoriesChip = ChipUM( + id = DEFAULT_ALL_NEWS_CATEGORIES_ID, + text = TextReference.Res(R.string.news_all_news), + isSelected = true, + onClick = { onCategoryClick(DEFAULT_ALL_NEWS_CATEGORIES_ID) }, + ) val filterChips = getNewsCategoriesUseCase .invoke() .fold( @@ -47,7 +89,7 @@ internal class NewsListModel @Inject constructor( persistentListOf() }, ifRight = { categories -> - categories.map { articleCategory -> + (listOf(allCategoriesChip) + categories.map { articleCategory -> ChipUM( id = articleCategory.id, text = TextReference.Str(articleCategory.name), @@ -56,7 +98,7 @@ internal class NewsListModel @Inject constructor( onCategoryClick(articleCategory.id) }, ) - }.toImmutableList() + }).toPersistentList() }, ) _state.update { currentState -> @@ -65,18 +107,68 @@ internal class NewsListModel @Inject constructor( } } - private fun onCategoryClick(categoryId: Int) { - _state.update { currentState -> - currentState.copy( - selectedCategoryId = categoryId, - filters = updateFilterChips(categoryId), - ) + private fun observeNewsList() { + modelScope.launch(dispatchers.default) { + combine( + batchFlowManager.uiItems, + batchFlowManager.isInInitialLoadingErrorState, + batchFlowManager.paginationStatus, + ) { articles, isError, paginationStatus -> + when { + isError -> NewsListState.LoadingError( + onRetryClicked = { + loadCategories() + batchFlowManager.reload() + }, + ) to persistentListOf() + paginationStatus is PaginationStatus.InitialLoading && articles.isEmpty() -> { + NewsListState.Loading to persistentListOf() + } + articles.isEmpty() -> { + NewsListState.Loading to persistentListOf() + } + else -> { + NewsListState.Content(loadMore = { batchFlowManager.loadMore() }) to articles + } + } + }.collect { (listState, articles) -> + _state.update { currentState -> + currentState.copy( + listOfArticles = articles, + newsListState = listState, + ) + } + } } } - private fun updateFilterChips(categoryId: Int): ImmutableList { + private fun onCategoryClick(categoryId: Int) { + val newCategoryId = if (state.value.selectedCategoryId == categoryId) { + DEFAULT_ALL_NEWS_CATEGORIES_ID + } else { + categoryId + } + selectedCategoryId.value = newCategoryId + _state.update { currentState -> + currentState.copy( + selectedCategoryId = newCategoryId, + filters = updateFilterChips(newCategoryId), + ) + } + batchFlowManager.reload() + } + + private fun updateFilterChips(categoryId: Int?): ImmutableList { return state.value.filters.map { chip -> chip.copy(isSelected = chip.id == categoryId) }.toImmutableList() } + + private fun getCurrentFetchedArticlesIds(): List { + return state.value.listOfArticles.map { it.id } + } + + companion object { + private const val DEFAULT_ALL_NEWS_CATEGORIES_ID = -1 + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt new file mode 100644 index 0000000000..3029f0a192 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt @@ -0,0 +1,122 @@ +package com.tangem.features.feed.model.news.list.statemanager + +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.news.model.NewsListBatchingContext +import com.tangem.domain.news.model.NewsListConfig +import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase +import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter +import com.tangem.features.feed.model.converter.distinctBatchesContent +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchAction +import com.tangem.pagination.PaginationStatus +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +internal class NewsListBatchFlowManager( + getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, + private val currentLanguage: Provider, + private val currentCategoryIds: Provider>, + private val modelScope: CoroutineScope, + private val dispatchers: CoroutineDispatcherProvider, +) { + private val actionsFlow = MutableSharedFlow>() + private val converter by lazy { + ShortArticleToArticleConfigUMConverter(isTrending = Provider { false }) + } + + private val batchFlow = getNewsListBatchFlowUseCase( + context = NewsListBatchingContext( + actionsFlow = actionsFlow, + coroutineScope = modelScope, + ), + batchSize = DEFAULT_BATCH_SIZE, + ) + + private val resultBatches = MutableStateFlow>>>(emptyList()) + + val uiItems: StateFlow> + get() = batchFlow.state + .map { batchListState -> + batchListState.data + .flatMap { batch -> batch.data } + .let { articles -> converter.convert(articles) } + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = persistentListOf(), + ) + + val isInInitialLoadingErrorState = batchFlow.state + .map { it.status is PaginationStatus.InitialLoadingError } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + + val paginationStatus: StateFlow>> + get() = batchFlow.state + .map { it.status } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = PaginationStatus.InitialLoading, + ) + + init { + batchFlow.state + .map { it.data } + .distinctBatchesContent() + .onEach { batches -> + resultBatches.value = batches.map { batch -> + Batch( + key = batch.key, + data = converter.convert(batch.data), + ) + } + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + fun reload() { + modelScope.launch(dispatchers.default) { + resultBatches.value = emptyList() + actionsFlow.emit( + BatchAction.Reload( + requestParams = createNewsListConfig(), + ), + ) + } + } + + fun loadMore() { + modelScope.launch(dispatchers.default) { + actionsFlow.emit(BatchAction.LoadMore()) + } + } + + private fun createNewsListConfig(): NewsListConfig { + return NewsListConfig( + language = currentLanguage(), + snapshot = null, + tokenIds = emptyList(), + categoryIds = currentCategoryIds(), + ) + } + + private companion object { + private const val DEFAULT_BATCH_SIZE = 20 + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt index eca1d5c4ee..b60edbbb14 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt @@ -51,11 +51,13 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif pageCount = { state.articles.size }, ) - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.currentPage } - .collect { page -> - state.onArticleIndexChanged(page) - } + if (state.articles.isNotEmpty()) { + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .collect { page -> + state.onArticleIndexChanged(page) + } + } } LaunchedEffect(state.selectedArticleIndex) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt index d9b5b00948..5dd1413007 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt @@ -1,25 +1,27 @@ package com.tangem.features.feed.ui.news.list import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.tangem.common.ui.news.ArticleCard import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.chip.Chip import com.tangem.core.ui.components.chip.entity.ChipUM import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.res.LocalMainBottomSheetColor -import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.ui.news.list.components.NewsListLazyColumn +import com.tangem.features.feed.ui.news.list.state.NewsListState import com.tangem.features.feed.ui.news.list.state.NewsListUM import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableSet @@ -27,6 +29,8 @@ import kotlinx.collections.immutable.toImmutableSet @Composable internal fun NewsListContent(state: NewsListUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value + val lazyListState = rememberLazyListState() + Column( modifier = modifier .fillMaxSize() @@ -43,25 +47,15 @@ internal fun NewsListContent(state: NewsListUM, modifier: Modifier = Modifier) { Chip(state = filter) } } - LazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(16.dp), - ) { - items( - items = state.articles, - key = ArticleConfigUM::id, - ) { article -> - ArticleCard( - articleConfigUM = article, - onArticleClick = { state.onArticleClick(article.id) }, - modifier = Modifier - .fillMaxWidth() - .height(164.dp), - colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), - ) - SpacerH(12.dp) - } - } + + SpacerH(16.dp) + + NewsListLazyColumn( + newsListState = state.newsListState, + listOfArticles = state.listOfArticles, + lazyListState = lazyListState, + onArticleClick = state.onArticleClick, + ) } } @@ -137,7 +131,8 @@ private fun NewsListContentPreview() { state = NewsListUM( selectedCategoryId = 0, filters = filters, - articles = articles, + listOfArticles = articles, + newsListState = NewsListState.Content(loadMore = {}), onArticleClick = {}, onBackClick = {}, ), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt new file mode 100644 index 0000000000..52d66787a6 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt @@ -0,0 +1,162 @@ +package com.tangem.features.feed.ui.news.list.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.news.ArticleCard +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.common.ui.news.DefaultLoadingArticle +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.components.list.InfiniteListHandler +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.ui.news.list.state.NewsListState +import kotlinx.collections.immutable.ImmutableList + +private const val LOAD_NEXT_PAGE_ON_END_INDEX = 40 + +@Composable +internal fun NewsListLazyColumn( + listOfArticles: ImmutableList, + newsListState: NewsListState, + lazyListState: LazyListState, + onArticleClick: (Int) -> Unit, +) { + val screenState by remember(listOfArticles, newsListState) { + derivedStateOf { + val isListEmpty = listOfArticles.isEmpty() + when { + isListEmpty && newsListState is NewsListState.Loading -> NewsListScreenState.InitialLoading + isListEmpty && newsListState is NewsListState.LoadingError -> NewsListScreenState.InitialError + else -> NewsListScreenState.Content + } + } + } + + AnimatedContent(targetState = screenState, label = "NewsListTransition") { state -> + when (state) { + NewsListScreenState.Content -> { + Content( + listOfArticles = listOfArticles, + newsListState = newsListState, + lazyListState = lazyListState, + onArticleClick = onArticleClick, + ) + } + NewsListScreenState.InitialLoading -> { + LazyColumn( + state = rememberLazyListState(), + contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp), + userScrollEnabled = false, + ) { + items( + count = 10, + key = { "initial_loading_$it" }, + ) { + DefaultLoadingArticle( + modifier = Modifier + .fillMaxWidth() + .height(164.dp), + ) + SpacerH(12.dp) + } + } + } + NewsListScreenState.InitialError -> { + val errorState = newsListState as? NewsListState.LoadingError + LoadingErrorItem( + modifier = Modifier.fillMaxSize(), + onTryAgain = errorState?.onRetryClicked ?: {}, + ) + } + } + } +} + +@Composable +private fun Content( + listOfArticles: ImmutableList, + newsListState: NewsListState, + lazyListState: LazyListState, + onArticleClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + state = lazyListState, + contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp), + userScrollEnabled = true, + ) { + items( + items = listOfArticles, + key = ArticleConfigUM::id, + ) { article -> + ArticleCard( + modifier = Modifier + .fillMaxWidth() + .height(164.dp), + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), + articleConfigUM = article, + onArticleClick = { + onArticleClick(article.id) + }, + ) + SpacerH(12.dp) + } + + if (newsListState is NewsListState.Loading) { + items( + count = 10, + key = { "loading_footer_$it" }, + ) { + DefaultLoadingArticle( + modifier = Modifier + .fillMaxWidth() + .height(164.dp), + ) + SpacerH(12.dp) + } + } + } + + if (newsListState is NewsListState.Content) { + InfiniteListHandler( + listState = lazyListState, + buffer = LOAD_NEXT_PAGE_ON_END_INDEX, + triggerLoadMoreCheckOnItemsCountChange = true, + onLoadMore = remember(newsListState) { + { + newsListState.loadMore() + true + } + }, + ) + } +} + +private enum class NewsListScreenState { + InitialLoading, + InitialError, + Content, +} + +@Composable +private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier.padding(vertical = 35.dp, horizontal = 10.dp), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData(onRetryClick = onTryAgain) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt index c1f6174111..12fe9861f3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt @@ -7,9 +7,17 @@ import kotlinx.collections.immutable.ImmutableList @Immutable data class NewsListUM( - val selectedCategoryId: Int?, + val selectedCategoryId: Int, val filters: ImmutableList, - val articles: ImmutableList, + val listOfArticles: ImmutableList, + val newsListState: NewsListState, val onArticleClick: (Int) -> Unit, val onBackClick: () -> Unit, -) \ No newline at end of file +) + +@Immutable +sealed class NewsListState { + data class Content(val loadMore: () -> Unit) : NewsListState() + data object Loading : NewsListState() + data class LoadingError(val onRetryClicked: () -> Unit) : NewsListState() +} \ No newline at end of file From 93ee92d3031f72b780b0f21169e65200f76543b1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Dec 2025 08:44:53 +0000 Subject: [PATCH 042/172] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 21f77fa638..36965eb1e1 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.32-1335" +tangemBlockchainSdk = "develop-1330" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.32-574" +tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From faaa2aad8fab25b4f3556b4ef943b39f415fda2f Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Dec 2025 19:27:21 +0100 Subject: [PATCH 043/172] Updated on 2026-08-14 --- .../news/repository/DefaultNewsRepository.kt | 102 ++++---- .../domain/news/repository/NewsRepository.kt | 11 +- .../domain/news/usecase/GetNewsUseCase.kt | 3 +- .../components/DefaultFeedEntryComponent.kt | 14 +- .../feed/components/FeedEntryChildFactory.kt | 3 +- .../details/DefaultNewsDetailsComponent.kt | 6 +- .../news/list/DefaultNewsListComponent.kt | 7 +- .../model/converter/BatchListStateManager.kt | 13 +- .../feed/model/feed/FeedComponentModel.kt | 1 + .../feed/model/feed/FeedModelClickIntents.kt | 14 +- .../feed/state/FeedMarketsBatchFlowManager.kt | 11 +- .../details/MarketsTokenDetailsModel.kt | 30 ++- .../model/news/details/NewsDetailsModel.kt | 240 +++++++----------- .../details/NewsDetailsPaginationManager.kt | 112 ++++++++ .../factory/NewsDetailsIndexManager.kt | 30 +++ .../factory/NewsDetailsStateFactory.kt | 20 +- .../details/loader/NewsRelatedTokensLoader.kt | 150 +++++++++++ .../feed/model/news/list/NewsListModel.kt | 78 +++--- .../list/calculator/NewsListStateManager.kt | 41 +++ .../news/list/loader/NewsCategoriesLoader.kt | 50 ++++ .../statemanager/NewsListBatchFlowManager.kt | 37 ++- .../tangem/features/feed/ui/feed/FeedList.kt | 20 +- .../features/feed/ui/feed/FeedListLoading.kt | 2 +- .../components/TokenMarketDetailsBody.kt | 11 +- .../list/components/MarketsListLazyColumn.kt | 2 + .../ui/news/details/NewsDetailsContent.kt | 38 ++- .../components/NewsDetailsPlaceholder.kt | 57 +++++ .../ui/news/details/state/NewsDetailsUM.kt | 8 + 28 files changed, 795 insertions(+), 316 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsIndexManager.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/loader/NewsRelatedTokensLoader.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/calculator/NewsListStateManager.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/loader/NewsCategoriesLoader.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt index 57d4b113f8..f503535500 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -45,8 +45,8 @@ internal class DefaultNewsRepository( return updateViewedStatusForNewsBatch(newsBatchFlow, context.coroutineScope) } - override suspend fun getNews(config: NewsListConfig, limit: Int): List { - return withContext(dispatchers.io) { + override fun getNews(config: NewsListConfig, limit: Int): Flow> { + return flow { val response = newsApi.getNews( page = FIRST_PAGE, limit = limit, @@ -56,25 +56,16 @@ internal class DefaultNewsRepository( categoryIds = config.categoryIds.takeIf { it.isNotEmpty() }, ).getOrThrow() - val articles = response.items.map { it.toDomainShortArticle() } - val viewedFlags = newsViewedStore.getSync() - - articles.map { article -> - val isViewed = viewedFlags[article.id] == true - article.copy(viewed = isViewed) + val shortArticles = response.items.map { it.toDomainShortArticle() } + emit(shortArticles) + } + .flowOn(dispatchers.io) + .combine(newsViewedStore.getAll()) { articlesToUpdate, viewedFlags -> + articlesToUpdate.map { article -> + val isViewed = viewedFlags[article.id] == true + article.copy(viewed = isViewed) + }.sortedBy { it.viewed } } - } - } - - override suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle { - val cached = newsDetailsStore.getSyncOrNull(newsId) - if (cached != null) return cached - - fetchDetailedArticlesInternal(newsIds = listOf(newsId), language = language) - - return requireNotNull(newsDetailsStore.getSyncOrNull(newsId)) { - "Unable to load detailed article with id=$newsId" - } } override fun observeDetailedArticles(): Flow> { @@ -98,10 +89,11 @@ internal class DefaultNewsRepository( ) { trendingNews, viewedFlags -> when (trendingNews) { is TrendingNews.Data -> { - val articlesWithViewedFlags = trendingNews.articles.map { article -> - val isViewed = viewedFlags[article.id] == true - article.copy(viewed = isViewed) - } + val articlesWithViewedFlags = trendingNews.articles + .map { article -> + article.copy(viewed = viewedFlags[article.id] == true) + } + .sortedBy { it.viewed } TrendingNews.Data(articlesWithViewedFlags) } is TrendingNews.Error -> trendingNews @@ -128,32 +120,11 @@ internal class DefaultNewsRepository( newsBatchFlow: NewsListBatchFlow, scope: CoroutineScope, ): NewsListBatchFlow { - return object : NewsListBatchFlow { - override val state: StateFlow>> = - combine( - newsBatchFlow.state, - newsViewedStore.getAll(), - ) { batchListState, viewedFlags -> - val updatedBatches = batchListState.data.map { batch -> - val updatedArticles = batch.data.map { article -> - val isViewed = viewedFlags[article.id] == true - article.copy(viewed = isViewed) - } - Batch(key = batch.key, data = updatedArticles) - } - BatchListState( - data = updatedBatches, - status = batchListState.status, - ) - }.stateIn( - scope = scope, - started = SharingStarted.Eagerly, - initialValue = BatchListState(emptyList(), newsBatchFlow.state.value.status), - ) - - override val updateResults: SharedFlow>>> = - newsBatchFlow.updateResults - } + return NewsBatchFlowWithViewedStatus( + upstream = newsBatchFlow, + newsViewedStore = newsViewedStore, + scope = scope, + ) } private suspend fun fetchDetailedArticlesInternal(newsIds: Collection, language: String?) = @@ -340,6 +311,37 @@ internal class DefaultNewsRepository( val params: NewsListConfig, ) + private class NewsBatchFlowWithViewedStatus( + private val upstream: NewsListBatchFlow, + newsViewedStore: NewsViewedStore, + scope: CoroutineScope, + ) : NewsListBatchFlow { + + override val state: StateFlow>> = combine( + upstream.state, + newsViewedStore.getAll(), + ) { batchListState, viewedFlags -> + val updatedBatches = batchListState.data.map { batch -> + val updatedArticles = batch.data.map { article -> + val isViewed = viewedFlags[article.id] == true + article.copy(viewed = isViewed) + } + Batch(key = batch.key, data = updatedArticles) + } + BatchListState( + data = updatedBatches, + status = batchListState.status, + ) + }.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = BatchListState(emptyList(), upstream.state.value.status), + ) + + override val updateResults: SharedFlow>>> + get() = upstream.updateResults + } + private companion object { private const val INITIAL_BATCH_KEY = 0 private const val FIRST_PAGE = 1 diff --git a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt index 2d2e5043a0..4522fe3a7c 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt @@ -23,18 +23,11 @@ interface NewsRepository { fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow /** - * Returns list of short article by config. + * Returns flow of list of short article by config. * * @param config config for getting news list */ - suspend fun getNews(config: NewsListConfig, limit: Int): List - - /** - * Returns detailed article by id with locale configuration. - * @param newsId news identification - * @param language current locale - */ - suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle + fun getNews(config: NewsListConfig, limit: Int): Flow> /** * Observes cached detailed articles. diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt index 74d8533a59..adaa426bfd 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt @@ -4,10 +4,11 @@ import arrow.core.Either import com.tangem.domain.models.news.ShortArticle import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.repository.NewsRepository +import kotlinx.coroutines.flow.Flow class GetNewsUseCase(private val repository: NewsRepository) { - suspend fun getNews(limit: Int, newsListConfig: NewsListConfig): Either> = + fun getNews(limit: Int, newsListConfig: NewsListConfig): Either>> = Either.catch { repository.getNews( config = newsListConfig, 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 172c7d6108..a66d53fe10 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 @@ -19,6 +19,7 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.news.model.NewsListConfig import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent @@ -79,13 +80,18 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( ) } - override fun onArticleClick(articleId: Int, preselectedArticlesId: List) { + override fun onArticleClick( + articleId: Int, + preselectedArticlesId: List, + paginationConfig: NewsListConfig?, + ) { innerRouter.push( FeedEntryChildFactory.Child.NewsDetails( params = DefaultNewsDetailsComponent.Params( articleId = articleId, onBackClicked = { onChildBack() }, preselectedArticlesId = preselectedArticlesId, + paginationConfig = paginationConfig, onTokenClick = { token, currency -> onMarketItemClick(token, currency) }, ), ), @@ -179,7 +185,11 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( }, onBackClicked = { router.pop() }, onArticleClick = { articleId, preselectedArticlesId -> - clickIntents.onArticleClick(articleId, preselectedArticlesId) + clickIntents.onArticleClick( + articleId = articleId, + preselectedArticlesId = preselectedArticlesId, + paginationConfig = null, + ) }, ), ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index 5b9a6132d6..8bf90f863d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -78,10 +78,11 @@ internal class FeedEntryChildFactory @Inject constructor( DefaultNewsListComponent( appComponentContext = appComponentContext, params = DefaultNewsListComponent.Params( - onArticleClicked = { currentArticle, prefetchedArticles -> + onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig -> feedEntryClickIntents.onArticleClick( articleId = currentArticle, preselectedArticlesId = prefetchedArticles, + paginationConfig = paginationConfig, ) }, onBackClick = onBackClicked, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt index fe6ca72911..c663891a5e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt @@ -15,8 +15,10 @@ import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.news.model.NewsListConfig import com.tangem.features.feed.model.news.details.NewsDetailsModel import com.tangem.features.feed.ui.news.details.NewsDetailsContent +import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM import kotlinx.serialization.Serializable internal class DefaultNewsDetailsComponent( @@ -41,7 +43,8 @@ internal class DefaultNewsDetailsComponent( endButton = TopAppBarButtonUM.Icon( iconRes = R.drawable.ic_share_24, onClicked = state.onShareClick, - isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED, + isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED && + state.articlesStateUM is ArticlesStateUM.Content, ), ) } @@ -61,5 +64,6 @@ internal class DefaultNewsDetailsComponent( val onBackClicked: () -> Unit, val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), val preselectedArticlesId: List = emptyList(), + val paginationConfig: NewsListConfig? = null, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt index a1ca9e03d3..73676ae03b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt @@ -14,6 +14,7 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.domain.news.model.NewsListConfig import com.tangem.features.feed.model.news.list.NewsListModel import com.tangem.features.feed.ui.news.list.NewsListContent import kotlinx.serialization.Serializable @@ -51,7 +52,11 @@ internal class DefaultNewsListComponent( @Serializable data class Params( - val onArticleClicked: (currentArticle: Int, prefetchedArticles: List) -> Unit, + val onArticleClicked: ( + currentArticle: Int, + prefetchedArticles: List, + paginationConfig: NewsListConfig?, + ) -> Unit, val onBackClick: () -> Unit, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt index e55c4c6fcb..0cf83cd01f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt @@ -13,7 +13,12 @@ internal class BatchListStateManager( private val converter: BatchItemConverter, private val dispatchers: CoroutineDispatcherProvider, ) { - val state = MutableStateFlow(BatchListState()) + val state = MutableStateFlow( + BatchListState( + uiBatches = emptyList(), + processedItems = emptyList(), + ), + ) suspend fun update(newList: List>>, forceUpdate: Boolean) = withContext(dispatchers.default) { @@ -26,7 +31,7 @@ internal class BatchListStateManager( } val isInitialLoading = forceUpdate || - previousList.isNullOrEmpty() || + previousList.isEmpty() || newList.firstOrNull()?.key != previousList.firstOrNull()?.key val outItems = if (isInitialLoading) { @@ -77,8 +82,8 @@ internal class BatchListStateManager( } internal data class BatchListState( - val uiBatches: List>> = emptyList(), - val processedItems: List>>? = emptyList(), + val uiBatches: List>>, + val processedItems: List>>, ) internal interface BatchItemConverter { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 203f4dd6df..f9c544b6e0 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -324,6 +324,7 @@ internal class FeedComponentModel @Inject constructor( NewsUMState.ERROR, -> emptyList() }, + paginationConfig = null, ) }, onOpenAllNews = { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt index 66d705a427..d2fa25d6e6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt @@ -2,6 +2,7 @@ package com.tangem.features.feed.model.feed import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.news.model.NewsListConfig import com.tangem.features.feed.model.market.list.state.SortByTypeUM /** @@ -10,6 +11,17 @@ import com.tangem.features.feed.model.market.list.state.SortByTypeUM internal interface FeedModelClickIntents { fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency) fun onMarketOpenClick(sortBy: SortByTypeUM?) - fun onArticleClick(articleId: Int, preselectedArticlesId: List = emptyList()) + + /** + * Method to open article of news from different places. + * @param articleId - id of article, which should be opened. + * @param preselectedArticlesId - ids of articles, which were prefetched before opening news details. + * @param paginationConfig - config of current pagination from news list. Must be null, when open from other places. + */ + fun onArticleClick( + articleId: Int, + preselectedArticlesId: List = emptyList(), + paginationConfig: NewsListConfig? = null, + ) fun onOpenAllNews() } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt index 8a6c1ff9a2..026527248e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt @@ -244,7 +244,10 @@ internal class FeedMarketsBatchFlowManager( fun reload(fiatPriceCurrency: String) { modelScope.launch(dispatchers.default) { - stateManager.state.value = BatchListState() + stateManager.state.value = BatchListState( + processedItems = emptyList(), + uiBatches = emptyList(), + ) actionsFlow.emit( BatchAction.Reload( requestParams = TokenMarketListConfig( @@ -315,9 +318,9 @@ internal class FeedMarketsBatchFlowManager( fun getTokenMarketById(tokenId: CryptoCurrency.RawID): TokenMarket? { return stateManager.state.value.processedItems - ?.asSequence() - ?.flatMap { it.data } - ?.firstOrNull { it.id == tokenId } + .asSequence() + .flatMap { it.data } + .firstOrNull { it.id == tokenId } } } 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 f81479096a..2750d9d6c3 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 @@ -309,20 +309,22 @@ internal class MarketsTokenDetailsModel @Inject constructor( snapshot = null, tokenIds = listOf(params.token.id.value), ), - ).onRight { articles -> - state.update { marketsTokenDetailsUM -> - val relatedNews = shortArticleToArticleConfigUMConverter.convert(articles) - marketsTokenDetailsUM.copy( - relatedNews = marketsTokenDetailsUM.relatedNews.copy( - articles = relatedNews, - onArticledClicked = { articledId -> - params.onArticleClick( - /* articledId */ articledId, - /* preselectedIds */ relatedNews.map { it.id }, - ) - }, - ), - ) + ).onRight { listOfArticles -> + listOfArticles.collect { articles -> + state.update { marketsTokenDetailsUM -> + val relatedNews = shortArticleToArticleConfigUMConverter.convert(articles) + marketsTokenDetailsUM.copy( + relatedNews = marketsTokenDetailsUM.relatedNews.copy( + articles = relatedNews, + onArticledClicked = { articledId -> + params.onArticleClick( + /* articledId */ articledId, + /* preselectedIds */ relatedNews.map { it.id }, + ) + }, + ), + ) + } } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt index cde555e61c..ab257ebb6b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt @@ -2,7 +2,6 @@ package com.tangem.features.feed.model.news.details import androidx.compose.runtime.Stable import arrow.core.getOrElse -import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -12,28 +11,22 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.GetTokenMarketInfoUseCase import com.tangem.domain.markets.GetTokenPriceChartUseCase -import com.tangem.domain.markets.PriceChangeInterval -import com.tangem.domain.markets.TokenMarketParams -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.news.RelatedToken +import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase import com.tangem.domain.news.usecase.MarkArticleAsViewedUseCase import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent +import com.tangem.features.feed.model.news.details.factory.NewsDetailsIndexManager import com.tangem.features.feed.model.news.details.converter.NewsDetailsConverter -import com.tangem.features.feed.model.news.details.converter.RelatedTokenConverter -import com.tangem.features.feed.model.news.details.converter.TokenMarketInfoToParamsConverter +import com.tangem.features.feed.model.news.details.loader.NewsRelatedTokensLoader import com.tangem.features.feed.model.news.details.factory.NewsDetailsStateFactory import com.tangem.features.feed.ui.news.details.state.ArticleUM +import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.extensions.indexOfFirstOrNull import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.util.Locale @@ -46,6 +39,7 @@ internal class NewsDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase, + private val getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, private val urlOpener: UrlOpener, private val shareManager: ShareManager, private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, @@ -56,10 +50,29 @@ internal class NewsDetailsModel @Inject constructor( private val params = paramsContainer.require() private val currentLanguage = Locale.getDefault().language - private val relatedTokensCache = mutableMapOf() private val newsDetailsConverter = NewsDetailsConverter(onSourceClick = urlOpener::openUrl) - private val tokenMarketInfoToParamsConverter = TokenMarketInfoToParamsConverter() + + private val paginationManager: NewsDetailsPaginationManager? = params.paginationConfig?.let { config -> + NewsDetailsPaginationManager( + getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, + currentLanguage = Provider { config.language }, + currentCategoryIds = Provider { config.categoryIds }, + modelScope = modelScope, + dispatchers = dispatchers, + observeNewsDetailsUseCase = observeNewsDetailsUseCase, + prefetchedIds = params.preselectedArticlesId.toSet(), + ) + } + + private val relatedTokensLoader by lazy { + NewsRelatedTokensLoader( + getTokenMarketInfoUseCase = getTokenMarketInfoUseCase, + getTokenPriceChartUseCase = getTokenPriceChartUseCase, + dispatchers = dispatchers, + maxCount = RELATED_TOKEN_MAX_COUNT, + ) + } private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> maybeAppCurrency.getOrElse { AppCurrency.Default } @@ -73,8 +86,9 @@ internal class NewsDetailsModel @Inject constructor( private val _state = MutableStateFlow( NewsDetailsUM( + articlesStateUM = ArticlesStateUM.Loading, articles = persistentListOf(), - selectedArticleIndex = 0, + selectedArticleIndex = -1, onShareClick = {}, onLikeClick = { /* [REDACTED_TODO_COMMENT] */ }, onBackClick = params.onBackClicked, @@ -87,174 +101,100 @@ internal class NewsDetailsModel @Inject constructor( currentStateProvider = Provider { _state.value }, shareManager = shareManager, onStateUpdate = { newState -> _state.update { newState } }, + onRetryClick = ::onRetryClicked, ) } val state: StateFlow = _state.asStateFlow() init { - if (params.preselectedArticlesId.isNotEmpty()) { - handlePreselectedArticles() - } else { - // TODO handle pagination [REDACTED_TASK_KEY] - } + loadNews() } private fun onArticleIndexChanged(newIndex: Int) { stateFactory.updateSelectedArticleIndex(newIndex) - val currentArticle = state.value.articles.getOrNull(newIndex) + val currentArticle = when (state.value.articlesStateUM) { + is ArticlesStateUM.Content -> { + paginationManager?.checkAndLoadMoreIfNeeded( + currentIndex = newIndex, + totalArticlesCount = state.value.articles.size, + ) + state.value.articles.getOrNull(newIndex) + } + ArticlesStateUM.Loading, + is ArticlesStateUM.LoadingError, + -> null + } currentArticle?.let { article -> markArticleAsViewed(article.id) loadRelatedTokens(article) } } - private fun handlePreselectedArticles() { + private fun loadNews() { modelScope.launch { - observeNewsDetailsUseCase.prefetch( - newsIds = params.preselectedArticlesId, - language = currentLanguage, - ) - - observeNewsDetailsUseCase - .invoke() - .map { articlesMap -> - params.preselectedArticlesId.mapNotNull { articleId -> - articlesMap[articleId]?.let { detailedArticle -> - newsDetailsConverter.convert(detailedArticle) - } + initialPrefetch() + paginationManager?.start() + val articleIdsFlow = paginationManager?.cachedPrefetchedIds ?: flowOf(params.preselectedArticlesId) + combine( + articleIdsFlow, + observeNewsDetailsUseCase.invoke(), + ) { articleIds, articlesMap -> + articleIds.mapNotNull { articleId -> + articlesMap[articleId]?.let { detailedArticle -> + newsDetailsConverter.convert(detailedArticle) } } + } .map { articles -> articles.toImmutableList() } - .onEach { articles -> - val selectedIndex = articles.indexOfFirstOrNull { it.id == params.articleId } ?: 0 - stateFactory.updateArticles(articles, selectedIndex) + .onEach { newArticles -> + val currentState = _state.value + val newIndex = NewsDetailsIndexManager.calculateNewIndex( + currentState = NewsDetailsIndexManager.NewsDetailsState( + articles = currentState.articles, + selectedArticleIndex = currentState.selectedArticleIndex, + ), + newArticles = newArticles, + defaultArticleId = params.articleId, + ) + stateFactory.updateArticles(newArticles, newIndex) } .launchIn(modelScope) } } - private fun loadRelatedTokens(article: ArticleUM) { - modelScope.launch(dispatchers.default) { - val cachedTokens = getCachedRelatedTokens(article.id) - if (cachedTokens != null) { - stateFactory.updateRelatedTokens(cachedTokens) - return@launch - } - - stateFactory.updateRelatedTokens(RelatedTokensUM.Loading) - - val relatedTokens = article.relatedTokens.take(RELATED_TOKEN_MAX_COUNT) - if (relatedTokens.isEmpty()) { - handleEmptyRelatedTokens(article.id) - return@launch - } - - val appCurrency = currentAppCurrency.value - val tokenDataList = loadTokensData(relatedTokens, appCurrency) - - if (tokenDataList.isEmpty()) { - handleEmptyTokenData(article.id) - return@launch - } - - val resultState = createRelatedTokensState(tokenDataList, appCurrency) - relatedTokensCache[article.id] = resultState - stateFactory.updateRelatedTokens(resultState) + private suspend fun initialPrefetch() { + observeNewsDetailsUseCase.prefetch( + newsIds = params.preselectedArticlesId, + language = currentLanguage, + ).mapLeft { + stateFactory.createErrorState() } } - private fun getCachedRelatedTokens(articleId: Int): RelatedTokensUM? { - return relatedTokensCache[articleId]?.takeIf { it !is RelatedTokensUM.Loading } + private fun onRetryClicked() { + stateFactory.createLoadingState() + modelScope.launch(dispatchers.default) { + initialPrefetch() + paginationManager?.reload() + } } - private fun handleEmptyRelatedTokens(articleId: Int) { - val errorState = RelatedTokensUM.LoadingError - relatedTokensCache[articleId] = errorState - stateFactory.updateRelatedTokens(errorState) - } - - private suspend fun CoroutineScope.loadTokensData( - relatedTokens: List, - appCurrency: AppCurrency, - ): List> { - val relatedTokenConverter = RelatedTokenConverter(appCurrency = appCurrency) - - return relatedTokens.map { token -> - async(dispatchers.default) { - loadSingleTokenData(token, appCurrency, relatedTokenConverter) - } - }.awaitAll().filterNotNull() - } - - private suspend fun loadSingleTokenData( - token: RelatedToken, - appCurrency: AppCurrency, - relatedTokenConverter: RelatedTokenConverter, - ): Pair? { - val tokenId = CryptoCurrency.RawID(token.id) - val tokenInfoResult = getTokenMarketInfoUseCase( - appCurrency = appCurrency, - tokenId = tokenId, - tokenSymbol = token.symbol, - ) - - return tokenInfoResult.fold( - ifLeft = { null }, - ifRight = { tokenInfo -> - val chart = loadTokenChart(tokenId, token.symbol, appCurrency) - val tokenItem = relatedTokenConverter.convert(tokenInfo to chart) - val tokenParams = tokenMarketInfoToParamsConverter.convert(tokenInfo) - tokenItem to tokenParams - }, - ) - } - - private suspend fun loadTokenChart( - tokenId: CryptoCurrency.RawID, - tokenSymbol: String, - appCurrency: AppCurrency, - ): com.tangem.domain.markets.TokenChart? { - val chartResult = getTokenPriceChartUseCase( - appCurrency = appCurrency, - interval = PriceChangeInterval.H24, - tokenId = tokenId, - tokenSymbol = tokenSymbol, - preview = true, - ) - return chartResult.getOrElse { null } - } - - private fun handleEmptyTokenData(articleId: Int) { - val errorState = RelatedTokensUM.LoadingError - relatedTokensCache[articleId] = errorState - stateFactory.updateRelatedTokens(errorState) - } - - private fun createRelatedTokensState( - tokenDataList: List>, - appCurrency: AppCurrency, - ): RelatedTokensUM.Content { - val tokenItems = tokenDataList.map { it.first } - val onTokenClick = createTokenClickHandler(tokenDataList, appCurrency) - - return stateFactory.createRelatedTokensContent( - items = tokenItems.toImmutableList(), - onTokenClick = onTokenClick, - ) - } - - private fun createTokenClickHandler( - tokenDataList: List>, - appCurrency: AppCurrency, - ): (MarketsListItemUM) -> Unit { - return { item -> - val tokenData = tokenDataList.find { it.first.id == item.id } - tokenData?.second?.let { tokenParams -> - params.onTokenClick(tokenParams, appCurrency) - } + private fun loadRelatedTokens(article: ArticleUM) { + modelScope.launch(dispatchers.default) { + stateFactory.updateRelatedTokens(RelatedTokensUM.Loading) + val appCurrency = currentAppCurrency.value + val resultState = relatedTokensLoader.load( + articleId = article.id, + relatedTokens = article.relatedTokens, + appCurrency = appCurrency, + onTokenClick = { tokenParams, currency -> + params.onTokenClick(tokenParams, currency) + }, + ) + stateFactory.updateRelatedTokens(resultState) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt new file mode 100644 index 0000000000..54ad5c4ec6 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt @@ -0,0 +1,112 @@ +package com.tangem.features.feed.model.news.details + +import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase +import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase +import com.tangem.features.feed.model.news.list.statemanager.NewsListBatchFlowManager +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.PaginationStatus +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +internal class NewsDetailsPaginationManager( + private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase, + private val currentLanguage: Provider, + getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, + dispatchers: CoroutineDispatcherProvider, + currentCategoryIds: Provider>, + modelScope: CoroutineScope, + prefetchedIds: Set, +) : NewsListBatchFlowManager( + getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, + currentLanguage = currentLanguage, + currentCategoryIds = currentCategoryIds, + modelScope = modelScope, + dispatchers = dispatchers, +) { + + private val _cachedPrefetchedIds = MutableStateFlow(prefetchedIds) + val cachedPrefetchedIds = _cachedPrefetchedIds.asStateFlow() + + init { + reload() + } + + fun start() { + modelScope.launch(dispatchers.default) { + combine( + rawArticlesFlow, + observeNewsDetailsUseCase(), + _cachedPrefetchedIds, + ) { articles, cachedArticles, prefetched -> + calculateIdsToFetch( + visibleIds = articles.map { it.id }.toSet(), + cachedIds = cachedArticles.keys, + alreadyPrefetched = prefetched, + ) + } + .distinctUntilChanged() + .collect { newIds -> + if (newIds.isNotEmpty()) { + observeNewsDetailsUseCase.prefetch( + newsIds = newIds, + language = currentLanguage(), + ) + _cachedPrefetchedIds.update { it + newIds } + } + } + } + } + + private fun calculateIdsToFetch(visibleIds: Set, cachedIds: Set, alreadyPrefetched: Set): Set { + return visibleIds - cachedIds - alreadyPrefetched + } + + fun checkAndLoadMoreIfNeeded( + currentIndex: Int, + totalArticlesCount: Int, + preloadThreshold: Int = PRELOAD_THRESHOLD, + ) { + if (currentIndex >= totalArticlesCount - preloadThreshold) { + loadMoreIfPossible() + } + } + + private fun loadMoreIfPossible() { + modelScope.launch(dispatchers.default) { + when (val status = paginationStatus.value) { + is PaginationStatus.InitialLoading, + is PaginationStatus.NextBatchLoading, + is PaginationStatus.EndOfPagination, + -> { + return@launch + } + + is PaginationStatus.InitialLoadingError -> { + reload() + return@launch + } + + is PaginationStatus.Paginating -> { + val lastResult = status.lastResult + val canLoadMore = lastResult is BatchFetchResult.Success && !lastResult.last + + if (canLoadMore) { + loadMore() + } + } + + is PaginationStatus.None -> { + reload() + } + } + } + } + + companion object { + private const val PRELOAD_THRESHOLD = 5 + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsIndexManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsIndexManager.kt new file mode 100644 index 0000000000..b7f4c844b4 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsIndexManager.kt @@ -0,0 +1,30 @@ +package com.tangem.features.feed.model.news.details.factory + +import com.tangem.features.feed.ui.news.details.state.ArticleUM +import com.tangem.utils.extensions.indexOfFirstOrNull +import kotlinx.collections.immutable.ImmutableList + +internal object NewsDetailsIndexManager { + + /** + * Calculate new index of article, trying to save current selected article while is loading + */ + fun calculateNewIndex( + currentState: NewsDetailsState, + newArticles: ImmutableList, + defaultArticleId: Int, + ): Int { + val currentArticleId = if (currentState.articles.isEmpty()) { + defaultArticleId + } else { + currentState.articles.getOrNull(currentState.selectedArticleIndex)?.id + ?: defaultArticleId + } + return newArticles.indexOfFirstOrNull { it.id == currentArticleId } ?: 0 + } + + data class NewsDetailsState( + val articles: ImmutableList, + val selectedArticleIndex: Int, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsStateFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsStateFactory.kt index 00553ec601..d52db614b1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsStateFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsStateFactory.kt @@ -1,8 +1,8 @@ package com.tangem.features.feed.model.news.details.factory -import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.navigation.share.ShareManager import com.tangem.features.feed.ui.news.details.state.ArticleUM +import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM import com.tangem.utils.Provider @@ -12,6 +12,7 @@ internal class NewsDetailsStateFactory( private val currentStateProvider: Provider, private val shareManager: ShareManager, private val onStateUpdate: (NewsDetailsUM) -> Unit, + private val onRetryClick: () -> Unit, ) { fun updateArticles(articles: List, selectedIndex: Int) { @@ -20,6 +21,7 @@ internal class NewsDetailsStateFactory( onStateUpdate( currentState.copy( articles = articles.toImmutableList(), + articlesStateUM = ArticlesStateUM.Content, selectedArticleIndex = selectedIndex, onShareClick = { currentArticle?.let { @@ -50,13 +52,13 @@ internal class NewsDetailsStateFactory( onStateUpdate(currentState.copy(relatedTokensUM = relatedTokens)) } - fun createRelatedTokensContent( - items: List, - onTokenClick: (MarketsListItemUM) -> Unit, - ): RelatedTokensUM.Content { - return RelatedTokensUM.Content( - items = items.toImmutableList(), - onTokenClick = onTokenClick, - ) + fun createErrorState() { + val currentState = currentStateProvider() + onStateUpdate(currentState.copy(articlesStateUM = ArticlesStateUM.LoadingError(onRetryClick))) + } + + fun createLoadingState() { + val currentState = currentStateProvider() + onStateUpdate(currentState.copy(articlesStateUM = ArticlesStateUM.Loading)) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/loader/NewsRelatedTokensLoader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/loader/NewsRelatedTokensLoader.kt new file mode 100644 index 0000000000..50294b3ffc --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/loader/NewsRelatedTokensLoader.kt @@ -0,0 +1,150 @@ +package com.tangem.features.feed.model.news.details.loader + +import arrow.core.getOrElse +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.* +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.news.RelatedToken +import com.tangem.features.feed.model.news.details.converter.RelatedTokenConverter +import com.tangem.features.feed.model.news.details.converter.TokenMarketInfoToParamsConverter +import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext + +internal class NewsRelatedTokensLoader( + private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, + private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, + private val dispatchers: CoroutineDispatcherProvider, + private val maxCount: Int, +) { + private val relatedTokensCache = mutableMapOf() + private val tokenMarketInfoToParamsConverter = TokenMarketInfoToParamsConverter() + + /** + * Loads data about related tokens. + * Returns the cached result if available, otherwise loads the data. + * + * @param articleId article ID for caching + * @param relatedTokens list of related tokens + * @param appCurrency application currency + * @param onTokenClick token click handler + * @return state of related tokens + */ + suspend fun load( + articleId: Int, + relatedTokens: List, + appCurrency: AppCurrency, + onTokenClick: (TokenMarketParams, AppCurrency) -> Unit, + ): RelatedTokensUM = withContext(dispatchers.default) { + val cached = getCachedRelatedTokens(articleId) + if (cached != null) { + return@withContext cached + } + + val tokensToLoad = relatedTokens.take(maxCount) + if (tokensToLoad.isEmpty()) { + val errorState = RelatedTokensUM.LoadingError + relatedTokensCache[articleId] = errorState + return@withContext errorState + } + + val tokenDataList = loadTokensData(tokensToLoad, appCurrency) + + if (tokenDataList.isEmpty()) { + val errorState = RelatedTokensUM.LoadingError + relatedTokensCache[articleId] = errorState + return@withContext errorState + } + + val resultState = createRelatedTokensState(tokenDataList, appCurrency, onTokenClick) + relatedTokensCache[articleId] = resultState + resultState + } + + private fun getCachedRelatedTokens(articleId: Int): RelatedTokensUM? { + return relatedTokensCache[articleId]?.takeIf { it !is RelatedTokensUM.Loading } + } + + private suspend fun CoroutineScope.loadTokensData( + relatedTokens: List, + appCurrency: AppCurrency, + ): List> { + val relatedTokenConverter = RelatedTokenConverter(appCurrency = appCurrency) + + return relatedTokens.map { token -> + async(dispatchers.default) { + loadSingleTokenData(token, appCurrency, relatedTokenConverter) + } + }.awaitAll().filterNotNull() + } + + private suspend fun loadSingleTokenData( + token: RelatedToken, + appCurrency: AppCurrency, + relatedTokenConverter: RelatedTokenConverter, + ): Pair? { + val tokenId = CryptoCurrency.RawID(token.id) + val tokenInfoResult = getTokenMarketInfoUseCase( + appCurrency = appCurrency, + tokenId = tokenId, + tokenSymbol = token.symbol, + ) + + return tokenInfoResult.fold( + ifLeft = { null }, + ifRight = { tokenInfo -> + val chart = loadTokenChart(tokenId, token.symbol, appCurrency) + val tokenItem = relatedTokenConverter.convert(tokenInfo to chart) + val tokenParams = tokenMarketInfoToParamsConverter.convert(tokenInfo) + tokenItem to tokenParams + }, + ) + } + + private suspend fun loadTokenChart( + tokenId: CryptoCurrency.RawID, + tokenSymbol: String, + appCurrency: AppCurrency, + ): TokenChart? { + val chartResult = getTokenPriceChartUseCase( + appCurrency = appCurrency, + interval = PriceChangeInterval.H24, + tokenId = tokenId, + tokenSymbol = tokenSymbol, + preview = true, + ) + return chartResult.getOrElse { null } + } + + private fun createRelatedTokensState( + tokenDataList: List>, + appCurrency: AppCurrency, + onTokenClick: (TokenMarketParams, AppCurrency) -> Unit, + ): RelatedTokensUM.Content { + val tokenItems = tokenDataList.map { it.first } + val onTokenClickHandler = createTokenClickHandler(tokenDataList, appCurrency, onTokenClick) + + return RelatedTokensUM.Content( + items = tokenItems.toImmutableList(), + onTokenClick = onTokenClickHandler, + ) + } + + private fun createTokenClickHandler( + tokenDataList: List>, + appCurrency: AppCurrency, + onTokenClick: (TokenMarketParams, AppCurrency) -> Unit, + ): (MarketsListItemUM) -> Unit { + return { item -> + val tokenData = tokenDataList.find { it.first.id == item.id } + tokenData?.second?.let { tokenParams -> + onTokenClick(tokenParams, appCurrency) + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt index 5bdf4d1705..1e205fe187 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt @@ -1,25 +1,24 @@ package com.tangem.features.feed.model.news.list -import com.tangem.common.ui.R 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.components.chip.entity.ChipUM -import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase import com.tangem.features.feed.components.news.list.DefaultNewsListComponent +import com.tangem.features.feed.model.news.list.calculator.NewsListStateManager +import com.tangem.features.feed.model.news.list.loader.NewsCategoriesLoader import com.tangem.features.feed.model.news.list.statemanager.NewsListBatchFlowManager import com.tangem.features.feed.ui.news.list.state.NewsListState import com.tangem.features.feed.ui.news.list.state.NewsListUM -import com.tangem.pagination.PaginationStatus import com.tangem.utils.Provider import com.tangem.utils.SupportedLanguages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine @@ -39,6 +38,14 @@ internal class NewsListModel @Inject constructor( private val selectedCategoryId = MutableStateFlow(null) private val currentLanguage = SupportedLanguages.getCurrentSupportedLanguageCode() + private val categoriesLoader by lazy { + NewsCategoriesLoader( + getNewsCategoriesUseCase = getNewsCategoriesUseCase, + defaultAllNewsCategoryId = DEFAULT_ALL_NEWS_CATEGORIES_ID, + onCategoryClick = ::onCategoryClick, + ) + } + private val batchFlowManager by lazy { NewsListBatchFlowManager( getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, @@ -61,6 +68,7 @@ internal class NewsListModel @Inject constructor( params.onArticleClicked( /* currentArticle */ articleId, /* prefetchedArticles */ getCurrentFetchedArticlesIds(), + /* paginationConfig */ createNewsListConfig(), ) }, onBackClick = params.onBackClick, @@ -76,31 +84,7 @@ internal class NewsListModel @Inject constructor( private fun loadCategories() { modelScope.launch(dispatchers.default) { - val allCategoriesChip = ChipUM( - id = DEFAULT_ALL_NEWS_CATEGORIES_ID, - text = TextReference.Res(R.string.news_all_news), - isSelected = true, - onClick = { onCategoryClick(DEFAULT_ALL_NEWS_CATEGORIES_ID) }, - ) - val filterChips = getNewsCategoriesUseCase - .invoke() - .fold( - ifLeft = { - persistentListOf() - }, - ifRight = { categories -> - (listOf(allCategoriesChip) + categories.map { articleCategory -> - ChipUM( - id = articleCategory.id, - text = TextReference.Str(articleCategory.name), - isSelected = false, - onClick = { - onCategoryClick(articleCategory.id) - }, - ) - }).toPersistentList() - }, - ) + val filterChips = categoriesLoader.load() _state.update { currentState -> currentState.copy(filters = filterChips) } @@ -114,23 +98,16 @@ internal class NewsListModel @Inject constructor( batchFlowManager.isInInitialLoadingErrorState, batchFlowManager.paginationStatus, ) { articles, isError, paginationStatus -> - when { - isError -> NewsListState.LoadingError( - onRetryClicked = { - loadCategories() - batchFlowManager.reload() - }, - ) to persistentListOf() - paginationStatus is PaginationStatus.InitialLoading && articles.isEmpty() -> { - NewsListState.Loading to persistentListOf() - } - articles.isEmpty() -> { - NewsListState.Loading to persistentListOf() - } - else -> { - NewsListState.Content(loadMore = { batchFlowManager.loadMore() }) to articles - } - } + NewsListStateManager.calculateState( + articles = articles, + isError = isError, + paginationStatus = paginationStatus, + onRetryClick = { + loadCategories() + batchFlowManager.reload() + }, + onLoadMore = { batchFlowManager.loadMore() }, + ) }.collect { (listState, articles) -> _state.update { currentState -> currentState.copy( @@ -168,6 +145,15 @@ internal class NewsListModel @Inject constructor( return state.value.listOfArticles.map { it.id } } + private fun createNewsListConfig(): NewsListConfig { + return NewsListConfig( + language = currentLanguage, + snapshot = null, + tokenIds = emptyList(), + categoryIds = selectedCategoryId.value?.takeIf { it > 0 }?.let { listOf(it) }.orEmpty(), + ) + } + companion object { private const val DEFAULT_ALL_NEWS_CATEGORIES_ID = -1 } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/calculator/NewsListStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/calculator/NewsListStateManager.kt new file mode 100644 index 0000000000..b711909253 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/calculator/NewsListStateManager.kt @@ -0,0 +1,41 @@ +package com.tangem.features.feed.model.news.list.calculator + +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.features.feed.ui.news.list.state.NewsListState +import com.tangem.pagination.PaginationStatus +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +/** + * A pure function for calculating the state of the news list. + * Extracted from NewsListModel to simplify testing and improve readability. + */ +internal object NewsListStateManager { + + /** + * Calculates the state of the news list based on the current data. + * + * @param articles the current list of articles + * @param isError flag indicating an initial loading error + * @param paginationStatus the current pagination status + * @param onRetryClick handler for the retry loading action + * @param onLoadMore handler for loading the next page + * @return a pair: the list state and the list of articles to display + */ + fun calculateState( + articles: ImmutableList, + isError: Boolean, + paginationStatus: PaginationStatus<*>, + onRetryClick: () -> Unit, + onLoadMore: () -> Unit, + ): Pair> { + return when { + isError -> NewsListState.LoadingError(onRetryClick) to persistentListOf() + paginationStatus is PaginationStatus.InitialLoading && articles.isEmpty() -> { + NewsListState.Loading to persistentListOf() + } + articles.isEmpty() -> NewsListState.Loading to persistentListOf() + else -> NewsListState.Content(loadMore = onLoadMore) to articles + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/loader/NewsCategoriesLoader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/loader/NewsCategoriesLoader.kt new file mode 100644 index 0000000000..849ae5a073 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/loader/NewsCategoriesLoader.kt @@ -0,0 +1,50 @@ +package com.tangem.features.feed.model.news.list.loader + +import com.tangem.common.ui.R +import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.news.ArticleCategory +import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +internal class NewsCategoriesLoader( + private val getNewsCategoriesUseCase: GetNewsCategoriesUseCase, + private val defaultAllNewsCategoryId: Int, + private val onCategoryClick: (Int) -> Unit, +) { + + suspend fun load(): ImmutableList { + val allCategoriesChip = createAllNewsChip() + val categoriesResult = getNewsCategoriesUseCase.invoke() + + return categoriesResult.fold( + ifLeft = { persistentListOf(allCategoriesChip) }, + ifRight = { categories -> + val categoryChips = categories.map { articleCategory -> + createCategoryChip(articleCategory) + } + (listOf(allCategoriesChip) + categoryChips).toPersistentList() + }, + ) + } + + private fun createAllNewsChip(): ChipUM { + return ChipUM( + id = defaultAllNewsCategoryId, + text = TextReference.Res(R.string.news_all_news), + isSelected = true, + onClick = { onCategoryClick(defaultAllNewsCategoryId) }, + ) + } + + private fun createCategoryChip(articleCategory: ArticleCategory): ChipUM { + return ChipUM( + id = articleCategory.id, + text = TextReference.Str(articleCategory.name), + isSelected = false, + onClick = { onCategoryClick(articleCategory.id) }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt index 3029f0a192..86687df7a8 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt @@ -14,17 +14,18 @@ import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @Suppress("LongParameterList") -internal class NewsListBatchFlowManager( +internal open class NewsListBatchFlowManager( getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, private val currentLanguage: Provider, private val currentCategoryIds: Provider>, - private val modelScope: CoroutineScope, - private val dispatchers: CoroutineDispatcherProvider, + protected val modelScope: CoroutineScope, + protected val dispatchers: CoroutineDispatcherProvider, ) { private val actionsFlow = MutableSharedFlow>() private val converter by lazy { @@ -41,6 +42,19 @@ internal class NewsListBatchFlowManager( private val resultBatches = MutableStateFlow>>>(emptyList()) + val rawArticlesFlow: StateFlow> = batchFlow.state + .map { batchListState -> + batchListState.data + .flatMap { batch -> batch.data } + .toImmutableList() + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = persistentListOf(), + ) + val uiItems: StateFlow> get() = batchFlow.state .map { batchListState -> @@ -64,15 +78,14 @@ internal class NewsListBatchFlowManager( initialValue = false, ) - val paginationStatus: StateFlow>> - get() = batchFlow.state - .map { it.status } - .distinctUntilChanged() - .stateIn( - scope = modelScope, - started = SharingStarted.Eagerly, - initialValue = PaginationStatus.InitialLoading, - ) + val paginationStatus: StateFlow>> = batchFlow.state + .map { it.status } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = PaginationStatus.InitialLoading, + ) init { batchFlow.state diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index 25a206dcb3..e402b48dff 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -11,10 +11,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -74,7 +71,7 @@ internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) { ) { animatedState -> when (animatedState) { is GlobalFeedState.Loading -> { - FeeListLoading( + FeedListLoading( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) @@ -88,7 +85,7 @@ internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) { ) } is GlobalFeedState.Content -> { - FeeListContent( + FeedListContent( modifier = Modifier, state = state, ) @@ -125,7 +122,7 @@ private fun FeedSearchBar(feedListSearchBar: FeedListSearchBar, modifier: Modifi } @Composable -private fun FeeListContent(state: FeedListUM, modifier: Modifier = Modifier) { +private fun FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value Column( modifier = modifier @@ -289,6 +286,13 @@ private fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendi @Suppress("LongMethod") @Composable private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) { + val listState = rememberLazyListState() + val articlesReadStatus = remember(news.content) { + news.content.map { it.isViewed } + } + LaunchedEffect(articlesReadStatus) { + listState.requestScrollToItem(0) + } Column { Header( title = { @@ -347,7 +351,7 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, verticalAlignment = Alignment.CenterVertically, contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - state = rememberLazyListState(), + state = listState, ) { items( items = news.content, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt index 2e24ff3ec2..0788fe844c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt @@ -17,7 +17,7 @@ import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.res.TangemThemePreview @Composable -internal fun FeeListLoading(modifier: Modifier = Modifier) { +internal fun FeedListLoading(modifier: Modifier = Modifier) { Column(modifier) { Column( modifier = Modifier diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt index d36d027eaa..0d47363ec9 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -203,6 +205,13 @@ private fun LazyListScope.loadingInfoBlocks() { private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { item("related-news") { + val listState = rememberLazyListState() + val articlesReadStatus = remember(relatedNews.articles) { + relatedNews.articles.map { it.isViewed } + } + LaunchedEffect(articlesReadStatus) { + listState.requestScrollToItem(0) + } Column( modifier = Modifier .fillMaxWidth() @@ -220,7 +229,7 @@ private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { verticalAlignment = Alignment.CenterVertically, contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - state = rememberLazyListState(), + state = listState, ) { items( items = relatedNews.articles, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt index 86d36b10cb..917bd83e8c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.platform.testTag import com.tangem.common.ui.markets.MarketsListItem import com.tangem.common.ui.markets.MarketsListItemPlaceholder import com.tangem.common.ui.markets.models.MarketsListItemUM.Companion.TOKEN_LAZY_LIST_ID_SEPARATOR +import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig @@ -176,6 +177,7 @@ private fun ShowTokensUnder100kItem(onShowTokensClick: () -> Unit, modifier: Mod onClick = onShowTokensClick, ), ) + SpacerH12() } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt index b60edbbb14..8010cc035f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt @@ -1,6 +1,7 @@ package com.tangem.features.feed.ui.news.details import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -20,6 +21,7 @@ import androidx.compose.runtime.snapshotFlow 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -40,12 +42,45 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.ui.news.details.components.NewsDetailsPlaceholder import com.tangem.features.feed.ui.news.details.components.RelatedTokensBlock import com.tangem.features.feed.ui.news.details.state.* @Composable internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value + AnimatedContent( + targetState = state.articlesStateUM, + modifier = modifier, + ) { animatedState -> + when (animatedState) { + ArticlesStateUM.Content -> { + Content(state = state, background = background) + } + ArticlesStateUM.Loading -> { + NewsDetailsPlaceholder(background = background) + } + is ArticlesStateUM.LoadingError -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(background), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData( + onRetryClick = animatedState.onRetryClicked, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 35.dp, horizontal = 10.dp), + ) + } + } + } + } +} + +@Composable +private fun Content(state: NewsDetailsUM, background: Color) { val pagerState = rememberPagerState( initialPage = state.selectedArticleIndex, pageCount = { state.articles.size }, @@ -67,7 +102,7 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif } Column( - modifier = modifier + modifier = Modifier .fillMaxSize() .background(background), ) { @@ -318,6 +353,7 @@ private fun PreviewNewsDetailsContent() { TangemThemePreview { NewsDetailsContent( state = NewsDetailsUM( + articlesStateUM = ArticlesStateUM.Content, articles = MockArticlesFactory.createMockArticles(), selectedArticleIndex = 0, onShareClick = {}, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt new file mode 100644 index 0000000000..e985004e1b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt @@ -0,0 +1,57 @@ +package com.tangem.features.feed.ui.news.details.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerH + +@Composable +fun NewsDetailsPlaceholder(background: Color, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize().background(background).padding(16.dp), + ) { + RectangleShimmer(modifier = Modifier.size(width = 112.dp, height = 20.dp)) + SpacerH(8.dp) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(28.dp), + ) + SpacerH(4.dp) + RectangleShimmer(modifier = Modifier.size(height = 28.dp, width = 208.dp)) + SpacerH(20.dp) + RectangleShimmer(modifier = Modifier.size(height = 36.dp, width = 99.dp), radius = 12.dp) + SpacerH(32.dp) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 24.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 70.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 96.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 100.dp), + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt index 0fe906afa9..f8bcdf5f7d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt @@ -8,6 +8,7 @@ import com.tangem.domain.models.news.RelatedToken import kotlinx.collections.immutable.ImmutableList internal data class NewsDetailsUM( + val articlesStateUM: ArticlesStateUM, val articles: ImmutableList, val selectedArticleIndex: Int, val onShareClick: () -> Unit, @@ -17,6 +18,13 @@ internal data class NewsDetailsUM( val relatedTokensUM: RelatedTokensUM = RelatedTokensUM.Loading, ) +@Immutable +internal sealed interface ArticlesStateUM { + data object Loading : ArticlesStateUM + data object Content : ArticlesStateUM + data class LoadingError(val onRetryClicked: () -> Unit) : ArticlesStateUM +} + internal data class ArticleUM( val id: Int, val title: String, From cc0faf389608c5f324f96403af9a945e1d87a4ac Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 31 Dec 2025 10:28:33 +0000 Subject: [PATCH 044/172] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 21f77fa638..36965eb1e1 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.32-1335" +tangemBlockchainSdk = "develop-1330" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.32-574" +tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 5a09e48a53e95c5b8ca1fd7a4d7d640e07e2cb4b Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 2 Jan 2026 14:44:46 +0300 Subject: [PATCH 045/172] Updated on 2026-08-14 --- .../assets/configs/feature_toggles_config.json | 4 ++++ .../features/send/v2/api/SendFeatureToggles.kt | 4 +++- .../features/send/v2/DefaultSendFeatureToggles.kt | 9 ++++++++- .../features/send/v2/di/SendFeatureModule.kt | 15 +++++---------- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 1cb7bb7d24..6fa865b480 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -62,5 +62,9 @@ { "name": "FEED_ENABLED", "version": "undefined" + }, + { + "name": "GASLESS_TRANSACTIONS_ENABLED", + "version": "5.33.0" } ] diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt index 4dbf452662..c8e3780fe2 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt @@ -1,3 +1,5 @@ package com.tangem.features.send.v2.api -interface SendFeatureToggles \ No newline at end of file +interface SendFeatureToggles { + val isGaslessTransactionsEnabled: Boolean +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt index f85c289e04..8b63d8547e 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt @@ -1,5 +1,12 @@ package com.tangem.features.send.v2 +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.send.v2.api.SendFeatureToggles +import javax.inject.Inject -internal class DefaultSendFeatureToggles : SendFeatureToggles \ No newline at end of file +internal class DefaultSendFeatureToggles @Inject constructor( + private val featureTogglesManager: FeatureTogglesManager, +) : SendFeatureToggles { + override val isGaslessTransactionsEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("GASLESS_TRANSACTIONS_ENABLED") +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendFeatureModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendFeatureModule.kt index 16b42fd31b..a349ed7129 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendFeatureModule.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendFeatureModule.kt @@ -8,25 +8,20 @@ import com.tangem.features.send.v2.sendnft.DefaultNFTSendComponent import com.tangem.features.send.v2.subcomponents.notifications.DefaultSendNotificationsComponent import dagger.Binds import dagger.Module -import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -@InstallIn(SingletonComponent::class) -@Module -internal object SendFeatureModule { - - @Provides - fun provideSendFeatureToggles(): SendFeatureToggles { - return DefaultSendFeatureToggles() - } -} +internal object SendFeatureModule @Module @InstallIn(SingletonComponent::class) internal interface SendFeatureModuleBinds { + @Binds + @Singleton + fun bindSendFeatureToggles(impl: DefaultSendFeatureToggles): SendFeatureToggles + @Binds @Singleton fun provideSendComponentFactory(impl: DefaultSendComponent.Factory): SendComponent.Factory From e7f862423dc3642a7fa3a35da8486d84fa7e35f0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 2 Jan 2026 18:00:16 +0300 Subject: [PATCH 046/172] Updated on 2026-08-14 --- .../core/ui/components/audits/AuditLabel.kt | 3 ++ .../core/ui/components/audits/AuditLabelUM.kt | 3 ++ .../tangem/core/ui/components/label/Label.kt | 2 +- .../components/token/internal/TokenTitle.kt | 2 +- .../token/internal/YieldSupplyPromoBanner.kt | 2 +- .../com/tangem/core/ui/res/TangemColors.kt | 6 +-- .../com/tangem/core/ui/res/TangemTheme.kt | 4 +- .../DefaultFeeSelectorBlockComponent.kt | 3 ++ .../feeselector/ui/FeeSelectorBlockContent.kt | 39 ++++++++++++++++--- .../amount/ui/SendAmountContent.kt | 2 +- .../ui/EnvironmentTogglesScreen.kt | 2 +- 11 files changed, 52 insertions(+), 16 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabel.kt b/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabel.kt index e5293e07ab..290eaf58ac 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabel.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabel.kt @@ -62,6 +62,7 @@ private fun getTextColorByType(type: AuditLabelUM.Type): Color { AuditLabelUM.Type.Warning -> TangemTheme.colors.text.attention AuditLabelUM.Type.Permit -> TangemTheme.colors.text.accent AuditLabelUM.Type.Info -> TangemTheme.colors.text.primary2 + AuditLabelUM.Type.General -> TangemTheme.colors.text.secondary } } @@ -73,6 +74,7 @@ private fun getBackgroundColorByType(type: AuditLabelUM.Type): Color { AuditLabelUM.Type.Warning -> TangemTheme.colors.text.attention.copy(alpha = 0.1f) AuditLabelUM.Type.Permit -> TangemTheme.colors.text.accent.copy(alpha = 0.1f) AuditLabelUM.Type.Info -> TangemTheme.colors.text.accent + AuditLabelUM.Type.General -> TangemTheme.colors.control.default } } @@ -83,6 +85,7 @@ private fun getPaddingByType(type: AuditLabelUM.Type): PaddingValues { AuditLabelUM.Type.Prohibition, AuditLabelUM.Type.Warning, AuditLabelUM.Type.Permit, + AuditLabelUM.Type.General, -> PaddingValues(horizontal = 4.dp) AuditLabelUM.Type.Info -> PaddingValues(horizontal = 6.dp, vertical = 1.dp) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabelUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabelUM.kt index 4b1ed19772..a3be3ff135 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabelUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabelUM.kt @@ -25,5 +25,8 @@ data class AuditLabelUM(val text: TextReference, val type: Type) { /** Blue with white text. Like, "best rate" */ Info, + + /** Gray. Like, "info" */ + General, } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt b/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt index 4b3d1db8d6..025bbcab5e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt @@ -50,7 +50,7 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) { val backgroundColor by animateColorAsState( targetValue = when (state.style) { LabelStyle.ACCENT -> TangemTheme.colors.text.accent.copy(alpha = 0.1f) - LabelStyle.REGULAR -> TangemTheme.colors.control.unchecked + LabelStyle.REGULAR -> TangemTheme.colors.control.default LabelStyle.WARNING -> TangemTheme.colors.text.warning.copy(alpha = 0.1f) }, ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenTitle.kt index ed61f2011d..a60f268fcb 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenTitle.kt @@ -90,7 +90,7 @@ private fun YieldSupplyApyLabel(apy: TextReference?, isActive: Boolean, modifier color = if (isActive) { TangemTheme.colors.text.accent.copy(alpha = 0.1f) } else { - TangemTheme.colors.control.unchecked + TangemTheme.colors.control.default }, shape = TangemTheme.shapes.roundedCornersSmall2, ), diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/YieldSupplyPromoBanner.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/YieldSupplyPromoBanner.kt index 71ccba070a..8d60a22526 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/YieldSupplyPromoBanner.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/YieldSupplyPromoBanner.kt @@ -43,7 +43,7 @@ internal fun YieldSupplyPromoBanner(state: PromoBannerState.Content, modifier: M LaunchedEffect(state) { state.onPromoShown() } - val bgColor = TangemTheme.colors.control.unchecked + val bgColor = TangemTheme.colors.control.default Column(modifier = modifier) { Row( modifier = Modifier diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors.kt index 12f15c13b5..e4c3d5085d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors.kt @@ -169,19 +169,19 @@ class TangemColors internal constructor( @Stable class Control internal constructor( checked: Color, - unchecked: Color, + default: Color, key: Color, ) { var checked by mutableStateOf(checked) private set - var unchecked by mutableStateOf(unchecked) + var default by mutableStateOf(default) private set var key by mutableStateOf(key) private set fun update(other: Control) { checked = other.checked - unchecked = other.unchecked + default = other.default key = other.key } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index 834910220f..a83759bff8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -260,7 +260,7 @@ internal fun lightThemeColors(redesign: Boolean = false): TangemColors { ), control = TangemColors.Control( checked = TangemColorPalette.Dark6, - unchecked = TangemColorPalette.Light2, + default = TangemColorPalette.Light2, key = TangemColorPalette.White, ), stroke = TangemColors.Stroke( @@ -314,7 +314,7 @@ internal fun darkThemeColors(): TangemColors { ), control = TangemColors.Control( checked = TangemColorPalette.Azure, - unchecked = TangemColorPalette.Dark4, + default = TangemColorPalette.Dark4, key = TangemColorPalette.White, ), stroke = TangemColors.Stroke( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt index dd4e3c17dd..3837c604f8 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt @@ -14,6 +14,7 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.extensions.conditional import com.tangem.features.send.v2.api.FeeSelectorBlockComponent import com.tangem.features.send.v2.api.FeeSelectorComponent +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.params.FeeSelectorParams import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel @@ -31,6 +32,7 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor( @Assisted private val params: FeeSelectorParams.FeeSelectorBlockParams, @Assisted onResult: (feeSelectorUM: FeeSelectorUM) -> Unit, private val feeSelectorComponentFactory: FeeSelectorComponent.Factory, + private val sendFeatureToggles: SendFeatureToggles, ) : FeeSelectorBlockComponent, AppComponentContext by appComponentContext { private val model: FeeSelectorModel = getOrCreateModel(params = params) @@ -80,6 +82,7 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor( FeeSelectorBlockContent( state = state, onReadMoreClick = model::onReadMoreClicked, + isGaslessFeatureEnabled = sendFeatureToggles.isGaslessTransactionsEnabled, modifier = modifier .conditional(isScreenSource && isNotSingleFee) { Modifier.clickable { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt index 1b2d00762c..27b688a281 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt @@ -28,8 +28,11 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.ui.amountScreen.utils.getFiatString import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.components.audits.AuditLabel +import com.tangem.core.ui.components.audits.AuditLabelUM import com.tangem.core.ui.components.tooltip.TangemTooltip import com.tangem.core.ui.extensions.annotatedReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.EMPTY_BALANCE_SIGN import com.tangem.core.ui.format.bigdecimal.crypto @@ -51,6 +54,7 @@ private const val READ_MORE_TAG = "READ_MORE" @Composable internal fun FeeSelectorBlockContent( state: FeeSelectorUM, + isGaslessFeatureEnabled: Boolean, onReadMoreClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -69,16 +73,25 @@ internal fun FeeSelectorBlockContent( contentDescription = null, tint = TangemTheme.colors.icon.accent, ) - FeeSelectorDescription(state = state, onReadMoreClick = onReadMoreClick) + FeeSelectorDescription( + state = state, + isGaslessFeatureEnabled = isGaslessFeatureEnabled, + onReadMoreClick = onReadMoreClick, + ) } } @Composable -private fun FeeSelectorDescription(state: FeeSelectorUM, onReadMoreClick: () -> Unit, modifier: Modifier = Modifier) { +private fun FeeSelectorDescription( + state: FeeSelectorUM, + isGaslessFeatureEnabled: Boolean, + onReadMoreClick: () -> Unit, + modifier: Modifier = Modifier, +) { Row(modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween) { FeeSelectorStaticPart(modifier = Modifier.weight(1f), onReadMoreClick = onReadMoreClick) when (state) { - is FeeSelectorUM.Content -> FeeContent(state) + is FeeSelectorUM.Content -> FeeContent(state, isGaslessFeatureEnabled) is FeeSelectorUM.Loading -> FeeLoading() is FeeSelectorUM.Error -> FeeError() } @@ -161,9 +174,18 @@ private fun FeeLoading() { } @Composable -private fun FeeContent(state: FeeSelectorUM.Content, modifier: Modifier = Modifier) { +private fun FeeContent(state: FeeSelectorUM.Content, isGaslessFeatureEnabled: Boolean, modifier: Modifier = Modifier) { val fiatRate = state.feeFiatRateUM Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + if (isGaslessFeatureEnabled) { + AuditLabel( + state = AuditLabelUM( + text = stringReference(state.selectedFeeItem.fee.amount.currencySymbol), + type = AuditLabelUM.Type.General, + ), + ) + } + EllipsisText( text = if (state.feeExtraInfo.isFeeConvertibleToFiat && fiatRate != null) { getFiatString( @@ -206,13 +228,18 @@ private fun FeeContent(state: FeeSelectorUM.Content, modifier: Modifier = Modifi @Composable private fun FeeSelectorBlockContent_Preview(@PreviewParameter(FeeSelectorUMProvider::class) state: FeeSelectorUM) { TangemThemePreview { - FeeSelectorBlockContent(modifier = Modifier.fillMaxWidth(), state = state, onReadMoreClick = {}) + FeeSelectorBlockContent( + modifier = Modifier.fillMaxWidth(), + isGaslessFeatureEnabled = true, + state = state, + onReadMoreClick = {}, + ) } } private class FeeSelectorUMProvider : PreviewParameterProvider { private val maxFeeItem = FeeItem.Market( - fee = Fee.Common(amount = Amount(value = BigDecimal("100000000"), blockchain = Blockchain.Ethereum)), + fee = Fee.Common(amount = Amount(value = BigDecimal("100000000"), blockchain = Blockchain.Hedera)), ) private val lowFeeItem = FeeItem.Market(Fee.Common(amount = Amount(value = BigDecimal("0.0002876"), blockchain = Blockchain.Ethereum))) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt index 1177c1f24f..87d931d616 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt @@ -75,7 +75,7 @@ private fun SendConvertTokenButton(onConvertToAnother: () -> Unit) { tint = TangemTheme.colors.icon.informative, modifier = Modifier .size(20.dp) - .background(TangemTheme.colors.control.unchecked, CircleShape) + .background(TangemTheme.colors.control.default, CircleShape) .padding(2.dp), ) Text( diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt index 02d68f4f73..3bfdec7578 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt @@ -140,7 +140,7 @@ private fun EnvironmentButtons(uiModel: EnvironmentTogglesScreenUM.ApiInfoUM, on colors = SegmentedButtonDefaults.colors( activeContainerColor = TangemTheme.colors.control.checked, activeContentColor = TangemTheme.colors.text.primary2, - inactiveContainerColor = TangemTheme.colors.control.unchecked, + inactiveContainerColor = TangemTheme.colors.control.default, inactiveContentColor = TangemTheme.colors.text.primary1, ), border = BorderStroke(0.dp, TangemTheme.colors.background.tertiary), From 4cfea7fd9a93bea6c2ec3bb6bb668337c71c52c9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sat, 3 Jan 2026 12:56:36 +0300 Subject: [PATCH 047/172] Updated on 2026-08-14 --- .../domain/transaction/error/GetFeeError.kt | 6 ++++++ .../gasless/GetAvailableFeeTokensUseCase.kt | 15 +++++++++++++ .../gasless/GetFeeForGaslessUseCase.kt | 21 +++++++++++++++++++ .../usecase/gasless/GetFeeForTokenUseCase.kt | 15 +++++++++++++ .../domain/yield/supply/FeeExtensions.kt | 1 + .../ethereum/EthereumCustomFeeConverter.kt | 4 ++++ .../feature/swap/domain/SwapInteractorImpl.kt | 2 ++ gradle/tangem_dependencies.toml | 2 +- .../tangem/lib/crypto/BlockchainFeeUtils.kt | 2 ++ 9 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt diff --git a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt index ef18d2916a..6bfb7e06d4 100644 --- a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt +++ b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt @@ -9,4 +9,10 @@ sealed class GetFeeError { data object KaspaZeroUtxo : BlockchainErrors() data object SuiOneCoinRequired : BlockchainErrors() } + + sealed class GaslessError : GetFeeError() { + data object NetworkIsNotSupported : GaslessError() + data object NoSupportedTokensFound : GaslessError() + data object NotEnoughFunds : GaslessError() + } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt new file mode 100644 index 0000000000..7d57316d91 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.transaction.usecase.gasless + +import arrow.core.Either +import arrow.core.left +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.GetFeeError + +class GetAvailableFeeTokensUseCase { + + operator fun invoke(userWalletId: UserWalletId, network: Network): Either> { + return GetFeeError.UnknownError.left() + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt new file mode 100644 index 0000000000..67b2f1ec38 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.transaction.usecase.gasless + +import arrow.core.Either +import arrow.core.left +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.GetFeeError.GaslessError + +class GetFeeForGaslessUseCase { + + operator fun invoke( + userWalletId: UserWalletId, + network: Network, + transactionData: TransactionData, + ): Either { + return GaslessError.NetworkIsNotSupported.left() + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt new file mode 100644 index 0000000000..f33773dca4 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.transaction.usecase.gasless + +import arrow.core.Either +import arrow.core.left +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.transaction.error.GetFeeError + +class GetFeeForTokenUseCase { + + operator fun invoke(token: CryptoCurrency, transactionData: TransactionData): Either { + return GetFeeError.UnknownError.left() + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/FeeExtensions.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/FeeExtensions.kt index 6df54ae127..07ce01ecb4 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/FeeExtensions.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/FeeExtensions.kt @@ -41,6 +41,7 @@ fun Fee.increaseGasLimitBy(percent: BigInteger): Fee { value = increasedGasLimit.toBigDecimal().multiply(increasedGasPrice).movePointLeft(this.amount.decimals), ) return when (this) { + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") is Fee.Ethereum.EIP1559 -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) is Fee.Ethereum.Legacy -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt index 65e2574a85..be8621ba5a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt @@ -43,6 +43,7 @@ internal class EthereumCustomFeeConverter( addAll( when (value) { + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") is Fee.Ethereum.EIP1559 -> eipFeeConverter.convert(value) is Fee.Ethereum.Legacy -> legacyFeeConverter.convert(value) }, @@ -55,6 +56,7 @@ internal class EthereumCustomFeeConverter( override fun convertBack(normalFee: Fee.Ethereum, value: ImmutableList): Fee.Ethereum { return when (normalFee) { + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") is Fee.Ethereum.EIP1559 -> eipFeeConverter.convertBack(normalFee = normalFee, value = value) is Fee.Ethereum.Legacy -> legacyFeeConverter.convertBack(normalFee = normalFee, value = value) } @@ -62,6 +64,7 @@ internal class EthereumCustomFeeConverter( override fun getGasLimitIndex(feeValue: Fee.Ethereum): Int { return when (feeValue) { + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") is Fee.Ethereum.EIP1559 -> eipFeeConverter.getGasLimitIndex(feeValue) is Fee.Ethereum.Legacy -> legacyFeeConverter.getGasLimitIndex(feeValue) } @@ -74,6 +77,7 @@ internal class EthereumCustomFeeConverter( value: String, ): ImmutableList { return when (feeValue) { + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") is Fee.Ethereum.EIP1559 -> eipFeeConverter.onValueChange( feeValue = feeValue, customValues = customValues, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 067870cc0e..72a86eaec1 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -2088,6 +2088,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private fun Fee.increaseEthGasLimitInNeeded(increaseBy: Int): Fee { return when (this) { + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") is Fee.Ethereum.EIP1559, is Fee.Ethereum.Legacy, -> this.increaseGasLimitBy(increaseBy) @@ -2123,6 +2124,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( return when (this) { is Fee.Ethereum.EIP1559 -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) is Fee.Ethereum.Legacy -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") } } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 36965eb1e1..a5e86b9855 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-1330" +tangemBlockchainSdk = "develop-1338" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt index 30f9d06686..74d6248b75 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt @@ -41,6 +41,7 @@ object BlockchainFeeUtils { private fun Fee.increaseEthGasLimitInNeeded(increaseBy: Int): Fee { return when (this) { + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") is Fee.Ethereum.EIP1559, is Fee.Ethereum.Legacy, -> this.increaseGasLimitBy(increaseBy) @@ -76,6 +77,7 @@ object BlockchainFeeUtils { return when (this) { is Fee.Ethereum.EIP1559 -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) is Fee.Ethereum.Legacy -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") } } } \ No newline at end of file From f60ac156a9ab46968bebab7268b7c8e45c7740bf Mon Sep 17 00:00:00 2001 From: Tangem Date: Sat, 3 Jan 2026 15:26:46 +0300 Subject: [PATCH 048/172] Updated on 2026-08-14 --- .../GaslessTransactionRepository.kt | 19 ++ .../models/TransactionFeeExtended.kt | 9 + .../gasless/GetFeeForGaslessUseCase.kt | 285 +++++++++++++++++- gradle/tangem_dependencies.toml | 2 +- 4 files changed, 307 insertions(+), 8 deletions(-) create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt new file mode 100644 index 0000000000..d8cee83001 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.transaction + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import java.math.BigInteger + +interface GaslessTransactionRepository { + + fun isNetworkSupported(network: Network): Boolean + + fun getSupportedTokens(): Set + + fun getTokenFeeReceiverAddress(): String + + /** + * Hardcoded value as baseGas + */ + fun getBaseGasForTransaction(): BigInteger +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt new file mode 100644 index 0000000000..581292f419 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.transaction.models + +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.models.currency.CryptoCurrency + +data class TransactionFeeExtended( + val transactionFee: TransactionFee, + val feeTokenId: CryptoCurrency.ID, +) \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt index 67b2f1ec38..d78a9f1cf1 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt @@ -1,21 +1,292 @@ package com.tangem.domain.transaction.usecase.gasless import arrow.core.Either -import arrow.core.left +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Token import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchain.extensions.Result +import com.tangem.domain.demo.DemoTransactionSender +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError.GaslessError +import com.tangem.domain.transaction.error.mapToFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade +import java.math.BigDecimal +import java.math.RoundingMode -class GetFeeForGaslessUseCase { +class GetFeeForGaslessUseCase( + private val walletManagersFacade: WalletManagersFacade, + private val demoConfig: DemoConfig, + private val gaslessTransactionRepository: GaslessTransactionRepository, + private val currenciesRepository: CurrenciesRepository, + private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val getFeeUseCase: GetFeeUseCase, +) { - operator fun invoke( - userWalletId: UserWalletId, + suspend operator fun invoke( + userWallet: UserWallet, network: Network, transactionData: TransactionData, - ): Either { - return GaslessError.NetworkIsNotSupported.left() + ): Either { + return either { + catch( + block = { + val nativeCurrency = currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = network.id, + derivationPath = network.derivationPath, + ) + + if (!gaslessTransactionRepository.isNetworkSupported(network)) { + getFeeUseCase.invoke(userWallet, network, transactionData).fold( + ifLeft = { raise(it) }, + ifRight = { fee -> + return@either TransactionFeeExtended( + transactionFee = fee, + feeTokenId = nativeCurrency.id, + ) + }, + ) + } + + val walletManager = prepareWalletManager(userWallet, network) + + val initialFee = calculateInitialFee( + userWallet = userWallet, + network = network, + walletManager = walletManager, + transactionData = transactionData, + ) + + selectFeePaymentStrategy( + userWallet = userWallet, + walletManager = walletManager, + nativeCurrency = nativeCurrency, + network = network, + initialFee = initialFee, + ) + }, + catch = { + raise(GetFeeError.DataError(it)) + }, + ) + } + } + + @Suppress("NullableToStringCall") + private suspend fun Raise.prepareWalletManager( + userWallet: UserWallet, + network: Network, + ): EthereumWalletManager { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = network, + ) + val ethereumWalletManager = walletManager as? EthereumWalletManager + ?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported") + return ethereumWalletManager + } + + private suspend fun Raise.calculateInitialFee( + userWallet: UserWallet, + network: Network, + walletManager: EthereumWalletManager, + transactionData: TransactionData, + ): TransactionFee { + val transactionSender = if (userWallet is UserWallet.Cold && + demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) + ) { + demoTransactionSender(userWallet, network) + } else { + walletManager + } + + val maybeFee = when (val result = transactionSender.getFee(transactionData = transactionData)) { + is Result.Success -> result.data + is Result.Failure -> raise(result.mapToFeeError()) + } + return maybeFee + } + + private suspend fun Raise.selectFeePaymentStrategy( + userWallet: UserWallet, + walletManager: EthereumWalletManager, + nativeCurrency: CryptoCurrency, + network: Network, + initialFee: TransactionFee, + ): TransactionFeeExtended { + val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError) + + val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + ).getOrNull() ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + + val networkCurrenciesStatuses = userCurrenciesStatuses.filter { + it.currency.network.id == network.id + } + + val nativeCurrencyStatus = networkCurrenciesStatuses.find { + it.currency.id == nativeCurrency.id + } ?: raiseIllegalStateError("native currency not found for network ${network.id}") + + val nativeBalance = nativeCurrencyStatus.value.amount ?: BigDecimal.ZERO + return if (nativeBalance >= feeValue) { + TransactionFeeExtended(transactionFee = initialFee, feeTokenId = nativeCurrencyStatus.currency.id) + } else { + findTokensToPayFee( + walletManager = walletManager, + initialTxFee = initialFee, + nativeCurrencyStatus = nativeCurrencyStatus, + networkCurrenciesStatuses = networkCurrenciesStatuses, + ) + } + } + + @Suppress("NullableToStringCall") + private suspend fun Raise.findTokensToPayFee( + walletManager: EthereumWalletManager, + initialTxFee: TransactionFee, + nativeCurrencyStatus: CryptoCurrencyStatus, + networkCurrenciesStatuses: List, + ): TransactionFeeExtended { + val initialFee = initialTxFee.normal as? Fee.Ethereum + ?: raiseIllegalStateError( + error = "only Fee.Ethereum supported, but was ${initialTxFee.normal::class.qualifiedName}", + ) + + val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens() + val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses + .filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token } + .sortedByDescending { it.value.amount } + .filter { it.currency in supportedGaslessTokens } + + /** + * Selects token with highest balance to maximize chances of successful fee payment. + * Returns null if no suitable tokens found. + */ + val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull() + ?: raise(GaslessError.NoSupportedTokensFound) + + return calculateTokenFee( + walletManager = walletManager, + tokenForPayFeeStatus = tokenForPayFeeStatus, + nativeCurrencyStatus = nativeCurrencyStatus, + initialFee = initialFee, + ) + } + + private suspend fun Raise.calculateTokenFee( + walletManager: EthereumWalletManager, + tokenForPayFeeStatus: CryptoCurrencyStatus, + nativeCurrencyStatus: CryptoCurrencyStatus, + initialFee: Fee.Ethereum, + ): TransactionFeeExtended { + val tokenForPayFee = tokenForPayFeeStatus.currency as? CryptoCurrency.Token + ?: raiseIllegalStateError("only tokens are supported") + + val transferFeeToContractAmount = createTokenAmount(tokenForPayFee, BigDecimal(FEE_TRANSFER_AMOUNT)) + val feeDestination = gaslessTransactionRepository.getTokenFeeReceiverAddress() + val feeTransferGasLimitResult = walletManager.getGasLimit( + amount = transferFeeToContractAmount, + destination = feeDestination, + callData = TransferERC20TokenCallData( + destination = feeDestination, + amount = transferFeeToContractAmount, + ), + ) + + val feeTransferGasLimit = when (feeTransferGasLimitResult) { + is Result.Failure -> raise(GetFeeError.DataError(feeTransferGasLimitResult.error)) + is Result.Success -> feeTransferGasLimitResult.data + } + + val baseGas = gaslessTransactionRepository.getBaseGasForTransaction() + + val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas + + val maxFeePerGas = when (initialFee) { + is Fee.Ethereum.EIP1559 -> initialFee.maxFeePerGas + is Fee.Ethereum.Legacy -> initialFee.gasPrice + is Fee.Ethereum.TokenCurrency -> raiseIllegalStateError("initialFee could only be native") + } + + val feeInNativeCurrency = maxTokenFeeGas + .multiply(maxFeePerGas) + .multiply(GAS_PRICE_MULTIPLIER.toBigInteger()) + + val nativeFiatRate = nativeCurrencyStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null") + val tokenFiatRate = tokenForPayFeeStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null") + val coinPriceInToken = nativeFiatRate.divide( + tokenFiatRate, + maxOf(nativeCurrencyStatus.currency.decimals, tokenForPayFee.decimals), + RoundingMode.DOWN, + ) + + val feeInTokenCurrency = coinPriceInToken.multiply(feeInNativeCurrency.toBigDecimal()) + + val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO + if (tokenBalance < feeInTokenCurrency) { + raise(GaslessError.NotEnoughFunds) + } + + val amount = createTokenAmount(tokenForPayFee, feeInTokenCurrency) + + val fee = Fee.Ethereum.TokenCurrency( + amount = amount, + gasLimit = maxTokenFeeGas, + coinPriceInToken = coinPriceInToken, + feeTransferGasLimit = feeTransferGasLimit, + baseGas = baseGas, + ) + return TransactionFeeExtended( + transactionFee = TransactionFee.Single(normal = fee), + feeTokenId = tokenForPayFee.id, + ) + } + + private fun createTokenAmount(token: CryptoCurrency.Token, value: BigDecimal): Amount = Amount( + token = Token( + symbol = token.symbol, + contractAddress = token.contractAddress, + decimals = token.decimals, + ), + value = value, + ) + + private suspend fun Raise.demoTransactionSender( + userWallet: UserWallet, + network: Network, + ): DemoTransactionSender { + return DemoTransactionSender( + walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network) + ?: raiseIllegalStateError("WalletManager is null"), + ) + } + + private fun Raise.raiseIllegalStateError(error: String): Nothing { + raise(GetFeeError.DataError(IllegalStateException(error))) + } + + private companion object { + /** Amount in token units for fee transfer */ + const val FEE_TRANSFER_AMOUNT = 10000 + /** Gas price safety multiplier for fee calculation */ + const val GAS_PRICE_MULTIPLIER = 2 } } \ No newline at end of file diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index a5e86b9855..b5bf92e53a 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-1338" +tangemBlockchainSdk = "develop-1340" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 30ba7411107ec1014f785d4d1d09bafe67d211c4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 4 Jan 2026 14:42:00 +0300 Subject: [PATCH 049/172] Updated on 2026-08-14 --- .../gasless/GetAvailableFeeTokensUseCase.kt | 84 +++++++++++++++++-- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt index 7d57316d91..019d021363 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -1,15 +1,89 @@ package com.tangem.domain.transaction.usecase.gasless import arrow.core.Either -import arrow.core.left +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError +import java.math.BigDecimal -class GetAvailableFeeTokensUseCase { +class GetAvailableFeeTokensUseCase( + private val gaslessTransactionRepository: GaslessTransactionRepository, + private val currenciesRepository: CurrenciesRepository, + private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, +) { - operator fun invoke(userWalletId: UserWalletId, network: Network): Either> { - return GetFeeError.UnknownError.left() + /** + * Retrieves available tokens for gasless fee payment. + * + * @return List where first element is always native currency (for fallback) + */ + suspend operator fun invoke( + userWallet: UserWallet, + network: Network, + ): Either> { + return either { + catch( + block = { + val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + ).getOrNull() + ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + + val nativeCurrencyStatus = getNativeCurrencyStatus(userWallet, network, userCurrenciesStatuses) + + if (!gaslessTransactionRepository.isNetworkSupported(network)) { + return@either listOf(nativeCurrencyStatus) + } + + val gaslessTokens = getGaslessTokens(userCurrenciesStatuses) + buildList { + add(nativeCurrencyStatus) + addAll(gaslessTokens) + } + }, + catch = { + raise(GetFeeError.DataError(it)) + }, + ) + } + } + + private suspend fun Raise.getNativeCurrencyStatus( + userWallet: UserWallet, + network: Network, + userCurrenciesStatuses: List, + ): CryptoCurrencyStatus { + val nativeCurrency = currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = network.id, + derivationPath = network.derivationPath, + ) + return userCurrenciesStatuses.find { + it.currency.id == nativeCurrency.id + } ?: raiseIllegalStateError("no native currency found") + } + + private fun getGaslessTokens(userCurrenciesStatuses: List): List { + val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens() + return userCurrenciesStatuses + .asSequence() + .filter { currencyStatus -> + currencyStatus.currency is CryptoCurrency.Token && + currencyStatus.value.amount?.let { amount -> amount > BigDecimal.ZERO } == true && + supportedGaslessTokens.contains(currencyStatus.currency) + } + .toList() + } + + private fun Raise.raiseIllegalStateError(error: String): Nothing { + raise(GetFeeError.DataError(IllegalStateException(error))) } } \ No newline at end of file From 68b0ef56e790519fa4f24ec3aa5cb7fd16eca97e Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 4 Jan 2026 14:20:46 +0200 Subject: [PATCH 050/172] Updated on 2026-08-14 --- .../walletmanager/utils/SdkTransactionTypeConverter.kt | 2 ++ domain/legacy/src/main/assets/contract_methods.json | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt index 639350d503..5096b7fe26 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt @@ -71,10 +71,12 @@ internal class SdkTransactionTypeConverter( "buyVoucher", "buyVoucherPOL", "delegate", + "pooledStake", -> TxInfo.TransactionType.Staking.Stake "sellVoucher_new", "sellVoucher_newPOL", "undelegate", + "pooledUnstake", -> TxInfo.TransactionType.Staking.Unstake "unstakeClaimTokens_new", "unstakeClaimTokens_newPOL", diff --git a/domain/legacy/src/main/assets/contract_methods.json b/domain/legacy/src/main/assets/contract_methods.json index fe538d3526..1ba70afcc7 100644 --- a/domain/legacy/src/main/assets/contract_methods.json +++ b/domain/legacy/src/main/assets/contract_methods.json @@ -236,5 +236,15 @@ "info": "yieldModule", "source": "https://github.com/tangem/tangem-yield-module-contracts/", "name": "supplyTopUp" + }, + "0xf9609f08": { + "info": "P2PEthPool", + "source": "https://docs.p2p.org/", + "name": "pooledStake" + }, + "0xac9650d8": { + "info": "P2PEthPool", + "source": "https://docs.p2p.org/", + "name": "pooledUnstake" } } From a1c0fa83e5fb969a143a17259afee6f3ade753f6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 4 Jan 2026 14:21:29 +0200 Subject: [PATCH 051/172] Updated on 2026-08-14 --- .../P2PEthPoolStakingBalanceConverter.kt | 31 ++++++++++++++----- .../store/DefaultP2PEthPoolBalancesStore.kt | 20 +++++------- .../tangem/data/staking/StakingBalanceExt.kt | 6 ++-- .../domain/models/staking/StakingBalance.kt | 12 +++---- 4 files changed, 40 insertions(+), 29 deletions(-) diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingBalanceConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingBalanceConverter.kt index d04832f6a4..d802603286 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingBalanceConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingBalanceConverter.kt @@ -8,29 +8,44 @@ import com.tangem.domain.staking.model.StakingIntegrationID import java.math.BigDecimal /** - * Converts P2PEthPool API response to [StakingBalance]. + * Converts P2PEthPool API responses to [StakingBalance]. * * Uses [P2PEthPoolStakingAccountConverter] for account conversion to avoid duplication. */ internal object P2PEthPoolStakingBalanceConverter { - fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance { + fun convertAll(responses: Set, source: StatusSource): Set { + return responses + .groupBy { it.delegatorAddress } + .map { (delegatorAddress, accountResponses) -> + convert(delegatorAddress, accountResponses, source) + } + .toSet() + } + + private fun convert( + delegatorAddress: String, + responses: List, + source: StatusSource, + ): StakingBalance { val stakingId = StakingID( integrationId = StakingIntegrationID.P2PEthPool.value, - address = response.delegatorAddress, + address = delegatorAddress, ) - val account = P2PEthPoolStakingAccountConverter.convert(response) + val accounts = responses.map { P2PEthPoolStakingAccountConverter.convert(it) } - val hasActivePosition = account.stake.assets > BigDecimal.ZERO || - account.exitQueue.total > BigDecimal.ZERO || - account.availableToWithdraw > BigDecimal.ZERO + val hasActivePosition = accounts.any { account -> + account.stake.assets > BigDecimal.ZERO || + account.exitQueue.total > BigDecimal.ZERO || + account.availableToWithdraw > BigDecimal.ZERO + } return if (hasActivePosition) { StakingBalance.Data.P2PEthPool( stakingId = stakingId, source = source, - account = account, + accounts = accounts, ) } else { StakingBalance.Empty( diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt index 35f65f95da..4bb91ccd21 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt @@ -46,12 +46,10 @@ internal class DefaultP2PEthPoolBalancesStore( runtimeStore.store( value = cachedData.map { (stringWalletId, responses) -> val key = UserWalletId(stringWalletId) - val value = responses.map { response -> - P2PEthPoolStakingBalanceConverter.convert( - response = response, - source = StatusSource.CACHE, - ) - }.toSet() + val value = P2PEthPoolStakingBalanceConverter.convertAll( + responses = responses, + source = StatusSource.CACHE, + ) key to value }.toMap(), @@ -116,12 +114,10 @@ internal class DefaultP2PEthPoolBalancesStore( } private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set) { - val newBalances = values.map { response -> - P2PEthPoolStakingBalanceConverter.convert( - response = response, - source = StatusSource.ACTUAL, - ) - }.toSet() + val newBalances = P2PEthPoolStakingBalanceConverter.convertAll( + responses = values, + source = StatusSource.ACTUAL, + ) runtimeStore.update(default = emptyMap()) { saved -> saved.toMutableMap().apply { diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt index aec8fadd1f..0b2a63f262 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt @@ -14,8 +14,8 @@ internal fun YieldBalanceWrapperDTO.toDomain(source: StatusSource = StatusSource internal fun P2PEthPoolAccountResponse.toDomain( source: StatusSource = StatusSource.CACHE, ): StakingBalance { - return P2PEthPoolStakingBalanceConverter.convert( - response = this, + return P2PEthPoolStakingBalanceConverter.convertAll( + responses = setOf(this), source = source, - ) + ).first() } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt index 3e852db2fa..11c22e2ae4 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt @@ -55,18 +55,18 @@ sealed interface StakingBalance { data class P2PEthPool( override val stakingId: StakingID, override val source: StatusSource, - val account: P2PEthPoolStakingAccount, + val accounts: List, ) : Data { - override val totalStaked: SerializedBigDecimal = account.stake.assets + override val totalStaked: SerializedBigDecimal = accounts.sumOf { it.stake.assets } - override val totalRewards: SerializedBigDecimal = account.stake.totalEarnedAssets + override val totalRewards: SerializedBigDecimal = accounts.sumOf { it.stake.totalEarnedAssets } - override val unstakingAmount: SerializedBigDecimal = account.exitQueue.total + override val unstakingAmount: SerializedBigDecimal = accounts.sumOf { it.exitQueue.total } - override val withdrawableAmount: SerializedBigDecimal = account.availableToWithdraw + override val withdrawableAmount: SerializedBigDecimal = accounts.sumOf { it.availableToWithdraw } - override val entries: List = account.toStakingBalanceEntries() + override val entries: List = accounts.flatMap { it.toStakingBalanceEntries() } } } From ebd588756cca28fc43b35a7563aa58b5555f6be6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 4 Jan 2026 15:03:46 +0200 Subject: [PATCH 052/172] Updated on 2026-08-14 --- .../impl/presentation/model/StakingModel.kt | 165 ++++++++++++------ .../state/events/StakingAlertUM.kt | 8 + .../state/events/StakingEventFactory.kt | 9 + .../UpdateConfirmationFeeTransformer.kt | 28 +++ 4 files changed, 158 insertions(+), 52 deletions(-) create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/UpdateConfirmationFeeTransformer.kt diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index 494dcdd997..88e9a090ad 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -3,6 +3,7 @@ package com.tangem.features.staking.impl.presentation.model import androidx.compose.runtime.Stable import arrow.core.getOrElse import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer @@ -397,62 +398,122 @@ internal class StakingModel @Inject constructor( modelScope.launch { stakingAnalyticSender.sendTransactionStakingClickedAnalytics(value) stateController.update(SetConfirmationStateInProgressTransformer()) - transactionSender.send( - StakingTransactionSender.Callbacks( - onConstructSuccess = { constructedTransactions -> - transactionsInProgress.addAll(constructedTransactions) - }, - onConstructError = { error -> - stakingEventFactory.createStakingErrorAlert(error) - stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) - }, - onSendSuccess = { txUrl -> - stakingAnalyticSender.sendTransactionStakingAnalytics( - stateController.value, - cryptoCurrencyStatus, - ) - transactionsInProgress.clear() - stateController.update( - SetConfirmationStateCompletedTransformer(txUrl, cryptoCurrencyStatus), - ) - }, - onSendError = { error -> - analyticsEventHandler.send( - StakingAnalyticsEvent.TransactionError( - errorCode = error.getAnalyticsDescription(), - ), - ) - stakingEventFactory.createSendTransactionErrorAlert(error) - stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) - }, - onFeeIncreased = { increasedFee, isFeeApproximate -> - stateController.updateAll( - SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus), - SetConfirmationStateAssentTransformer( - appCurrencyProvider = Provider { appCurrency }, - feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, - fee = increasedFee, - isFeeApproximate = isFeeApproximate, - cryptoCurrencyStatus = cryptoCurrencyStatus, - ), - ) - stateController.updateEvent( - StakingEvent.ShowAlert( - StakingAlertUM.FeeIncreased(stateController::dismissAlert), - ), - ) - updateNotifications() - }, - onTransactionExpired = { - stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) - getFee() - }, - ), - ) + + if (integration is P2PEthPoolIntegration) { + checkFeeAndSendP2PTransaction() + } else { + sendTransaction() + } }.saveIn(sendTransactionJobHolder) } } + private suspend fun checkFeeAndSendP2PTransaction() { + val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data + val currentFeeState = confirmationState?.feeState as? FeeState.Content + val currentFee = currentFeeState?.fee + + var actualFee: Fee? = null + feeLoader.getFee( + onStakingFee = { fee, _ -> + actualFee = fee + }, + onStakingFeeError = { error -> + stakingEventFactory.createStakingErrorAlert(error) + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + }, + onApprovalFee = { }, + onFeeError = { error -> + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + updateNotifications(error) + }, + ) + + val newFee = actualFee ?: return + + val currentFeeAmount = when (currentFee) { + is Fee.Common -> currentFee.amount.value ?: BigDecimal.ZERO + is Fee.Ethereum -> currentFee.amount.value ?: BigDecimal.ZERO + else -> BigDecimal.ZERO + } + + val newFeeAmount = when (newFee) { + is Fee.Common -> newFee.amount.value ?: BigDecimal.ZERO + is Fee.Ethereum -> newFee.amount.value ?: BigDecimal.ZERO + else -> BigDecimal.ZERO + } + + if (newFeeAmount > currentFeeAmount) { + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + stakingEventFactory.createNetworkFeeUpdatedAlert( + onConfirm = { + modelScope.launch { + stateController.update(UpdateConfirmationFeeTransformer(newFee)) + stateController.update(SetConfirmationStateInProgressTransformer()) + sendTransaction() + } + }, + ) + } else { + sendTransaction() + } + } + + private suspend fun sendTransaction() { + transactionSender.send( + StakingTransactionSender.Callbacks( + onConstructSuccess = { constructedTransactions -> + transactionsInProgress.addAll(constructedTransactions) + }, + onConstructError = { error -> + stakingEventFactory.createStakingErrorAlert(error) + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + }, + onSendSuccess = { txUrl -> + stakingAnalyticSender.sendTransactionStakingAnalytics( + stateController.value, + cryptoCurrencyStatus, + ) + transactionsInProgress.clear() + stateController.update( + SetConfirmationStateCompletedTransformer(txUrl, cryptoCurrencyStatus), + ) + }, + onSendError = { error -> + analyticsEventHandler.send( + StakingAnalyticsEvent.TransactionError( + errorCode = error.getAnalyticsDescription(), + ), + ) + stakingEventFactory.createSendTransactionErrorAlert(error) + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + }, + onFeeIncreased = { increasedFee, isFeeApproximate -> + stateController.updateAll( + SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus), + SetConfirmationStateAssentTransformer( + appCurrencyProvider = Provider { appCurrency }, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fee = increasedFee, + isFeeApproximate = isFeeApproximate, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ), + ) + stateController.updateEvent( + StakingEvent.ShowAlert( + StakingAlertUM.FeeIncreased(stateController::dismissAlert), + ), + ) + updateNotifications() + }, + onTransactionExpired = { + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + getFee() + }, + ), + ) + } + override fun onPrevClick() { if (value.currentStep == StakingStep.Confirmation) { val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt index af6931c29f..3e757a6adc 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt @@ -74,4 +74,12 @@ internal sealed class StakingAlertUM : AlertUM { ) override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok) } + + data class NetworkFeeUpdated( + override val onConfirmClick: () -> Unit, + ) : StakingAlertUM() { + override val title: TextReference = resourceReference(R.string.staking_alert_network_fee_updated_title) + override val message: TextReference = resourceReference(R.string.staking_alert_network_fee_updated_message) + override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok) + } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt index 465bd1c024..4ba7fb1dd9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt @@ -57,4 +57,13 @@ internal class StakingEventFactory( ), ) } + + fun createNetworkFeeUpdatedAlert(onConfirm: () -> Unit) { + val alert = StakingEvent.ShowAlert( + alert = StakingAlertUM.NetworkFeeUpdated( + onConfirmClick = onConfirm, + ), + ) + stateController.updateEvent(alert) + } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/UpdateConfirmationFeeTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/UpdateConfirmationFeeTransformer.kt new file mode 100644 index 0000000000..9c00ba3234 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/UpdateConfirmationFeeTransformer.kt @@ -0,0 +1,28 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.features.staking.impl.presentation.state.FeeState +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.utils.transformer.Transformer + +internal class UpdateConfirmationFeeTransformer( + private val newFee: Fee, +) : Transformer { + + override fun transform(prevState: StakingUiState): StakingUiState { + val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data + ?: return prevState + + val currentFeeState = confirmationState.feeState as? FeeState.Content + ?: return prevState + + val updatedFeeState = currentFeeState.copy(fee = newFee) + + return prevState.copy( + confirmationState = confirmationState.copy( + feeState = updatedFeeState, + ), + ) + } +} \ No newline at end of file From 90c43018fd612e93d82a9a20ca80177f1d56268a Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 4 Jan 2026 17:20:58 +0300 Subject: [PATCH 053/172] Updated on 2026-08-14 --- .../com/tangem/common/TangemBlogUrlBuilder.kt | 4 + core/res/src/main/res/values-de/strings.xml | 18 +- core/res/src/main/res/values-es/strings.xml | 2 + core/res/src/main/res/values-ja/strings.xml | 45 +- core/res/src/main/res/values-ru/strings.xml | 20 + .../src/main/res/values-uk-rUA/strings.xml | 446 +++++++++++++++ core/res/src/main/res/values/strings.xml | 4 + features/send-v2/impl/build.gradle.kts | 1 + .../DefaultFeeSelectorBlockComponent.kt | 4 +- .../DefaultFeeSelectorComponent.kt | 55 +- .../component/FeeSelectorComponentParams.kt | 10 + .../speed/FeeSpeedSelectorComponent.kt | 44 ++ .../speed/FeeSpeedSelectorIntents.kt | 15 + .../speed/model/FeeSpeedSelectorModel.kt | 39 ++ .../speed/ui/FeeSpeedSelectorContent.kt | 513 ++++++++++++++++++ .../feeselector/di/FeeSelectorModelModule.kt | 12 + .../model/FeeSelectorBlockModel.kt | 78 +++ .../feeselector/model/FeeSelectorIntents.kt | 2 + .../v2/feeselector/model/FeeSelectorLogic.kt | 202 +++++++ .../v2/feeselector/model/FeeSelectorModel.kt | 239 +------- .../v2/feeselector/route/FeeSelectorRoute.kt | 30 + .../ui/FeeSelectorModalBottomSheet.kt | 500 ++--------------- .../PreviewFeeSelectorBlockComponent.kt | 9 + 23 files changed, 1590 insertions(+), 702 deletions(-) create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/FeeSelectorComponentParams.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/FeeSpeedSelectorComponent.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/FeeSpeedSelectorIntents.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/model/FeeSpeedSelectorModel.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorBlockModel.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/route/FeeSelectorRoute.kt diff --git a/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt b/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt index 0319e1c59f..1ba2efe988 100644 --- a/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt +++ b/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt @@ -31,5 +31,9 @@ object TangemBlogUrlBuilder { data object WhatWalletToChoose : Post { override val path: String = "mobile-wallet" } + + data object WhatIsTransactionFee : Post { + override val path: String = "what-is-a-transaction-fee-and-why-do-we-need-it" + } } } \ No newline at end of file diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 81ee6497c5..0b971535c9 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -61,8 +61,8 @@ Bist Du sicher, dass Du das neue Konto verwerfen willst? Bist Du sicher, dass Du die Bearbeitungen verwerfen willst? Nicht gespeicherte Änderungen - Einige benutzerdefinierte Token wurden von “%1$s” Zu “%2$s” da ihre Herleitung zu diesem Konto gehört. - Einige benutzerdefinierte Token wurden verschoben. + Einige benutzerdefinierte Token werden automatisch von „%1$s\" Zu \"%2$sda ihre Herleitung zu diesem Konto gehört. + Einige benutzerdefinierte Token werden automatisch verschoben. Du kannst Deinen Token nicht finden? Gehe zum Bereich „Markt“ auf der Hauptseite und fügen diesen zum kaufen im Portfolio hinzu. Du kannst Deinen Token nicht finden? Gehe zum Bereich „Markt“ auf der Hauptseite und fügen diesen zum verkaufen im Portfolio hinzu. Verkaufen @@ -182,8 +182,8 @@ Alle Tangem-Geräte wurden zurückgesetzt. Du kannst nun mit dem Upgrade Deiner Wallet fortfahren. Erneut aktualisieren Zurücksetzen abgeschlossen - Du hast nicht all Deine Tangem-Geräte zurückgesetzt. - Du hast nicht alle Deine Tangem-Geräte zurückgesetzt. + Wir empfehlen, den Reset-Prozess für alle Tangem-Geräte in dieser Wallet abzuschließen. + Einige Tangem-Geräte müssen noch zurückgesetzt werden. Deaktiviere diese Option, wenn du nicht möchtest, dass diese Karte oder Ring zum Zurücksetzen von Zugangscodes auf anderen Karten oder Ringe in dieser Wallets verwendet wird. Bitte beachte, dass du dann auch den Zugangscode auf dieser Karte oder Ring nicht zurücksetzen kannst. Ermöglicht die Verwendung dieser Karte oder Ring zum Zurücksetzen des Zugangscodes auf anderen Karten oder Ringe in dieser Brieftasche Zugangspin resetten @@ -276,7 +276,7 @@ Aktivieren Aktiviert Fehler - Geschätzte Gebühr + Aufladegebühr Umtausch Erkunden Transaktionsverlauf einsehen @@ -539,6 +539,10 @@ ID: %s Transaktions-ID kopiert Tauschen jeden Vermögenswert in Deinem Portfolio gegen diesen Token + Eine höhere Geschwindigkeit bedeutet eine schnellere Bestätigung und eine höhere Netzgebühr. %s + Geschwindigkeit wählen + Wähle aus, mit welchem Token Du die Netzgebühr bezahlen möchtest. %s + Token auswählen Markt & Nachrichten Aktuelle Trends Die folgenden Angaben sind freiwillig. Du kannst diese löschen, wenn du sie nicht weitergeben möchtest. @@ -625,7 +629,7 @@ Trotzdem entfernen! Für diese Wallet existiert ein Backup. Überprüfe dieses bitte, bevor Du sie entfernst, um sicherzustellen, dass Du sie später wiederherstellen kannst. Wenn Du diese Wallet ohne Backup entfernst, verlierst Du dauerhaft den Zugriff auf Deine Assets. - Diese Wallet dauerhaft entfernen? + Bist du sicher, dass du diese Wallet vergessen willst? Mir ist bewusst, dass ich den Zugriff auf meine Wallet verlieren kann, wenn ich sie vor der Entfernung nicht gesichert habe. Mir ist bewusst, dass durch das Entfernen meiner Wallet diese nicht gelöscht, sondern lediglich von meinem Gerät entfernt wird. Upgrade @@ -1474,7 +1478,7 @@ Tippe auf „Karte hinzufügen“. Tippen Sie auf „Debit- oder Kreditkarte“ Gebe die Kartendaten manuell ein - Verifiziere die Karte mit dem Einmalpasswort (OPT), das an Dein Gerät gesendet wird. + Verifiziere Deine Karte mithilfe des an Dein Gerät gesendeten OTP. Alles erledigt! Deine Karte ist einsatzbereit. Karte zu Google Pay hinzufügen Karte zu Apple Pay hinzufügen diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 1c463c9d8e..aad8904c38 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -20,6 +20,8 @@ Archivar Estás archivando esta cuenta, pero siempre puede recuperarla. Cuenta + No se puede editar la cuenta + Algunos tokens personalizados se moverán automáticamente de \"%1$s\" a \"%2$s\" ya que su derivación pertenece a esa cuenta. ¿No encuentra el token en su billetera? Consulte la sección de Mercados para encontrarlo y añadirlo a la compra ¿No encuentra el token en su billetera? Consulte los mercados para encontrarlo y añadirlo a la venta Vender diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 666fe8e6bb..0dbce5c791 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -61,8 +61,8 @@ 新しいアカウントを破棄してもよろしいですか? 編集内容を破棄してもよろしいですか? 保存されていない変更 - 一部のカスタムトークンは、その派生がそのアカウントに属しているため、「 %1$s 」から「 %2$s 」に移動されました。 - 一部のカスタムトークンが移動されました + 一部のカスタムトークンは、その派生元(デリベーション)が該当アカウントに属しているため、自動的に「%1$s」から「%2$s」へ移動されます。 + 一部のカスタムトークンは自動的に移動されます トークンが見つかりませんか?メインページのマーケットセクションに移動し、ポートフォリオに追加して買付できるようにします。 トークンが見つかりませんか?メインページのマーケットセクションに移動し、ポートフォリオに追加して売却できるようにします。 売却 @@ -127,15 +127,15 @@ モバイルウォレット ウォレットのバックアップが正常に完了しました。 - これらの単語は一度失うと復元できません。安全な場所に保管してください。 + これらの単語は、紛失すると復元できません。安全に保管してください。 バックアップが完了しました - シークレットリカバリーフレーズとは、ウォレットへのアクセスや復元のために使う、固定された%s個のランダムな単語のセットです。 + シークレットリカバリーフレーズは、ウォレットへのアクセスおよび復元に使用する、%s個のランダムな単語のセットです。 これらの単語は紛失した場合、復元できません。大切に保管してください。 安全に保管してください これらの%s個の単語を安全な場所に保存し、決して誰とも共有しないでください。 復元は不可能です リカバリーフレーズ - これらの単語は誰にも教えないでください。Tangemがこれらの単語を尋ねることは、絶対にありません。以下の%s個の単語はウォレットのリカバリーフレーズです。デバイスを紛失した場合、これらの単語を使用してウォレットを復元できます。 + 以下の%s個の単語が、あなたのウォレットのリカバリーフレーズです。これらの単語は絶対に他人と共有しないでください。Tangemがこれらを尋ねることは決してありません。デバイスを紛失した場合、このフレーズを使ってウォレットを復元できます。 これらの%s個の単語を番号順に書き留め、安全かつ他人の目に触れない場所に保管してください ウォレットの安全確保およびリカバリーフレーズの安全なバックアップは、すべてご自身の責任となります。 リカバリーフレーズ @@ -539,6 +539,7 @@ お持ちのカードまたはリングについて教えてください サポートチームの皆さん、こんにちは。 問題について詳しく教えてください。どんな些細なことでも役に立ちます。 + バックアップの問題 以前に有効化されたウォレット 私の提案 カード / リングをスキャンできません @@ -557,7 +558,7 @@ 続行するには、%1sスマートコントラクトに%2sを使用する権限を付与してください 許可を与える 無制限 - バックアップは2枚または3枚のTangemカードを使って作成されます。紛失や破損に備えて、それぞれ別々の安全な場所に保管してください—シードフレーズは不要です。 + バックアップは2〜3枚のTangemカードを使用して作成されます。紛失や破損に備え、カードはそれぞれ安全な別の場所に保管してください。シードフレーズは不要です。 複数のカードでバックアップ Tangemウォレットを追加 あなたの秘密鍵はTangemカード内部で直接生成され、決してカードの外に出ることはありません。 @@ -578,7 +579,7 @@ ウォレットの設定を完了する アクセスコードでアプリを保護して、設定を完了してください。 終了すると、最初からやり直す必要があります。 - セットアップを中止してもよろしいですか? + アクティベーションを終了してもよろしいですか? 暗号資産をオフラインで厳重保管。カードサイズで、金庫以上の安心を。 実行すると、最初からやり直す必要があります。 Googleドライブのバックアップから既存のウォレットを復元する @@ -628,7 +629,7 @@ 操作中にエラーが発生しました。 処理中も、資金は安全に保たれ、常にアクセス可能です。 資金へのアクセス - ウォレットのデータはアプリから削除され、ハードウェアウォレットに保存されます + ウォレット情報はアプリから削除され、ハードウェアウォレットに保存されます セキュリティ全般 秘密鍵はアプリからTangemハードウェアウォレットへ移行されます キーの移行 @@ -802,6 +803,7 @@ クイックまとめ 関連ニュース + 関連トークン 情報源 最新情報を入手 お使いのデバイスではNFCが使用できません @@ -884,8 +886,8 @@ 操作を繰り返してください。カードは工場出荷時の設定にリセットされます。 アクティベーションに失敗しました トークンを追加する - バックアップカードまたはリングを1つ追加しました。バックアップ処理が終了すると、これ以上バックアップデバイスを追加することはできません。もう一つカードまたはリングがある場合、バックアップに追加してください。バックアップ処理を続けますか? - バックアップ手続きは部分的に完了しています。現在終了することはできません。 + バックアップ用のカードまたはリングを1つ追加しました。バックアップが完了すると、これ以上デバイスを追加することはできません。追加のカードまたはリングをお持ちの場合は、今のうちに追加してください。続行しますか? + バックアップは一部完了しており、現在は中断できません。 パスフレーズは、リカバリーフレーズに単語やフレーズを追加することで、追加の保護を提供する任意のセキュリティ機能です。これにより、新しいウォレットアドレスのセットが作成されます。 バックアップ用のカードまたはリングを追加 カードをスキャン @@ -933,7 +935,7 @@ レガシー シードフレーズを正しく書き込んだかどうかを確認するために、2番目、7番目、11番目の単語を入力してください。 では、確認してみましょう - バックアップ処理を開始するには、最大2つのバックアップカードまたはリングを追加します。 + バックアップを開始するには、最大2枚のバックアップカードまたはリングを追加してください。 カードまたはリングをもう1つ追加するか、バックアップ処理を完了します 番号%sのバックアップカードを準備してください プライマリカードまたはリングをスキャンして、バックアップ処理を開始します。 @@ -1068,8 +1070,8 @@ この操作を実行すると、現在のウォレットにアクセスできなくなることを理解しています。 このカードを使用して、現在のウォレットの他のカードのアクセスコードを回復させられないことを認識しています。 復元は不可能であり、Tangem Payカードおよびカード上のすべての資金へのアクセスを完全に失うことを理解しました - 工場出荷時設定にリセットすると、選択したカードやリングからウォレットが完全に削除されます。現在のウォレットを復元したり、カードやリングを使用してアクセスコードを復元することはできません。 - 工場出荷時の状態にリセットすると、選択したカードやリングからウォレットが完全に削除され、アプリから削除されます。現在のウォレットを復元することはできません。 + 初期化を行うと、選択したカードまたはリングからウォレットは完全に消去されます。現在のウォレットを復元することはできず、このカードまたはリングを使ってアクセスコードを復旧することもできません。 + 初期化を行うと、選択したカードまたはリングからウォレットが完全に消去され、アプリからも削除されます。現在のウォレットは復元できません。 すべてのTangemデバイスがリセットされました。 アクティベーション処理中に問題が発生しました。カードを1枚ずつリセットしてください。 カード認証に失敗しました @@ -1232,6 +1234,7 @@ ステーキングアカウントとは、ステーキングされたSOLが保管される特別なアカウントです。トークンをバリデーターに委任し、取引の検証に参加して報酬を受け取る際に作成されます。ステーキングアカウントの作成には少額の手数料がかかりますが、ステーキング完了後に返金されます。 年率 APR:1年間で得られる利息を単利で表示します。得た利息は残高に加算されず、その利息にはさらに利息がつきません。 + 年利(APY) APY:1年間で得られる合計利息を複利込みで表示します。複利とは、得た利息が残高に加算され、その利息にもさらに利息がつくことを意味します。 APR APY @@ -1353,7 +1356,7 @@ あなたのステーキング カードまたはリング内に秘密鍵を保管しながら、暗号資産を安全に保管します 革新的なハードウェアウォレット - 1つのウォレットに最大3枚のカードまたは3本のリング + 1つのウォレットに、最大3枚のカードまたはリングを追加 安全なバックアップ ビットコイン、イーサリアム、その他多くの暗号資産を同時に管理できるハードウェアウォレット – オール・イン・ワンのカード / リング 数千の通貨 @@ -1508,7 +1511,9 @@ 技術的な問題を修正しています。後でもう一度お試しください。 サービスは一時的に利用できません 現在、データを表示できませんが、カードでのお支払いは引き続きご利用いただけます。 - 同期が必要です + \nPINコードの設定 + 未同期 + アクセスを復元 日常の支払いにUSDCを利用 Tangem Payは現在一時的に利用できません。 Tangem Pay @@ -1717,7 +1722,7 @@ %sを使用するか、カード / リングをスキャンしてウォレットにアクセスしてください 許可付与のプロセスは現在進行中であり、まもなく完了する予定です。 承認中 - カードまたはリングのアクティベーションが正しく完了しませんでした。デバイスのNFCモジュールに問題があるか、カードまたはリングをデバイスに正しくタップしていないことが原因かもしれません。サポートチームにお問い合わせください。 + アクティベーションが正常に完了しませんでした。NFCの問題、またはタップ方法が正しくない可能性があります。サポートチームまでお問い合わせください。 アクティベーションに失敗しました 2024年12月3日、BEP-2ネットワークはネットワーク開発者の決定により使用不能となり、サポートは終了しました。 BNBビーコンチェーンは閉鎖されました @@ -1728,6 +1733,8 @@ はい、わかりました! すごくクールだ! リフレッシュ + Cloreの公式ドキュメントによると、12月21日以前に受け取ったすべてのコインは Clore(ERC-20トークン)へ移行されますが、同日以降に受け取ったコインは移行されません。送金(移行)ソリューションは現在準備中です。続報をお待ちください。 + Cloreネットワークの移行 現在デモモードです デモモードが有効になっています スキャンしたカードは開発者カードです。ウォレットの作成には使用しないでください。 @@ -1775,7 +1782,7 @@ 現在、ネットワークにアクセスできません。しばらくしてからもう一度お試しください。 ネットワークにアクセスできません ウォレットに入金 - ウォレットがバックアップされていません。アセットを保護するために今すぐこの手順を実行してください。 + ウォレットはまだバックアップされていません。今すぐバックアップして資産を保護しましょう。 バックアップがありません このカードは以前取引に使用されたことがあります。信頼できない出所から受け取った場合は、全資金を引き出すことを検討してください。あなたのカードであれば、何もする必要はありません。 カードはすでに取引に署名済みです @@ -1914,10 +1921,10 @@ Tangemハードウェアウォレットを使用する 詳細を見る・購入する 破棄 - バックアップが中断されました。再開しますか? + バックアップが中断されました。再開しますか? はい、再開します 破棄 - バックアップを破棄する場合は、デバイスを工場出荷時の設定にリセットしてやり直す必要があります。 + 今バックアップを破棄すると、デバイスを初期化して最初からやり直す必要があります。 バックアップを再開する これはやり直しができないアクションです %sでログイン diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index ee6d3b99d7..aa04e8ffcf 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -9,6 +9,8 @@ Код доступа может быть легко угадан Введите код доступа Неверный код доступа. Ваш мобильный кошелёк будет удалён после ещё %s неверных попыток. + Неверный код доступа. Приложение будет заблокировано после ещё %s неудачных попыток. + Неверный код доступа.\nПожалуйста, подождите %s секунд и попробуйте снова. Подтвердите ваш код доступа, чтобы продолжить. Повторите код доступа Установите %s-значный код доступа для разблокировки кошелька. @@ -633,6 +635,7 @@ Забыть этот кошелек? Я понимаю, что если я не создал резервную копию кошелька перед его удалением, я могу потерять к нему доступ. Я понимаю, что удаление моего кошелька не стирает его — оно просто удаляет его с моего устройства. + Апгрейд Фраза восстановления больше не нужна — ваша карта или кольцо Tangem становятся вашей надёжной резервной копией. Резервное копирование Это устройство не может быть использовано для апгрейда, оно уже содержит другой кошелек. @@ -814,6 +817,23 @@ Обновитесь до версии %1$s, чтобы создать мобильный кошелёк Мобильный кошелек требует %1$s или новее. Все новости + Нравится + + %dч назад + %dч назад + %dч назад + %dч назад + + + %dмин назад + %dмин назад + %dмин назад + %dмин назад + + Резюме + Связанные новости + Связанные токены + Источники Будьте в курсе Функция NFC недоступна на вашем устройстве О NFT diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 6df04c2899..016bb09e5e 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1,5 +1,67 @@ + Ваш гаманець не захищений без коду доступу. + Все одно пропустити + Код доступу не встановлено + Змінити код + Ваш код доступу буде використовуватися для розблокування гаманця та захисту доступу до ваших активів. + Використати все одно + Код доступу може бути легко вгаданий + Введіть код доступу + Неправильний код доступу. Ваш мобільний гаманець буде видалено після ще %s невірних спроб. + Неправильний код доступу. Додаток буде заблоковано після ще %s невдалих спроб + Неправильний код доступу.\nБудь ласка, зачекайте %s секунд і спробуйте ще раз. + Підтвердьте свій код доступу, щоб продовжити + Повторно введіть код доступу + Встановіть %s-значний код доступу для розблокування вашого гаманця. + Створити код доступу + Код доступу + Не можна створити більше ніж %1$s акаунтів. Архівуйте, щоб додати новий. + Неможливо додати новий акаунт + Акаунт архівовано + Архівовані акаунти + Відновити + Ви збираєтеся відновити \"%1$s\". + Відновити акаунт + Ви досягли ліміту в 20 активних акаунтів. Архівуйте акаунт, щоб відновити цей. + Неможливо відновити акаунт + Архівовано + Не вдалося архівувати акаунт. Будь ласка, спробуйте пізніше. + Цей акаунт бере участь у реферальній програмі. + Цей акаунт не можна архівувати + Не вдалося створити акаунт. Будь ласка, спробуйте пізніше. + Акаунт створено + Архівний акаунт + Архів + Ви архівуєте цей акаунт, але ви завжди можете його відновити. + Архівування... + Акаунт + Неможливо відредагувати обліковий запис + Акаунт збережено + Акаунт для винагород + Акаунт №%s — використовується для деривації. + Додати акаунт + Зберегти + Назва акаунту + Акаунт з такою назвою вже існує. Будь ласка, виберіть іншу назву. + Акаунт + Новий акаунт + Додати акаунт + Редагувати акаунт + Спробуйте пізніше. Якщо проблема повториться, зверніться у підтримку — ми допоможемо її вирішити. + %1$s в %2$s + Основний акаунт + Ви перевищили ліміт %1$s активних акаунтів. Архівуйте, щоб продовжити + Неможливо відновити акаунт + Акаунт відновлено + Натисніть та утримуйте акаунт, щоб змінити порядок акаунтів + Продовжити + Скасувати + Ви впевнені, що хочете скасувати створення нового акаунту? + Ви впевнені, що хочете скасувати зміни? + Незбережені зміни + Деякі користувацькі токени будуть автоматично переміщені з \"%1$s\" до \"%2$s\", оскільки їх похідні належать до того акаунту. + Деякі користувацькі токени будуть автоматично переміщені Токен не знайдено у вашому портфелі? Перевірте Ринки, щоб знайти та додати його для покупки Токен не знайдено у вашому портфелі? Перевірте Ринки, щоб знайти та додати його для продажу Продати @@ -34,12 +96,19 @@ Токени в мережі %1$s не підтримуються цією карткою або кільцем через обмеження прошивки. У вас виникли труднощі зі скануванням картки або кільця? Ця картка не призначена для роботи з цим додатком + Спочатку встановіть код доступу, щоб увімкнути біометрію. + Використовуйте %1$s для розблокування гаманця і підтвердження конфіденційних дій, таких як підписання транзакцій. Для апаратних гаманців для підпису все ще потрібна картка або кільце. Комісія за замовчуванням Увімкніть комісію за замовчуванням, щоб автоматично встановлювати комісію за транзакцію і пропускати сторінку комісій під час відправлення коштів. Ви завжди можете повернутися до цієї сторінки за необхідності. Перейдіть до налаштувань, щоб увімкнути біометричну автентифікацію в додатку Tangem Увімкнути біометричну автентифікацію + Вимкнення %1$s вимагатиме введення коду доступу для розблокування додатку та взаємодії з гаманцем. + Пізніше буде необхідно ввести код доступу для безпечного зберігання гаманця. Усі збережені коди доступу будуть видалені. Вам доведеться вводити код доступу при роботі з гаманцем. При відключенні функції збереження гаманця всі до цього збережені гаманці будуть видалені зі застосунку. + Це призведе до видалення всіх збережених кодів доступу до гаманця. Вам потрібно буде ввести код доступу знову, щоб користуватися гаманцем. + Необхідний код доступу + Ця опція вимикає можливість використовувати біометрію для конфіденційних дій. Вам потрібно буде вводити код доступу щоразу, коли ви підписуєте транзакцію. Зберегти код доступу Підключіть функцію збереження кодів доступу від карток на телефоні у зашифрованому виді, і при роботі з карткою або кільцем замість коду доступу буде запитуватися біометрична автентифікація. Зберігати гаманець у додатку @@ -49,6 +118,16 @@ Системна Тема Налаштування застосунку + Додати гаманець + Виберіть гаманець для входу + З поверненням! + + %s картка + %s картки + %s карток + %s картки + + Мобільний гаманець Ви успішно створили резервну копію свого гаманця. Ці слова неможливо відновити у разі втрати. Зберігайте їх надійно. Бекап завершено @@ -68,10 +147,17 @@ Баланси приховані Згідно інформації від розробників мережі, токени Kaspa знаходяться у режимі бета. Слідкуйте за оновленнями! Бета режим + Біометрія на вашому пристрої вимкнена, тому ви не можете використовувати її для розблокування гаманців. Увімкніть біометрію в налаштуваннях пристрою, щоб знову використовувати цей метод. + Біометрична автентифікація відключена Будь ласка, відскануйте картку або кільце + Ви досягли ліміту спроб біометрії. Розблокуйте гаманець за допомогою дотику пристрою або введіть код доступу. + Біометрична автентифікація заблокована Будь ласка, спробуйте знову через 30 секунд або відскануйте картку або кільце + Біометричний вхід тимчасово заблоковано. Будь ласка, повторіть спробу через 30 секунд або розблокуйте гаманець дотиком пристрою чи кодом доступу. Забагато спроб Ви вимкнули біометричну автентифікацію на своєму телефоні і не зможете зберігати гаманці в додатку. Щоб зберегти гаманці, будь ласка, увімкніть функцію біометричної автентифікації в налаштуваннях телефону. + Біометричні дані на вашому пристрої було оновлено. Будь ласка, оберіть свій гаманець і введіть код доступу до нього, щоб знову увімкнути біометричний вхід. + Увага Під час обробки промокоду сталася помилка. Будь ласка, спробуйте пізніше. Помилка активації Ваш промокод було успішно активовано. Винагороду буде зараховано на ваш рахунок протягом 14 днів. @@ -90,6 +176,19 @@ %d пристроїв %d пристроїв + + %1$d токен + %1$d токени + %1$d токенів + %1$d токени + + Будь ласка, скиньте налаштування наступного пристрою, щоб продовжити. + Скидання гаманця + Всі пристрої Tangem було скинуто. Тепер ви можете продовжити оновлення гаманця. + Оновити ще раз + Скидання завершено + Ми рекомендуємо завершити процес скидання для всіх пристроїв Tangem у цьому гаманці. + Деякі пристрої Tangem все ще потрібно скинути. Вимкніть цю опцію, якщо не хочете, щоб ця картка використовувалася для скидання кодів доступу на інших картках у цьому гаманці. Зверніть увагу, скидання коду доступу також не буде доступним на цій картці. Дозволяє використовувати цю картку для скидання коду доступу на інших картках у цьому гаманці Відновлення коду доступу @@ -115,19 +214,25 @@ Недостатньо ADA Прийняти Доступ заборонено + Акаунт + Акаунти + Активувати Додати Додати у портфель Додати токен + Додано Адреса Усе Дозволити Сума Аналітика + і Застосовувати Затвердження Підтвердити Увага Доступні мережі + Резервне копіювання Баланс: %s Баланс біометричну автентифікацію @@ -138,15 +243,20 @@ Ви не надали доступ до камери, будь ласка, змініть налаштування конфіденційності Скасувати Змінити + Оберіть акаунт Виберіть дію + Оберіть мережу Оберіть токен Оберіть гаманець Отримати Отримати винагороди Закрити + Незабаром Підтвердити Підключення Звернутися у підтримку + Звернутися до служби підтримки Tangem + Звернутися до служби підтримки Visa Продовжити Конвертувати Копіювати @@ -167,6 +277,7 @@ днів Видалити + Вимкнути Вимкнуто Від\'єднати Готово @@ -186,8 +297,13 @@ Повільно Швидкість та комісія Завершити + Забути + Безкоштовно + З + З %s Синхронізувати адреси Почати + Отримати токен Перейти до провайдера Перейти до токену Зrozуміло @@ -198,16 +314,27 @@ Пізніше Дізнатися більше Залишилося %1$s + Legacy Bitcoin Заблокований + Заблоковані гаманці Основна мережа місяць Комісія мережі Сума відправлення буде зменшена на %1$s (%2$s) для покриття обраного рівня комісії + + %1$s мережа + %1$s мережі + %1$s мереж + %1$s мережі + + Нова адреса + Новини Далі NFT Ні Немає адреси Не додано + Не зараз Зараз ОК Відкрити в браузері @@ -220,10 +347,12 @@ %1$s — %2$s Детальніше Отримати + Рекомендовано Відхилити Оновити Перейменувати Обов\'язково + Скинути Зберегти Зберегти зміни Пошук @@ -238,19 +367,25 @@ Сервер недоступний, спробуйте пізніше Поширити Поділитися посиланням + Сховати Показати більше Підписати Підписати та надіслати + Пропустити + Щось пішло не так Застейкати Стейкінг Почати Продовжити Успіх Підтримка + Підтримувані мережі Обмін + Tangem умови участі Умовами використання До + На %s Сьогодні %d токен @@ -262,17 +397,23 @@ Статус транзакції Транзакції Переказ + Неможливо завантажити дані... Я зрозумів + Я розумію, продовжити Виникла помилка. Будь ласка, спробуйте ще раз. Недоступно Скасувати стейкінг Через обмеження %1$s в одну транзакцію може поміститися тільки %2$d UTXO. Це означає, що ви можете відправити тільки %3$s або менше. Вам потрібно зменшити суму. + Скопійовано тиждень з Так Режим дохідності Адреса контракту скопійована! Доступні мережі + Деривація вашого токена співпадає с деривацією %1$s. Ваш токен буде додано до цього акаунта. + Деривація належить іншому акаунту. + Токен додано до акаунту %1$s Адреса контракту Адреса контракту недійсна Будь ласка, оберіть мережу @@ -313,6 +454,7 @@ Переверніть екран пристрою вниз, щоб швидко приховати та відобразити баланси %s хешів Номер пристрою + Відкрити чат підтримки Додати більше карток Валюта застосунку Приховувати баланси жестом перевороту @@ -333,11 +475,13 @@ Помилка WalletConnect Ви використали картку або кільце від іншого гаманця. Прикладіть картку або кільце, пов\'язану з цим гаманцем. Недостатньо коштів для здійснення транзакції. Будь ласка, поповніть свій акаунт. + Обміняйте будь-який актив у вашому портфелі на цей токен Мої токени У вас немає доданих токенів. Додайте токени для обміну Недоступний для обміну з %s Послугу надає Статус + Виберіть провайдера Tangem пропонує обмін токенів через сторонніх провайдерів відповідно до умов кожного провайдера Провайдер Виникла помилка. Код: %s @@ -388,6 +532,7 @@ Найкращий курс Список попереджень FCA Найкращий вибір + Список попереджень FCA Доступно до %s Доступно від %s Недоступно для цієї пари @@ -399,10 +544,15 @@ Приховати цю транзакцію Після приховування статус транзакції більше не можна буде переглянути. Замість цього ви можете просто провести пальцем, щоб закрити. Приховати статус транзакції? + Цей токен не підтримується. Будь ласка, виберіть інший токен для обміну. + %s не підтримується + Обміняти з Токенів не знайдено. Будь ласка, спробуйте інший запит ID: %s ID транзакції скопійовано Обміняйте будь-який актив у своєму портфелі на цей токен + Ринок та Новини + В тренді Інформація нижче не є обов\'язковою. Ви можете стерти її, якщо бажаєте. Розкажіть, яких функцій вам не вистачає, і ми спробуємо вам допомогти. Розкажіть, будь ласка, яку картку або кільце ви маєте? @@ -434,9 +584,11 @@ Генерація ключа Операції з криптографією відбуваються всередині захищеного чіпу, стійкого до клонування та фізичному взлому. Безпека на апаратному рівні + Додати існуючий гаманець Створити новий гаманець Купити Сканувати + Дозволити додатку \"Tangem\" використовувати біометричну автентифікацію для підтвердження вашої особи та відкриття додатку? в %s В мережі %s Ви впевнені, що хочете скасувати процес створення коду доступу? @@ -488,6 +640,7 @@ Забути цей гаманець? Я розумію, що якщо я не створив резервну копію гаманця перед його видаленням, я можу втратити досту до нього. Я розумію, що видалення мого гаманця не видаляє його повністю, а просто видаляє його з мого пристрою. + Оновлення Фраза відновлення більше не потрібна — ваша картка або кільце Tangem стає вашою безпечною резервною копією. Резервне копіювання з Tangem Цей пристрій не може бути використаний для оновлення, він вже містить інший гаманець. @@ -554,17 +707,21 @@ Обраний токен наразі недоступний для дій у криптогаманці. Але не хвилюйтеся, ви можете висловити свою зацікавленість, проголосувавши за його інтеграцію. Проголосувати Гаманець не підтримує більше однієї мережі + Про монету Щоб купити, обміняти або отримати цей актив, додайте його до свого портфоліо Цей актив наразі не підтримується в гаманці Цей токен недоступний для цього гаманця Додати + APY %s Моє портфоліо Маркет + Заробляйте з Tangem Щоб згенерувати адреси для обраних мереж, відскануйте вашу картку або кільце Tangem гаманця Щоб додати токени, потягніть вгору або торкніться панелі пошуку Дані цього розділу отримані з наступних мереж: %s Не вдалося завантажити дані... Немає даних + Пульс ринку Швидкі дії Шукати на маркеті Результат @@ -586,8 +743,10 @@ Лідери росту Лідери падіння В тренді + Режим дохідності Стейкінг — простий спосіб отримувати дохід з вашої криптовалюти. %s Отримуйте до %s APY + Токен додано Про %s %d біржа @@ -658,6 +817,25 @@ Обсяг Потягніть вгору або торкніться панелі пошуку, щоб додати токени безпосередньо з маркету Додати токени + Збільшуйте дохід з активів, зберігаючи миттєвий доступ до них. %s + Активувати режим дохідності + Оновіться до версії %1$s, щоб створити мобільний гаманець + Мобільний гаманець потребує %1$s або новіше. + Всі новини + + %1$s годину тому + %1$s години тому + %1$s годин тому + %1$s годин тому + + + %1$s хвилину тому + %1$s хвилини тому + %1$s хвилин тому + %1$s хвилин тому + + Пов\'язані токени + Залишайтеся в курсі подій Функція NFC недоступна на вашому пристрої Про NFT NFT @@ -716,6 +894,11 @@ Обліковий запис одержувача не активовано. Надішліть %s або більше, щоб активувати обліковий запис. Для створення акаунту надішліть кошти на цю адресу Акаунт одержувача не містить трастлайну для активу, що надсилається. + Отримайте $10 в BTC на кожен гаманець. Поспішайте! + Чорна п\'ятниця: знижки до 30% + Купити + Час обмежений! + 1+1: Купи один гаманець — отримай 50% знижку Приєднатися Поділіться промокодом — заробіть 5 USDT з кожної покупки. Ваші друзі отримають знижку 10% на картку Tangem! Отримуй бонуси за кожного друга! @@ -762,12 +945,14 @@ Генеруйте ключі приватно Ваша картка активована та готова до використання Успішно! + Ваш гаманець налаштований і готовий до використання! У цьому випадку вам доведеться почати наново. Ви хочете вийти з процесу активації? Підготовка На картці, яку ви намагаєтеся додати, вже створено інший гаманець. Якщо у вас є кошти на цьому гаманці, будь ласка, виведіть їх, а потім скиньте цю картку до заводських налаштувань і додайте її як резервну. Збережіть ваш гаманець Резервна копія + Біометрія Дізнатися більше про seed-фразу @@ -825,6 +1010,7 @@ Відновлення коду доступу Ідентичні картки Код доступу + Усі пропозиції Доступно з %s Tangem забезпечує доступ к покупці через сторонніх провайдерів згідно з їхніми умовами Пошук за країною @@ -832,13 +1018,28 @@ Інші валюти Популярні фіати Пошук по валюті + Ця транзакція вже була оброблена. Додаткові дії не потребуються. + Шукаємо найвигідніший курс... + Миттєво Використовуючи сервіс покупки, ви погоджуєтесь з %1$s та %2$s + Послуга надається зовнішнім провайдером.\nTangem не несе відповідальності. Сума покупки не може бути більше ніж %s Сума покупки повинна бути не менше %s Для данної валюти немає доступних провайдерів + Найшвидший Оплата з + Спосіб оплати Доступно до %s Доступно від %s + + %s провайдер + %s провайдери + %s провайдерів + %s провайдерів + + Провайдери + Нещодавно використано + Рекомендовано Ви зможете завершити транзакцію у стороннього постачальника, %s Перенаправлення на %s... Наші сервіси недоступні в цій країні @@ -847,8 +1048,21 @@ Будь ласка, виберіть правильну країну, щоб забезпечити точні способи оплати та послуги. Параметри Ви можете закрити цей екран і перевірити статус транзакції на екрані деталей токена. + + до %s дня + до %s днів + до %s днів + до %s днів + + %s хв + Доступно з + Доступно до + Ви отримаєте + Послуга надається зовнішнім провайдером. \nTangem не несе відповідальності. Ви можете перевірити статус транзакції на сторінці токена + До Через + Ви заплатите Групами За балансом Сортування токенів @@ -860,6 +1074,7 @@ Налаштування Увімкнути сповіщення Отримуйте сповіщення про вхідні транзакції в підтримуваних мережах. + Сповіщення про транзакції Виберіть з галереї Налаштування Ви не надали доступ до своєї камери @@ -871,6 +1086,7 @@ Надсилайте лише %s на цю адресу. Надсилання будь-якої іншої валюти призведе до її незворотної втрати. Надсилайте лише %1$s у мережі %2$s Переказуйте кошти з будь-якого гаманця або біржі + Адреса для отримання винагород Взяти участь Не вдалося завантажити інформацію по реферальній програмі. Будь ласка, спробуйте пізніше. Не вдалося завантажити інформацію по реферальній програмі. Код помилки: %s. Будь ласка, спробуйте пізніше. @@ -909,8 +1125,14 @@ Я розумію, що не можу використати цю картку для відновлення свого коду доступу на інших картках поточного гаманця Скидання до заводських налаштувань призведе до повного видалення гаманця з обраної картки або кільця. Ви не зможете відновити поточний гаманець або використати цю картку або кільце для відновлення коду доступу. Скидання до заводських налаштувань призведе до повного видалення гаманця з обраної картки або кільця. Ви не зможете відновити поточний гаманець. + Усі пристрої Tangem було скинуто. + Щось пішло не так у процесі активації. Будь ласка, скиньте картки по черзі. + Не вдалося перевірити картку + Будь ласка, скиньте наступний пристрій, щоб продовжити Власникам кілець - 3 обміни без комісії на Changelly до 15.11! Обмін з 0% комісією! + Пристрої з root-доступом вважаються менш безпечними. Ваші дані можуть бути піддані додатковим ризикам. + Виявлено root-доступ Увійдіть у додаток та слідкуйте за своїм балансом без сканування картки чи кільця Доступ до додатку Використовувати біометрію @@ -938,6 +1160,7 @@ Транзакція не завершена Конвертувати в інший токен Сума + Буде надіслано одержувачу Ви можете встановити розмір комісії за транзакцію, налаштувавши значення в полі Satoshi per vByte. Комісія, яка буде стягнута за вашу транзакцію. Ви можете встановити власне значення. Максимальна комісія @@ -962,6 +1185,7 @@ Перевірте підключення до мережі Інформація щодо комісії в мережі недоступна Ви надсилаєте + З %s Ліміт газу Це максимальна кількість газу, яка буде витрачена на здійснення транзакції чи контракту. Ліміт газу запобігає несподіваним або необмеженим стягненням під час виконання транзакції. Вартість газу @@ -969,6 +1193,7 @@ Все Максимальна сума Комісія може сягати до + Мемо: %s Недопустимий Memo Покриття мережевої комісії Nonce @@ -1018,6 +1243,7 @@ комісія мережі буде покрита, використовуючи %1$s енергію Комісія мережі буде зменшена, за рахунок використання %1$s енергії включно з комісією мережі %1$s + На адресу Транзакція успішно підписана і відправлена до блокчейну. Баланс гаманця буде оновлено через деякий час %1$s — це монета у мережі Tron. Щоб розрахувати комісію та здійснити транзакцію, вам необхідно внести певну кількість Tron (TRX) на свій рахунок. Сума перевищує залишок @@ -1047,14 +1273,21 @@ Це призведе до видалення гаманця з застосунку. Сам гаманець можна додати знову. Ім\'я Змусьте свій токен працювати + Мережева комісія — це невелика оплата, необхідна для обробки та підтвердження вашої транзакції в блокчейні. + Щоб почати стейкінг, ваш TON-акаунт потрібно активувати транзакцією на 1 TON. Кошти залишаються на вашому акаунті, оскільки цей крок лише активує його для стейкінгу. + Активація акаунту + Мережева комісія змінилася. Будь ласка, перевірте нову суму перед тим, як продовжити. + Комісія мережі оновлена Сума для стейкінгу має бути не менше %s Сума стейкінгу буде округлена до %1$s TRX відповідно до правил мережі. Сума зняття зі стейкінгу буде округлена до %1$s TRX через мережеві правила. + APR %1$s%% Зняти кошти Комісія за стейкінг-акаунт Стейкінг-акаунт — це спеціальний рахунок, на якому зберігаються застейкані SOL токени. Він створюється, коли ви делегуєте свої токени валідатору для участі у перевірці транзакцій та отримання винагород. За створення стейкінг-акаунту стягується невелика комісія, яка повертається після завершення стейкінгу. Процентна ставка APR: Показує відсоток, який ви можете заробити за рік без урахування складного відсотка. Нараховані відсотки не додаються до вашого балансу, тому на них не нараховуються додаткові відсотки. + Річна відсоткова дохідність APY: Показує загальний відсоток, який ви можете заробити за рік з урахуванням складного відсотка. Складний відсоток означає, що нараховані відсотки додаються до вашого балансу, і на них також нараховуються відсотки. APR APY @@ -1083,6 +1316,7 @@ Комісія за схвалення токену, щоб верифікувати, що саме ви дозволяєте використання токена для стейкінгу. Використовуючи функцію стейкінгу, ви погоджуєтесь з %1$s та %2$s Заблоковано + Максимальна сума: %s Перемістити Нативний стейкінг На даний момент немає доступних активних валідаторів для стейкінгу. Будь ласка, спробуйте пізніше. @@ -1113,6 +1347,7 @@ Рестейк дозволяє вам перемістити ваші кошти від одного валідатора до іншого без необхідності виводити кошти зі стейкінгу Ви збираєтеся застейкати весь свій баланс. Ми рекомендуємо залишити невелику суму, щоб покрити комісію мережі за зняття коштів або отримання винагороди. Щоб розпочати стейкінг, спочатку активуйте свій TON-акаунт. + Активація акаунту Щоб почати стейкінг в TON, спочатку здійсніть вихідну транзакцію на будь-яку суму — це активує ваш гаманець. До 0.2 TON може знадобитися додатково мережевої комісії для завершення транзакції. Невикористана частина буде повернута. Для виконання операції потребується додатково 0.2 TON, крім мережевої комісії. Будь ласка, поповніть баланс. @@ -1186,6 +1421,7 @@ Для відправки потрібна вхідна транзакція на суму не менше %1$s Недостатньо коштів Підтверджуючи, ви дозволяєте смартконтракту використовувати ваші токени в майбутніх транзакціях. + Фіксований курс Мережа стягує комісію за схвалення токену за підтвердження, що саме ви дозволяєте використовувати ваш токен для обміну. Обмінюйте більше токенів за вигіднішим курсом прямо у своєму гаманці. З\'явився новий провайдер обмінів! @@ -1210,33 +1446,130 @@ Помилка при розрахунку комісії. Будь ласка, надішліть відгук до служби підтримки. Ви обмінюєте Обмін цієї кількості обраних токенів призведе до значного впливу на ціну і зменшить вашу кінцеву суму. + Високий вплив на ціну Недостатньо коштів Надати дозвіл Обміняти Ви отримаєте Оберіть токен недоступно + Будемо раді вашому зворотньому звʼязку + Tangem Pay у режимі beta + Картку заморожено + Оплата карткою + Депозит + Оскаржити + Переглянути транзакцію + Сервісна комісія + Комісія + Зберігайте свої гроші в безпеці. Ви можете розморозити картку у будь-який час. + Заморозити вашу картку? + Не вдалося заморозити картку. Спробуйте пізніше. + Заморозити + Вашу картку заморожено. + Звернутися до підтримки + Інше + Завершено + Відхилено + В очікуванні + Умови, комісії та ліміти + Умови та обмеження + Банк відхилив цей запит на транзакцію. + Ця комісія покриває витрати на обробку вашого переказу. + Продовжуйте користуватися карткою. Заморозити можна в будь-який момент. + Розморозити картку? + Не вдалося розморозити картку. Спробуйте пізніше. + Картку розморожено. + Виведення коштів + Заборонено використовувати на root-пристроях + Скасувати KYC + Поповнити рахунок + Варіанти поповнення Номер картки + Змінити PIN-код + Картка готова до оплат. + PIN-код створено CVC + Не вдалося завантажити дані. Спробуйте пізніше. Термін дії + Заморозити картку Сховати деталі + Приховати + Відкрити Google Wallet + Налаштуйте Tangem Pay в кілька кроків і почніть платити за допомогою Google Pay. + Налаштуйте Tangem Pay в кілька кроків і почніть платити за допомогою Apple Pay. + Додайте картку до Google Pay Додайте свою картку в Apple Pay + Відкрийте Google Wallet Натисніть кнопку \"+\" у верхньому правому куті Відкрити Apple Wallet + Натисніть «Додати картку» Натисніть «Дебетова або кредитна картка» + Введіть дані картки вручну + Підтвердьте картку за допомогою одноразового коду, надісланого на ваш пристрій. Все готово! Ваша картка готова до використання. + Додайте картку до Google Pay Додайте свою картку в Apple Pay + ПІН-код + Поділіться своєю адресою або покажіть QR-код + Виявлено технічні проблеми. Будь ласка, спробуйте пізніше або зверніться до служби підтримки. + Поповнення наразі недоступне + Показати Показати деталі Обміняйте будь-який актив у вашому портфелі на картку + Реквізити картки Розморозити картку + Ваш ПІН + Вивести + Вивід наразі недоступний + Ви не можете розпочати обмін або новий вивід, поки не завершено поточний. + Вивід виконується + Змінити PIN-код + Можна подивитися тут, якщо забудете його. + Я розумію, що повністю втрачу доступ до своєї картки Tangem Pay та всіх коштів на ній без можливості відновлення. + Не вдалося випустити картку + Технічна помилка, спробуйте ще раз, натиснувши кнопку нижче + Технічна помилка, звʼяжіться з підтримки Отримайте безкоштовну віртуальну картку Tangem Visa + Отримати Tangem Pay + Написати у підтримку + Зазвичай це займає до 15 хвилин + Готуємо вашу картку Tangem + Випускаємо картку + Випускаємо картку, це займе деякий час. + Tangem Pay + Підтвердити скасування + Ви впевнені, що хочете зупинити процес KYC? Ви можете повернутися до нього в будь-який момент. + Не вдалося підтвердити ваш профіль. Якщо у вас є питання, зверніться у службу підтримки. + На жаль, нам не вдалося підтвердити вашу особу + KYC в процесі + Переглянути статус + Перевірка KYC для Tangem Pay в процесі + Ви можете переглянути поточний статус KYC або відмінити його Отримайте безкоштовну віртуальну картку Tangem Visa Використовуйте USDC для щоденних платежів + Отримати картку + Віртуальну картку можна додати до Apple Pay та Google Pay + Витрачайте свої активи будь-де + Немає жодних додаткових комісій за покупки + Платіть стільки, скільки бачите + Буде створено окремий платіжний рахунок без розкриття ваших адрес та активів + Неперевершена конфіденційність + Отримайте безкоштовну картку Tangem Pay за лічені хвилини + Платіжний акаунт Платіжний рахунок не синхронізовано + Слабкий ПІН: не використовуйте повторів або послідовностей. Ми усуваємо технічну проблему. Будь ласка, спробуйте пізніше. Сервіс тимчасово недоступний + Не можемо показати дані картки, але оплати продовжують працювати. + Встановіть \nPIN-код + Не синхронізовано + Відновити доступ Використовуйте USDC для щоденних платежів + Tangem Pay тимчасово недоступний + Tangem Pay Натисніть кнопку нижче, щоб відновити доступ + Ваш PIN-код Це мій гаманець Баланси приховано Баланси показано @@ -1265,6 +1598,7 @@ Токен у мережі %%image%% %1$s Токен %1$s (%2$s) є основною валютою в мережі %3$s і не може бути прихований до тих пір, поки у вас в списку є інші токени цієї мережі Неможливо приховати %s + Показати QR-код Обмінюйте цей токен на інші з %1$s комісією за обслуговування з %2$s по %3$s лютого. Обмін із Changelly, %s комісії Обміняти @@ -1279,6 +1613,7 @@ Кілька адрес Історія транзакцій наразі не підтримується для цього блокчейну. Але не хвилюйтеся, ми працюємо над цим! А поки можете перевірити це в оглядачі. Операція + для: %s від: %s до: %s валідатор: %s @@ -1301,6 +1636,7 @@ Ця дія є незворотною. Ви не матимете доступу до старого гаманця. Прикладіть twin-картку з номером %s та не прибирайте її до завершення операції Спробуйте ще раз пізніше. Якщо проблема залишиться, зверніться до служби підтримки. + Щось пішло не так! Виникла помилка. Код помилки: %s. Спробуйте, будь ласка, знову. Якщо проблема знову виникає — зверніться до нашої служби підтримки. Використовуйте %s або відскануйте картку/кільце, щоб отримати доступ до свого гаманця Не вдалося встановити з\'єднання: Цей dApp використовує Wallet Connect версії 1.0, яка не підтримується. Будь ласка, переконайтеся, що dApp підтримує Wallet Connect версії 2.0 для успішного підключення. @@ -1312,6 +1648,8 @@ Ранній доступ до нових функцій та ексклюзивних пропозицій. Нові функції та важливі новини Бажаєте використовувати Push-повідомлення? + Увімкніть push-сповіщення, щоб отримувати повідомлення, коли кошти надходять на ваш гаманець. + Не пропустіть транзакцію Додати новий гаманець Якщо ви видалите цей гаманець без резервної копії, ви назавжди втратите доступ до своїх коштів. Ви впевнені, що хочете видалити цей гаманець? @@ -1322,7 +1660,39 @@ Перейменування гаманця Розблокувати все Розблокувати все з %s + + доступно протягом %1$s дня + доступно протягом %1$s днів + доступно протягом %1$s днів + доступно протягом %1$s днів + + Баланси та ліміти + Код доступу + Активація акаунту + Ви впевнені, що хочете вийти? Ви можете продовжити пізніше з того місця, де зупинилися. + Це не займе багато часу. Ми налаштовуємо ваш акаунт. + Це не займе багато часу. Ми завершуємо активацію. + Встановіть 4-значний код.\nВін буде використаний для платежів. + ПІН-код + Встановити ПІН-код PIN-код не прийнятий. Спробуйте ще раз або введіть інший код. + Слабкий ПІН: не використовуйте повторення або послідовності + Перейти на веб-сайт + Давайте продовжимо налаштування вашого акаунту. + З поверненням! + Виконайте ці кроки, щоб налаштувати свій акаунт. + Вітаємо! + Оскаржити цю транзакцію + Розблокувати + Відскануйте картку, щоб розблокувати доступ + Потрібне розблокування + Оберіть тип гаманця + Відскануйте картку або кільце Tangem, щоб відновити або імпортувати з іншого гаманця. + Створити апаратний гаманець + Хочете придбати гаманець Tangem? + Імпортувати фразу відновлення + Відновіть гаманець на телефоні або імпортуйте з іншого додатку — зручно, але менш безпечно, ніж картка Tangem. + Що обрати? Блокчейн недоступний. Спробуйте пізніше Відскануйте картку або кільце Цей гаманець вже був активований раніше.\nЯкщо це було зроблено не вами, зверніться до служби підтримки.\nTangem ніколи не продає гаманці разом з попередньо згенерованим кодом доступу. @@ -1343,6 +1713,8 @@ Невірна картка або кільце обрана в додатку Tangem Не вдалося створити транзакцію з даних Dapp. Код: %s Ми зіткнулися з невідомою помилкою. Код помилки: %d. Якщо проблема не зникає — зверніться до нашої служби підтримки. + Багатоетапна транзакція + Для успішної обробки вашу транзакцію буде розділено на кілька частин. Потрібно буде кілька разів прикласти картку, щоб завершити її. Немає відкритих сеансів WalletConnect Упс. Немає сеансів. Не вдалося створити пару WalletConnect: %1$s @@ -1354,10 +1726,36 @@ Цю картку не можна використовувати з WalletConnect. Ця мережа не підтримується. Будь ласка, виберіть іншу мережу. Оберіть мережу + Транзакція обробляється. Будь ласка, прикладіть картку кілька разів, щоб завершити її. + Транзакція обробляється Сеанси WalletConnect Підключення до dApps WalletConnect Підключення може зайняти кілька секунд + Створіть гаманець Tangem + Від %s + Купіть Tangem Wallet — фізичний пристрій, який надійно зберігає ваш приватний ключ офлайн. + Апаратний гаманець + Миттєво створіть безпечний гаманець на телефоні + Мобільний гаманець + Що вибрати + Вже користуєтеся Tangem Wallet? + Відсканувати + Виберіть спосіб створення гаманця + Хочете купити гаманець Tangem? + Купити зараз + Відновіть існуючий гаманець за допомогою резервної копії Google Drive + Імпорт з Google Drive + Додати існуючий гаманець + Фізичні пристрої, які надійно зберігають ваш приватний ключ офлайн. + Відскануйте гаманець Tangem + Імпортуйте існуючий гаманець за допомогою сід-фрази. + Імпортувати гаманець + Введіть фразу відновлення + Ви успішно створили резервну копію свого гаманця. + Імпортувати гаманець + Імпорт завершено + Імпортувати гаманець Ринкова ціна %s за 24 години Мережа %s @@ -1366,8 +1764,13 @@ Отримати з 10% знижкою Доступ до 13,000+ криптовалют. Купуйте, продавайте, обмінюйте та стейкайте одним дотиком.\nЗ’єднайте до трьох карток для бекапу. Відкрийте Tangem Wallet + Цей код доступу захищає ваш гаманець і використовується для входу та підписання транзакцій. + Встановити/Змінити код доступу + Змінити код доступу Отримуйте сповіщення про вхідні транзакції в гаманці та оновлення Tangem. + Push-повідомлення наразі можуть не працювати на пристроях Huawei. Ми активно працюємо над розв\'язанням цієї проблеми та плануємо випустити виправлення в наступному оновленні. Дякуємо за розуміння! Сповіщення про транзакції + Встановити код доступу Налаштування гаманця Tangem Використовуйте %s або відскануйте картку/кільце, щоб розблокувати доступ до вашого гаманця @@ -1443,6 +1846,7 @@ Ваш відгук мотивує нас робити гаманець Tangem Wallet ще кращим Подобається Tangem? Вам необхідно провести асоціацію токена, щоб мати можливість приймати його + Ви повинні відкрити трастлайн для свого токена, щоб мати можливість отримати на нього кошти Необхідна плата за оренду мережі Потрібна дія Чи зверталися в службу підтримки через додаток протягом 7 днів після створення гаманця? Якщо зверталися або не впевнені, натисніть «Так» і дотримуйтесь інструкцій. @@ -1468,6 +1872,7 @@ Відкрийте Trustline Потрібна мережа %s не додана до вашого портфеля. Спочатку додайте її, а потім виконайте підключення. Додати мережу до портфеля + Зловмисний домен Невідомий домен Все одно підключитися Помилка тайм-ауту. Будь ласка, спробуйте пізніше. @@ -1479,9 +1884,14 @@ Час очікування запиту вичерпано Будь ласка, поверніться до браузеру і повторно підключіться через WalletConnect. Сеанс Wallet Connect було завершено + Підписати все одно Код помилки: %s. Якщо проблема зберігається, зверніться до нашої служби підтримки. + Якщо проблема не зникає — зверніться до нашої служби підтримки. Ми зіткнулися з невідомою помилкою Tangem Wallet наразі не підтримує %s + Непідтримуваний dApp + Код помилки: 8 005. Якщо проблема не зникає - зверніться до нашої служби підтримки. + Ми помітили невідому помилку Ця мережа %s не підтримується Tangem Wallet і не може бути підключена. Непідтримувана мережа Tangem наразі не підтримує необхідну мережу для %s. @@ -1491,11 +1901,14 @@ Обрана не вірна картка або кільце Схоже, виникла проблема Усі dApps відключені + Дозвіл на використання Підтверджуючи, ви дозволяєте dApp або смартконтракту використовувати токени в майбутніх транзакціях. Адреса Підключення + Завантаження Мережа Мережі + Необмежено Гаманець Підключений додаток Підключені мережі @@ -1509,6 +1922,7 @@ З\'єднання Вміст Копіювати дані + Користувацький ліміт dApp відключено Розʼєднати все Усі сесії dApp буде відключено. Ваш гаманець більше не буде пов’язаний із жодним dApp. @@ -1520,9 +1934,12 @@ Будь ласка, згенеруйте новий URI та спробуйте ще раз Термін для з’єднання минув Прогнозовані зміни + Не вдалося виконати симуляцію транзакції. Будь ласка, дійте з обережністю. + Оцінка не підтримується для %s Пропоновано %s Поповніть баланс, щоб покрити комісію мережі Недостатньо %1$s + Зловмисна транзакція Додайте %s мережі до вашого портфелю для цього гаманця В гаманці не додані необхідні мережі Нове з\'єднання @@ -1541,7 +1958,25 @@ До Запит транзакції Запит транзакції + Необмежена кількість + Переконайтеся, що при кожній спробі з\'єднання використовується новий та унікальний URI + URI вже використовується WalletConnect + Підозріла транзакція + Вже маєте гаманець Tangem? + Тисячі активів + Революційний апаратний гаманець + Швидка доставка + Почніть в один клік + Просто та надійно + Простий у використанні + Створіть апаратний гаманець з Tangem. Тонкий, як банківська картка, надійний, як банківське сховище. + Створіть або імпортуйте програмний гаманець + Створіть або імпортуйте програмний гаманець на своєму телефоні. + Почніть з Мобільним гаманцем + Інші методи + Використовуйте апаратний гаманець Tangem + Дізнатися більше та придбати Відмовитися Ви не завершили резервне копіювання. Бажаєте продовжити? Так, поновити @@ -1592,7 +2027,10 @@ Політика комісій Tangem комісію за обслуговування у розмірі 15% від отриманого доходу. Ваші кошти будуть автоматично переведені у Aave, як тільки мережеві комісії знизяться або ваш баланс досягне мінімально необхідної суми. + Комісії наразі вищі чим зазвичай із-за високої активності на ринку. Ви можете продовжити зараз чи повернутися пізніше, коли комісія знизиться. + Висока мережева комісія Історична прибутковість + Отримуй до %1$s APY на свій баланс Дозвіл для вашого токена в режимі Yield було відкликано. Відкрийте токен, щоб надати дозвіл знову. Потрібний дозвіл для токену Перевірте підключення до мережі @@ -1607,7 +2045,9 @@ Децентралізований та некастодіальний Використовуючи сервіс, ви погоджуєтесь з умовами провайдера %1$s та %2$s Підключити Aave + Підключіть %1$s %% APY\nна ваш баланс Aave %1$s%% • Плаваюча ставка + Динамічна процентна ставка Аave Серед. %s Дохідність за минулий рік @@ -1626,6 +2066,7 @@ Вимкнення цієї опції виведе ваші активи з Aave, отримаєте їх назад у %s у вашому гаманці і зупинить накопичення нагород. Комісію мережі стягує блокчейн при виході з режиму доходності. Завершити заробіток + Обіг Річна прибутковість (APY) APY Відсотки нараховуються автоматично. @@ -1633,15 +2074,20 @@ Режим дохідності Відправка ваших коштів Режим прибутковості + Встановлення контракту режиму дохідності Режим прибутковості увімкнено %1$s постачено в Aave Режим прибутковості вимкнено %1$s виведено з Aave + Режим дохідності ініціалізовано + Режим дохідності знову активовано Постачити в Aave %1$s постачено в Aave + Вивід із Aave Автоматично Внесіть трохи %1$s %2$s, щоб покрити комісію мережі за транзакції. Неможливо покрити комісію в %s Сервіс доходності у даний час недоступний. Будь ласка, спробуйте пізніше. Режим дохідності недоступний + Не вдалося завантажити графік... diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 582d2ca04c..4833e76320 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -539,6 +539,10 @@ ID: %s Transaction ID copied Swap any asset in your portfolio for this token + Higher speed means faster confirmation and a higher network fee. %s + Choose speed + Choose which token to use to pay the network fee. %s + Choose token Market & News Tangem AI Trending Now diff --git a/features/send-v2/impl/build.gradle.kts b/features/send-v2/impl/build.gradle.kts index a4297a69f1..32c849b301 100644 --- a/features/send-v2/impl/build.gradle.kts +++ b/features/send-v2/impl/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { /** Common */ implementation(projects.common.ui) implementation(projects.common.routing) + implementation(projects.common) /** Domain */ implementation(projects.domain.models) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt index 3837c604f8..8b1a940af1 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt @@ -17,7 +17,7 @@ import com.tangem.features.send.v2.api.FeeSelectorComponent import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.params.FeeSelectorParams -import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel +import com.tangem.features.send.v2.feeselector.model.FeeSelectorBlockModel import com.tangem.features.send.v2.feeselector.ui.FeeSelectorBlockContent import com.tangem.utils.extensions.isSingleItem import dagger.assisted.Assisted @@ -35,7 +35,7 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor( private val sendFeatureToggles: SendFeatureToggles, ) : FeeSelectorBlockComponent, AppComponentContext by appComponentContext { - private val model: FeeSelectorModel = getOrCreateModel(params = params) + private val model: FeeSelectorBlockModel = getOrCreateModel(params = params) private val bottomSheetSlot = childSlot( source = model.feeSelectorBottomSheet, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt index aae3d79280..807e95bcb3 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt @@ -3,23 +3,50 @@ package com.tangem.features.send.v2.feeselector import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.essenty.instancekeeper.getOrCreateSimple import com.tangem.core.decompose.context.AppComponentContext +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.features.send.v2.api.FeeSelectorComponent import com.tangem.features.send.v2.api.params.FeeSelectorParams +import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.speed.FeeSpeedSelectorComponent import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel +import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute import com.tangem.features.send.v2.feeselector.ui.FeeSelectorModalBottomSheet import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +@Suppress("PropertyUsedBeforeDeclaration") internal class DefaultFeeSelectorComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: FeeSelectorParams.FeeSelectorDetailsParams, @Assisted private val onDismiss: () -> Unit, + private val feeSpeedSelectorComponentFactory: FeeSpeedSelectorComponent.Factory, ) : FeeSelectorComponent, AppComponentContext by appComponentContext { - private val model: FeeSelectorModel = getOrCreateModel(params = params) + private val stackNavigation = instanceKeeper.getOrCreateSimple { StackNavigation() } + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { model.onChildBack() }, + ) + private val model: FeeSelectorModel = getOrCreateModel(params = params, router = innerRouter) + + private val contentStack = childStack( + key = "FeeSelectorDetailsStack", + source = stackNavigation, + serializer = FeeSelectorRoute.serializer(), + initialConfiguration = model.getInitialRoute(), + handleBackButton = false, + childFactory = ::screenChild, + ) override fun dismiss() { onDismiss() @@ -28,14 +55,38 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor( @Composable override fun BottomSheet() { val state by model.uiState.collectAsStateWithLifecycle() + val contentStack by contentStack.subscribeAsState() + FeeSelectorModalBottomSheet( + childStack = contentStack, onDismiss = ::dismiss, state = state, - feeSelectorIntents = model, + feeSelectorIntents = model.intents, feeDisplaySource = params.feeDisplaySource, ) } + private fun screenChild(config: FeeSelectorRoute, componentContext: ComponentContext): ComposableContentComponent { + val appComponentContext = childByContext( + componentContext = componentContext, + router = innerRouter, + ) + + val componentParams = FeeSelectorComponentParams( + state = model.uiState, + intents = model.intents, + ) + + return when (config) { + FeeSelectorRoute.ChooseSpeed -> feeSpeedSelectorComponentFactory.create( + appComponentContext = appComponentContext, + params = componentParams, + ) + FeeSelectorRoute.ChooseToken -> TODO() + FeeSelectorRoute.NetworkFee -> TODO() + } + } + @AssistedFactory interface Factory : FeeSelectorComponent.Factory { override fun create( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/FeeSelectorComponentParams.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/FeeSelectorComponentParams.kt new file mode 100644 index 0000000000..cddc26216e --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/FeeSelectorComponentParams.kt @@ -0,0 +1,10 @@ +package com.tangem.features.send.v2.feeselector.component + +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents +import kotlinx.coroutines.flow.MutableStateFlow + +internal class FeeSelectorComponentParams( + val state: MutableStateFlow, + val intents: FeeSelectorIntents, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/FeeSpeedSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/FeeSpeedSelectorComponent.kt new file mode 100644 index 0000000000..1735ccd476 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/FeeSpeedSelectorComponent.kt @@ -0,0 +1,44 @@ +package com.tangem.features.send.v2.feeselector.component.speed + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +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.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.speed.model.FeeSpeedSelectorModel +import com.tangem.features.send.v2.feeselector.component.speed.ui.FeeSpeedSelectorContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class FeeSpeedSelectorComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: FeeSelectorComponentParams, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: FeeSpeedSelectorModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + if (state is FeeSelectorUM.Content) { + FeeSpeedSelectorContent( + state = state as FeeSelectorUM.Content, + intents = model, + ) + } + } + + @AssistedFactory + interface Factory { + fun create( + appComponentContext: AppComponentContext, + params: FeeSelectorComponentParams, + ): FeeSpeedSelectorComponent + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/FeeSpeedSelectorIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/FeeSpeedSelectorIntents.kt new file mode 100644 index 0000000000..13a9fb4477 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/FeeSpeedSelectorIntents.kt @@ -0,0 +1,15 @@ +package com.tangem.features.send.v2.feeselector.component.speed + +import androidx.compose.runtime.Stable +import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents +import com.tangem.features.send.v2.feeselector.model.StubFeeSelectorIntents + +@Stable +internal interface FeeSpeedSelectorIntents : FeeSelectorIntents { + fun onLearnMoreClick() +} + +@Stable +internal class StubFeeSpeedSelectorIntents : FeeSpeedSelectorIntents, FeeSelectorIntents by StubFeeSelectorIntents() { + override fun onLearnMoreClick() {} +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/model/FeeSpeedSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/model/FeeSpeedSelectorModel.kt new file mode 100644 index 0000000000..8f42d817fb --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/model/FeeSpeedSelectorModel.kt @@ -0,0 +1,39 @@ +package com.tangem.features.send.v2.feeselector.component.speed.model + +import androidx.compose.runtime.Stable +import com.tangem.common.TangemBlogUrlBuilder +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.navigation.url.UrlOpener +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.speed.FeeSpeedSelectorIntents +import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class FeeSpeedSelectorModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val urlOpener: UrlOpener, +) : Model(), FeeSpeedSelectorIntents, + FeeSelectorIntents by paramsContainer.require().intents { + + private val params = paramsContainer.require() + + val uiState: StateFlow + field = params.state + + override fun onLearnMoreClick() { + modelScope.launch { + urlOpener.openUrl( + TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee), + ) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt new file mode 100644 index 0000000000..87eab44b5c --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt @@ -0,0 +1,513 @@ +package com.tangem.features.send.v2.feeselector.component.speed.ui + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.* +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.amountScreen.utils.getFiatReference +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.components.atoms.text.TextEllipsis +import com.tangem.core.ui.components.inputrow.InputRowEnter +import com.tangem.core.ui.components.inputrow.InputRowEnterInfoAmountV2 +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fee +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.SendSelectNetworkFeeBottomSheetTestTags +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.send.v2.api.entity.* +import com.tangem.features.send.v2.feeselector.component.speed.FeeSpeedSelectorIntents +import com.tangem.features.send.v2.feeselector.component.speed.StubFeeSpeedSelectorIntents +import com.tangem.features.send.v2.impl.R +import com.tangem.utils.StringsSigns +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal + +@Composable +internal fun FeeSpeedSelectorContent( + state: FeeSelectorUM.Content, + intents: FeeSpeedSelectorIntents, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ), + ) { + Title(onLearnMoreClick = intents::onLearnMoreClick) + SpacerH8() + FeeSelectorItems( + state = state, + intents = intents, + ) + } +} + +@Composable +private fun Title(modifier: Modifier = Modifier, onLearnMoreClick: () -> Unit) { + val linkText = stringResourceSafe(R.string.common_learn_more) + val fullString = stringResourceSafe(R.string.fee_selector_choose_speed_description, linkText) + val linkTextPosition = fullString.length - linkText.length + val defaultColor = TangemTheme.colors.text.secondary + val linkColor = TangemTheme.colors.text.accent + val annotatedString = remember(defaultColor, linkColor, onLearnMoreClick) { + buildAnnotatedString { + withStyle(SpanStyle(defaultColor)) { + append(fullString.substring(0, linkTextPosition)) + } + withLink( + link = LinkAnnotation.Clickable( + tag = "learn_more", + linkInteractionListener = { onLearnMoreClick() }, + ), + block = { + withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.None)) { + append( + fullString.substring(linkTextPosition, fullString.length).replace( + ' ', + StringsSigns.NON_BREAKING_SPACE, + ), + ) + } + }, + ) + } + } + + Text( + modifier = modifier.padding(horizontal = 32.dp), + text = annotatedString, + style = TangemTheme.typography.caption2, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun FeeSelectorItems( + state: FeeSelectorUM.Content, + intents: FeeSpeedSelectorIntents, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + val feeFiatRateUM = state.feeFiatRateUM + state.feeItems.fastForEachIndexed { index, item -> + val isSelected = item.isSameClass(state.selectedFeeItem) + val iconTint by animateColorAsState( + targetValue = if (isSelected) TangemTheme.colors.icon.accent else TangemTheme.colors.text.tertiary, + label = "Fee selector icon tint change", + ) + val iconBackgroundColor by animateColorAsState( + targetValue = if (isSelected) { + TangemTheme.colors.icon.accent.copy(alpha = 0.1F) + } else { + TangemTheme.colors.background.secondary + }, + label = "Fee selector icon background change", + ) + val itemModifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + .selectedBorder(isSelected = isSelected) + .clickableSingle(onClick = { intents.onFeeItemSelected(item) }) + .background(TangemTheme.colors.background.action) + when (item) { + is FeeItem.Custom -> CustomFeeBlock( + modifier = itemModifier, + customFee = item, + isSelected = isSelected, + iconBackgroundColor = iconBackgroundColor, + iconTint = iconTint, + onValueChange = intents::onCustomFeeValueChange, + nonce = state.feeNonce, + ) + else -> RegularFeeItemContent( + modifier = itemModifier, + title = item.title, + iconRes = item.iconRes, + iconBackgroundColor = iconBackgroundColor, + iconTint = iconTint, + preDot = stringReference( + item.fee.amount.value.format { + crypto( + symbol = item.fee.amount.currencySymbol, + decimals = item.fee.amount.decimals, + ).fee(canBeLower = state.feeExtraInfo.isFeeApproximate) + }, + ), + postDot = if (state.feeExtraInfo.isFeeConvertibleToFiat && feeFiatRateUM != null) { + getFiatReference( + value = item.fee.amount.value, + rate = feeFiatRateUM.rate, + appCurrency = feeFiatRateUM.appCurrency, + ) + } else { + null + }, + ellipsizeOffset = item.fee.amount.currencySymbol.length, + ) + } + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun CustomFeeBlock( + customFee: FeeItem.Custom, + isSelected: Boolean, + iconBackgroundColor: Color, + iconTint: Color, + onValueChange: (Int, String) -> Unit, + nonce: FeeNonce, + modifier: Modifier = Modifier, +) { + val textMeasurer = rememberTextMeasurer() + val textHeight = with(LocalDensity.current) { + textMeasurer.measure("S", style = TangemTheme.typography.subtitle2).size.height.toDp() + + textMeasurer.measure("S", style = TangemTheme.typography.caption1).size.height.toDp() + 2.dp + } + + Column(modifier = modifier) { + Row( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 15.dp) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_FEE_ITEM), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier + .size(36.dp) + .background(color = iconBackgroundColor, shape = CircleShape) + .padding(6.dp) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_ICON), + painter = painterResource(R.drawable.ic_edit_v2_24), + tint = iconTint, + contentDescription = null, + ) + Box(Modifier.heightIn(min = textHeight)) { + Text( + text = stringResourceSafe(R.string.common_custom), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle2, + modifier = Modifier + .align(Alignment.CenterStart) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_TITLE), + ) + } + } + AnimatedVisibility( + visible = isSelected, + label = "Custom Fee Selected Animation", + enter = expandVertically().plus(fadeIn()), + exit = shrinkVertically().plus(fadeOut()), + ) { + ExpandedCustomFeeItems( + customFeeFields = customFee.customValues, + onValueChange = onValueChange, + nonce = nonce, + ) + } + } +} + +@Composable +private fun ExpandedCustomFeeItems( + customFeeFields: ImmutableList, + onValueChange: (Int, String) -> Unit, + nonce: FeeNonce, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + customFeeFields.fastForEachIndexed { index, field -> + val isShowDivider = index != customFeeFields.size - 1 || nonce is FeeNonce.Nonce + if (field.label != null) { + InputRowEnterInfoAmountV2( + text = field.value, + decimals = field.decimals, + symbol = field.symbol, + title = field.title, + titleColor = TangemTheme.colors.text.tertiary, + info = field.label, + description = field.footer, + keyboardOptions = field.keyboardOptions, + keyboardActions = field.keyboardActions, + onValueChange = { onValueChange(index, it) }, + showDivider = isShowDivider, + isReadOnly = field.isReadonly, + ) + } else { + InputRowEnterInfoAmountV2( + text = field.value, + decimals = field.decimals, + title = field.title, + titleColor = TangemTheme.colors.text.tertiary, + symbol = field.symbol, + description = field.footer, + onValueChange = { onValueChange(index, it) }, + keyboardOptions = field.keyboardOptions, + keyboardActions = field.keyboardActions, + showDivider = isShowDivider, + ) + } + } + + if (nonce is FeeNonce.Nonce) { + InputRowEnter( + text = nonce.nonce?.toString().orEmpty(), + title = resourceReference(R.string.send_nonce), + description = resourceReference(R.string.send_nonce_footer), + onValueChange = nonce.onNonceChange, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + placeholder = resourceReference(R.string.send_nonce_hint), + titleColor = TangemTheme.colors.text.secondary, + showDivider = false, + modifier = Modifier + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.NONCE_INPUT_ITEM), + ) + } + } +} + +@Composable +private fun RegularFeeItemContent( + title: TextReference, + @DrawableRes iconRes: Int, + iconBackgroundColor: Color, + iconTint: Color, + modifier: Modifier = Modifier, + preDot: TextReference? = null, + postDot: TextReference? = null, + ellipsizeOffset: Int? = null, +) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 15.dp) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_FEE_ITEM), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier + .size(36.dp) + .background(color = iconBackgroundColor, shape = CircleShape) + .padding(6.dp) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_ICON), + painter = painterResource(iconRes), + tint = iconTint, + contentDescription = null, + ) + FeeDescription( + title = title, + preDot = preDot, + postDot = postDot, + ellipsizeOffset = ellipsizeOffset, + ) + } + } +} + +@Composable +private fun FeeDescription( + title: TextReference, + modifier: Modifier = Modifier, + preDot: TextReference? = null, + postDot: TextReference? = null, + ellipsizeOffset: Int? = null, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title.resolveReference(), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle2, + modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE), + ) + if (preDot != null) { + FeeValueContent(preDot = preDot, postDot = postDot, ellipsizeOffset = ellipsizeOffset) + } + } +} + +@Composable +private fun FeeValueContent(preDot: TextReference, postDot: TextReference?, ellipsizeOffset: Int? = null) { + val ellipsis = if (ellipsizeOffset == null) { + TextEllipsis.End + } else { + TextEllipsis.OffsetEnd(ellipsizeOffset) + } + val textColor = TangemTheme.colors.text.tertiary + val textStyle = TangemTheme.typography.caption1 + Row { + EllipsisText( + text = preDot.resolveReference(), + style = textStyle, + color = textColor, + textAlign = TextAlign.End, + ellipsis = ellipsis, + modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.TOKEN_AMOUNT), + ) + if (postDot != null) { + Text( + text = StringsSigns.DOT, + style = textStyle, + color = textColor, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing4) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.DOT_SIGN), + ) + Text( + text = postDot.resolveReference(), + style = textStyle, + color = textColor, + modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.FIAT_AMOUNT), + ) + } + } +} + +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO) +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview( + @PreviewParameter(FeeSpeedSelectorUMContentProvider::class) + state: FeeSelectorUM.Content, +) { + TangemThemePreview { + Box(Modifier.background(TangemTheme.colors.background.secondary)) { + FeeSpeedSelectorContent( + state = state, + intents = StubFeeSpeedSelectorIntents(), + ) + } + } +} + +private class FeeSpeedSelectorUMContentProvider : CollectionPreviewParameterProvider( + collection = listOf( + FeeSelectorUM.Content( + isPrimaryButtonEnabled = false, + feeItems = persistentListOf( + FeeItem.Suggested( + title = resourceReference( + id = R.string.wc_fee_suggested, + formatArgs = wrappedList("Tangem"), + ), + fee = Fee.Common(Amount(value = BigDecimal("0.1"), blockchain = Blockchain.Ethereum)), + ), + FeeItem.Slow(fee = Fee.Common(Amount(value = BigDecimal("0.01"), blockchain = Blockchain.Ethereum))), + FeeItem.Market( + fee = Fee.Common( + Amount( + value = BigDecimal("0.02"), + blockchain = Blockchain.Ethereum, + ), + ), + ), + FeeItem.Fast(fee = Fee.Common(Amount(value = BigDecimal("0.03"), blockchain = Blockchain.Ethereum))), + customFeeItem, + ), + selectedFeeItem = FeeItem.Slow( + fee = Fee.Common( + Amount( + value = BigDecimal("0.01"), + blockchain = Blockchain.Ethereum, + ), + ), + ), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = true, + isFeeConvertibleToFiat = true, + isTronToken = false, + ), + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal.TEN, + appCurrency = AppCurrency.Default, + ), + feeNonce = FeeNonce.None, + fees = TransactionFee.Single(customFeeItem.fee), + ), + ), +) + +private val customFeeItem = FeeItem.Custom( + fee = Fee.Common(Amount(value = BigDecimal("0.05"), blockchain = Blockchain.Ethereum)), + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.119806", + onValueChange = {}, + keyboardOptions = KeyboardOptions(), + keyboardActions = KeyboardActions(), + symbol = "ETH", + decimals = 8, + title = resourceReference(R.string.send_max_fee), + footer = resourceReference(R.string.send_custom_amount_fee_footer), + label = stringReference("~ 0,03 \$"), + isReadonly = false, + ), + CustomFeeFieldUM( + value = "40000", + onValueChange = {}, + keyboardOptions = KeyboardOptions(), + keyboardActions = KeyboardActions(), + symbol = "GWEI", + decimals = 8, + title = resourceReference(R.string.send_gas_price), + footer = resourceReference(R.string.send_gas_price_footer), + label = null, + isReadonly = false, + ), + CustomFeeFieldUM( + value = "31400", + onValueChange = {}, + keyboardOptions = KeyboardOptions(), + keyboardActions = KeyboardActions(), + symbol = null, + decimals = 8, + title = resourceReference(R.string.send_gas_limit), + footer = resourceReference(R.string.send_gas_limit_footer), + label = null, + isReadonly = false, + ), + ), +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt index 9afa349960..295b4f178e 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt @@ -2,6 +2,8 @@ package com.tangem.features.send.v2.feeselector.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model +import com.tangem.features.send.v2.feeselector.model.FeeSelectorBlockModel +import com.tangem.features.send.v2.feeselector.component.speed.model.FeeSpeedSelectorModel import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel import dagger.Binds import dagger.Module @@ -16,4 +18,14 @@ internal interface FeeSelectorModelModule { @IntoMap @ClassKey(FeeSelectorModel::class) fun bindModel(model: FeeSelectorModel): Model + + @Binds + @IntoMap + @ClassKey(FeeSelectorBlockModel::class) + fun bindBlockModel(model: FeeSelectorBlockModel): Model + + @Binds + @IntoMap + @ClassKey(FeeSpeedSelectorModel::class) + fun bindFeeSelectorSpeedModel(model: FeeSpeedSelectorModel): Model } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorBlockModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorBlockModel.kt new file mode 100644 index 0000000000..e8f6e511c9 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorBlockModel.kt @@ -0,0 +1,78 @@ +package com.tangem.features.send.v2.feeselector.model + +import androidx.compose.runtime.Stable +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.TangemBlogUrlBuilder +import com.tangem.core.analytics.api.AnalyticsEventHandler +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.navigation.url.UrlOpener +import com.tangem.domain.settings.NeverShowTapHelpUseCase +import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.SendScreenSource +import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.api.params.FeeSelectorParams +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class FeeSelectorBlockModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val feeSelectorLogicFactory: FeeSelectorLogic.Factory, + private val analyticsEventHandler: AnalyticsEventHandler, + private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase, + private val urlOpener: UrlOpener, +) : Model(), FeeSelectorModelCallback { + + private val params = paramsContainer.require() + private val feeSelectorLogic = feeSelectorLogicFactory.create( + params = params, + modelScope = modelScope, + ) + + val feeSelectorBottomSheet = SlotNavigation() + val uiState: StateFlow + field = feeSelectorLogic.uiState + + override fun onFeeResult(feeSelectorUM: FeeSelectorUM) { + uiState.value = feeSelectorUM + feeSelectorBottomSheet.dismiss() + } + + fun updateState(feeSelectorUM: FeeSelectorUM) { + uiState.value = feeSelectorUM + } + + fun onReadMoreClicked() { + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee)) + } + } + + fun showFeeSelector() { + analyticsEventHandler.send( + CommonSendAnalyticEvents.FeeScreenOpened( + categoryName = params.analyticsCategoryName, + source = params.analyticsSendSource, + ), + ) + analyticsEventHandler.send( + CommonSendAnalyticEvents.ScreenReopened( + categoryName = params.analyticsCategoryName, + source = SendScreenSource.Fee, + ), + ) + modelScope.launch { + neverShowTapHelpUseCase() + feeSelectorBottomSheet.activate(Unit) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorIntents.kt index d35aa6784b..ad255feda4 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorIntents.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorIntents.kt @@ -1,7 +1,9 @@ package com.tangem.features.send.v2.feeselector.model +import androidx.compose.runtime.Stable import com.tangem.features.send.v2.api.entity.FeeItem +@Stable internal interface FeeSelectorIntents { fun onFeeItemSelected(feeItem: FeeItem) fun onCustomFeeValueChange(index: Int, value: String) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt new file mode 100644 index 0000000000..fd8f950d3e --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt @@ -0,0 +1,202 @@ +package com.tangem.features.send.v2.feeselector.model + +import arrow.core.getOrElse +import com.tangem.blockchain.common.AmountType +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase +import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.NonceInserted +import com.tangem.features.send.v2.api.entity.FeeItem +import com.tangem.features.send.v2.api.entity.FeeNonce +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.api.params.FeeSelectorParams +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadListener +import com.tangem.features.send.v2.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents +import com.tangem.features.send.v2.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents.GasPriceInserter +import com.tangem.features.send.v2.feeselector.model.transformers.* +import com.tangem.utils.transformer.update +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +internal class FeeSelectorLogic @AssistedInject constructor( + @Assisted private val params: FeeSelectorParams, + @Assisted private val modelScope: CoroutineScope, + private val isFeeApproximateUseCase: IsFeeApproximateUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val feeSelectorReloadListener: FeeSelectorReloadListener, + private val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener, + private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger, + private val feeSelectorAlertFactory: FeeSelectorAlertFactory, + private val analyticsEventHandler: AnalyticsEventHandler, +) : FeeSelectorIntents { + + private var appCurrency: AppCurrency = AppCurrency.Default + val uiState = MutableStateFlow(params.state) + + init { + initAppCurrency() + subscribeOnFeeReloadTriggerUpdates() + subscribeOnFeeCheckReloadTriggerUpdates() + subscribeOnFeeLoadingStateTriggerUpdates() + loadFee() + } + + fun updateState(feeSelectorUM: FeeSelectorUM) { + uiState.value = feeSelectorUM + } + + private fun initAppCurrency() { + modelScope.launch { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + } + } + + private fun loadFee() { + if (uiState.value !is FeeSelectorUM.Content) { + uiState.update(FeeSelectorLoadingTransformer) + } + modelScope.launch { + params.onLoadFee() + .fold( + ifLeft = { error -> uiState.update(FeeSelectorErrorTransformer(error)) }, + ifRight = { fee -> + uiState.update( + FeeSelectorLoadedTransformer( + cryptoCurrencyStatus = params.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, + appCurrency = appCurrency, + fees = fee, + feeStateConfiguration = params.feeStateConfiguration, + isFeeApproximate = isFeeApproximate(fee.normal.amount.type), + feeSelectorIntents = this@FeeSelectorLogic, + ), + ) + }, + ) + } + } + + private fun isFeeApproximate(amountType: AmountType): Boolean { + val networkId = params.feeCryptoCurrencyStatus.currency.network.id + return isFeeApproximateUseCase(networkId = networkId, amountType = amountType) + } + + override fun onFeeItemSelected(feeItem: FeeItem) { + if (feeItem is FeeItem.Custom) { + analyticsEventHandler.send( + CommonSendFeeAnalyticEvents.CustomFeeButtonClicked(categoryName = params.analyticsCategoryName), + ) + } + uiState.update(FeeItemSelectedTransformer(feeItem)) + if (feeItem !is FeeItem.Custom) { + onDoneClick() + } + } + + override fun onCustomFeeValueChange(index: Int, value: String) { + uiState.update( + FeeSelectorCustomValueChangedTransformer( + index = index, + value = value, + intents = this, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, + ), + ) + } + + override fun onNonceChange(value: String) { + uiState.update(FeeSelectorNonceChangeTransformer(value = value)) + } + + override fun onDoneClick() { + val feeSelectorUM = uiState.value as? FeeSelectorUM.Content ?: return + analyticsEventHandler.send( + CommonSendFeeAnalyticEvents.SelectedFee( + categoryName = params.analyticsCategoryName, + feeType = feeSelectorUM.toAnalyticType(), + source = params.analyticsSendSource, + ), + ) + val isCustomFeeEdited = feeSelectorUM.selectedFeeItem.fee.amount.value != feeSelectorUM.fees.normal.amount.value + if (feeSelectorUM.selectedFeeItem is FeeItem.Custom && isCustomFeeEdited) { + analyticsEventHandler.send(GasPriceInserter(categoryName = params.analyticsCategoryName)) + } + if (feeSelectorUM.feeNonce is FeeNonce.Nonce) { + analyticsEventHandler.send( + NonceInserted( + categoryName = params.analyticsCategoryName, + token = params.feeCryptoCurrencyStatus.currency.symbol, + blockchain = params.feeCryptoCurrencyStatus.currency.network.name, + ), + ) + } + + (params as? FeeSelectorParams.FeeSelectorDetailsParams)?.callback?.onFeeResult(uiState.value) + } + + private fun subscribeOnFeeReloadTriggerUpdates() { + feeSelectorReloadListener.reloadTriggerFlow + .onEach { data -> + if (data.isRemoveSuggestedFee) { + uiState.update(FeeSelectorRemoveSuggestedTransformer) + } + loadFee() + } + .launchIn(modelScope) + } + + private fun subscribeOnFeeLoadingStateTriggerUpdates() { + feeSelectorReloadListener.loadingStateTriggerFlow + .onEach { uiState.update(FeeSelectorLoadingTransformer) } + .launchIn(modelScope) + } + + private fun subscribeOnFeeCheckReloadTriggerUpdates() { + feeSelectorCheckReloadListener.checkReloadTriggerFlow + .onEach { checkLoadFee() } + .launchIn(modelScope) + } + + private fun checkLoadFee() { + modelScope.launch { + params.onLoadFee().fold( + ifRight = { newFee -> + feeSelectorAlertFactory.getFeeUpdatedAlert( + newTransactionFee = newFee, + feeSelectorUM = uiState.value, + proceedAction = { + modelScope.launch { + feeSelectorCheckReloadTrigger.callbackCheckResult(true) + } + }, + stopAction = { + modelScope.launch { + feeSelectorCheckReloadTrigger.callbackCheckResult(false) + } + }, + ) + }, + ifLeft = { feeError -> + feeSelectorCheckReloadTrigger.callbackCheckResult(false) + feeSelectorAlertFactory.getFeeUnreachableErrorState(::loadFee) + }, + ) + } + } + + @AssistedFactory + interface Factory { + fun create(params: FeeSelectorParams, modelScope: CoroutineScope): FeeSelectorLogic + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt index c290cc9fb3..706881dbe0 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt @@ -1,246 +1,43 @@ package com.tangem.features.send.v2.feeselector.model import androidx.compose.runtime.Stable -import arrow.core.getOrElse -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.AmountType -import com.tangem.core.analytics.api.AnalyticsEventHandler 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.navigation.url.UrlOpener -import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.settings.NeverShowTapHelpUseCase -import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase -import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.NonceInserted -import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.SendScreenSource -import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback -import com.tangem.features.send.v2.api.entity.FeeItem -import com.tangem.features.send.v2.api.entity.FeeNonce -import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.send.v2.api.params.FeeSelectorParams -import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener -import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger -import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadListener -import com.tangem.features.send.v2.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents -import com.tangem.features.send.v2.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents.GasPriceInserter -import com.tangem.features.send.v2.feeselector.model.transformers.* -import com.tangem.utils.TangemBlogUrlBuilder +import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.transformer.update -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import javax.inject.Inject -@Suppress("LongParameterList") @Stable @ModelScoped internal class FeeSelectorModel @Inject constructor( paramsContainer: ParamsContainer, - private val urlOpener: UrlOpener, - private val isFeeApproximateUseCase: IsFeeApproximateUseCase, - private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, - private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase, - private val feeSelectorReloadListener: FeeSelectorReloadListener, - private val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener, - private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger, - private val feeSelectorAlertFactory: FeeSelectorAlertFactory, override val dispatchers: CoroutineDispatcherProvider, - private val analyticsEventHandler: AnalyticsEventHandler, -) : Model(), FeeSelectorIntents, FeeSelectorModelCallback { + private val feeSelectorLogicFactory: FeeSelectorLogic.Factory, + private val sendFeatureToggles: SendFeatureToggles, +) : Model() { private val params = paramsContainer.require() - private var appCurrency: AppCurrency = AppCurrency.Default + private val feeSelectorLogic = feeSelectorLogicFactory.create( + params = params, + modelScope = modelScope, + ) - val feeSelectorBottomSheet = SlotNavigation() + val uiState = feeSelectorLogic.uiState + val intents: FeeSelectorIntents = feeSelectorLogic - val uiState: StateFlow - field = MutableStateFlow(params.state) - - init { - initAppCurrency() - subscribeOnFeeReloadTriggerUpdates() - subscribeOnFeeCheckReloadTriggerUpdates() - subscribeOnFeeLoadingStateTriggerUpdates() - loadFee() + fun onChildBack() { + // TODO("Not yet implemented") } - fun updateState(feeSelectorUM: FeeSelectorUM) { - uiState.value = feeSelectorUM - } - - fun onReadMoreClicked() { - urlOpener.openUrl(TangemBlogUrlBuilder.FEE_BLOG_LINK) - } - - private fun initAppCurrency() { - modelScope.launch { - appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } - } - } - - private fun loadFee() { - if (uiState.value !is FeeSelectorUM.Content) { - uiState.update(FeeSelectorLoadingTransformer) - } - modelScope.launch { - params.onLoadFee() - .fold( - ifLeft = { error -> uiState.update(FeeSelectorErrorTransformer(error)) }, - ifRight = { fee -> - uiState.update( - FeeSelectorLoadedTransformer( - cryptoCurrencyStatus = params.cryptoCurrencyStatus, - feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, - appCurrency = appCurrency, - fees = fee, - feeStateConfiguration = params.feeStateConfiguration, - isFeeApproximate = isFeeApproximate(fee.normal.amount.type), - feeSelectorIntents = this@FeeSelectorModel, - ), - ) - }, - ) - } - } - - private fun isFeeApproximate(amountType: AmountType): Boolean { - val networkId = params.feeCryptoCurrencyStatus.currency.network.id - return isFeeApproximateUseCase(networkId = networkId, amountType = amountType) - } - - override fun onFeeItemSelected(feeItem: FeeItem) { - if (feeItem is FeeItem.Custom) { - analyticsEventHandler.send( - CommonSendFeeAnalyticEvents.CustomFeeButtonClicked(categoryName = params.analyticsCategoryName), - ) - } - uiState.update(FeeItemSelectedTransformer(feeItem)) - if (feeItem !is FeeItem.Custom) { - onDoneClick() - } - } - - override fun onCustomFeeValueChange(index: Int, value: String) { - uiState.update( - FeeSelectorCustomValueChangedTransformer( - index = index, - value = value, - intents = this, - appCurrency = appCurrency, - feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, - ), - ) - } - - override fun onNonceChange(value: String) { - uiState.update(FeeSelectorNonceChangeTransformer(value = value)) - } - - override fun onDoneClick() { - val feeSelectorUM = uiState.value as? FeeSelectorUM.Content ?: return - analyticsEventHandler.send( - CommonSendFeeAnalyticEvents.SelectedFee( - categoryName = params.analyticsCategoryName, - feeType = feeSelectorUM.toAnalyticType(), - source = params.analyticsSendSource, - ), - ) - val isCustomFeeEdited = feeSelectorUM.selectedFeeItem.fee.amount.value != feeSelectorUM.fees.normal.amount.value - if (feeSelectorUM.selectedFeeItem is FeeItem.Custom && isCustomFeeEdited) { - analyticsEventHandler.send(GasPriceInserter(categoryName = params.analyticsCategoryName)) - } - if (feeSelectorUM.feeNonce is FeeNonce.Nonce) { - analyticsEventHandler.send( - NonceInserted( - categoryName = params.analyticsCategoryName, - token = params.feeCryptoCurrencyStatus.currency.symbol, - blockchain = params.feeCryptoCurrencyStatus.currency.network.name, - ), - ) + fun getInitialRoute(): FeeSelectorRoute { + if (sendFeatureToggles.isGaslessTransactionsEnabled.not()) { + return FeeSelectorRoute.ChooseSpeed } - (params as? FeeSelectorParams.FeeSelectorDetailsParams)?.callback?.onFeeResult(uiState.value) - } - - override fun onFeeResult(feeSelectorUM: FeeSelectorUM) { - uiState.value = feeSelectorUM - feeSelectorBottomSheet.dismiss() - } - - fun showFeeSelector() { - analyticsEventHandler.send( - CommonSendAnalyticEvents.FeeScreenOpened( - categoryName = params.analyticsCategoryName, - source = params.analyticsSendSource, - ), - ) - analyticsEventHandler.send( - CommonSendAnalyticEvents.ScreenReopened( - categoryName = params.analyticsCategoryName, - source = SendScreenSource.Fee, - ), - ) - modelScope.launch { - neverShowTapHelpUseCase() - feeSelectorBottomSheet.activate(Unit) - } - } - - private fun subscribeOnFeeReloadTriggerUpdates() { - feeSelectorReloadListener.reloadTriggerFlow - .onEach { data -> - if (data.isRemoveSuggestedFee) { - uiState.update(FeeSelectorRemoveSuggestedTransformer) - } - loadFee() - } - .launchIn(modelScope) - } - - private fun subscribeOnFeeLoadingStateTriggerUpdates() { - feeSelectorReloadListener.loadingStateTriggerFlow - .onEach { uiState.update(FeeSelectorLoadingTransformer) } - .launchIn(modelScope) - } - - private fun subscribeOnFeeCheckReloadTriggerUpdates() { - feeSelectorCheckReloadListener.checkReloadTriggerFlow - .onEach { checkLoadFee() } - .launchIn(modelScope) - } - - private fun checkLoadFee() { - modelScope.launch { - params.onLoadFee().fold( - ifRight = { newFee -> - feeSelectorAlertFactory.getFeeUpdatedAlert( - newTransactionFee = newFee, - feeSelectorUM = uiState.value, - proceedAction = { - modelScope.launch { - feeSelectorCheckReloadTrigger.callbackCheckResult(true) - } - }, - stopAction = { - modelScope.launch { - feeSelectorCheckReloadTrigger.callbackCheckResult(false) - } - }, - ) - }, - ifLeft = { feeError -> - feeSelectorCheckReloadTrigger.callbackCheckResult(false) - feeSelectorAlertFactory.getFeeUnreachableErrorState(::loadFee) - }, - ) - } + // TODO: Add logic to choose initial route based on other conditions + return FeeSelectorRoute.ChooseSpeed } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/route/FeeSelectorRoute.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/route/FeeSelectorRoute.kt new file mode 100644 index 0000000000..f122f8fed0 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/route/FeeSelectorRoute.kt @@ -0,0 +1,30 @@ +package com.tangem.features.send.v2.feeselector.route + +import androidx.compose.runtime.Immutable +import com.tangem.core.decompose.navigation.Route +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.send.v2.impl.R +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +internal sealed interface FeeSelectorRoute : Route { + + val title: TextReference + + @Serializable + data object NetworkFee : FeeSelectorRoute { + override val title: TextReference = resourceReference(R.string.common_fee_selector_title) + } + + @Serializable + data object ChooseSpeed : FeeSelectorRoute { + override val title: TextReference = resourceReference(R.string.fee_selector_choose_speed_title) + } + + @Serializable + data object ChooseToken : FeeSelectorRoute { + override val title: TextReference = resourceReference(R.string.fee_selector_choose_token_title) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt index b115e87f71..7bf68aab96 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt @@ -1,65 +1,35 @@ package com.tangem.features.send.v2.feeselector.ui -import android.content.res.Configuration -import androidx.annotation.DrawableRes -import androidx.compose.animation.* -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.Text +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.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastForEachIndexed -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.common.ui.amountScreen.utils.getFiatReference +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.components.PrimaryButton -import com.tangem.core.ui.components.atoms.text.EllipsisText -import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter -import com.tangem.core.ui.components.inputrow.InputRowEnter -import com.tangem.core.ui.components.inputrow.InputRowEnterInfoAmountV2 -import com.tangem.core.ui.extensions.* -import com.tangem.core.ui.format.bigdecimal.crypto -import com.tangem.core.ui.format.bigdecimal.fee -import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.SendSelectNetworkFeeBottomSheetTestTags -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.features.send.v2.api.entity.* +import com.tangem.features.send.v2.api.entity.FeeItem +import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.params.FeeSelectorParams import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents -import com.tangem.features.send.v2.feeselector.model.StubFeeSelectorIntents +import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute import com.tangem.features.send.v2.impl.R -import com.tangem.utils.StringsSigns -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import java.math.BigDecimal @Composable internal fun FeeSelectorModalBottomSheet( + childStack: ChildStack, state: FeeSelectorUM, feeSelectorIntents: FeeSelectorIntents, feeDisplaySource: FeeSelectorParams.FeeDisplaySource, @@ -73,16 +43,12 @@ internal fun FeeSelectorModalBottomSheet( onDismissRequest = onDismiss, content = TangemBottomSheetConfigContent.Empty, ), - containerColor = TangemTheme.colors.background.primary, + containerColor = TangemTheme.colors.background.tertiary, title = { - FeeTitle(feeDisplaySource = feeDisplaySource, onDismiss = onDismiss) + FeeTitle(childStack = childStack, feeDisplaySource = feeDisplaySource, onDismiss = onDismiss) }, content = { - FeeSelectorItems( - state = state, - feeSelectorIntents = feeSelectorIntents, - modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp), - ) + Content(childStack = childStack) }, footer = if (state.selectedFeeItem is FeeItem.Custom) { { @@ -102,414 +68,46 @@ internal fun FeeSelectorModalBottomSheet( } @Composable -private fun FeeTitle(feeDisplaySource: FeeSelectorParams.FeeDisplaySource, onDismiss: () -> Unit) { - when (feeDisplaySource) { - FeeSelectorParams.FeeDisplaySource.Screen -> { - TangemModalBottomSheetTitle( - title = resourceReference(R.string.common_network_fee_title), - endIconRes = R.drawable.ic_close_24, - onEndClick = onDismiss, - ) - } - FeeSelectorParams.FeeDisplaySource.BottomSheet -> { - TangemModalBottomSheetTitle( - title = resourceReference(R.string.common_network_fee_title), - startIconRes = R.drawable.ic_back_24, - onStartClick = onDismiss, - ) - } - } -} - -@Composable -private fun FeeSelectorItems( - state: FeeSelectorUM.Content, - feeSelectorIntents: FeeSelectorIntents, - modifier: Modifier = Modifier, +private fun FeeTitle( + childStack: ChildStack, + feeDisplaySource: FeeSelectorParams.FeeDisplaySource, + onDismiss: () -> Unit, ) { - Column(modifier = modifier) { - val feeFiatRateUM = state.feeFiatRateUM - state.feeItems.fastForEachIndexed { index, item -> - val isSelected = item.isSameClass(state.selectedFeeItem) - val isLastItem = index == state.feeItems.size - 1 - val iconTint by animateColorAsState( - targetValue = if (isSelected) TangemTheme.colors.icon.accent else TangemTheme.colors.text.tertiary, - label = "Fee selector icon tint change", - ) - val iconBackgroundColor by animateColorAsState( - targetValue = if (isSelected) { - TangemTheme.colors.icon.accent.copy(alpha = 0.1F) - } else { - TangemTheme.colors.background.secondary - }, - label = "Fee selector icon background change", - ) - val itemModifier = Modifier - .fillMaxWidth() - .selectedBorder(isSelected = isSelected) - .clickableSingle(onClick = { feeSelectorIntents.onFeeItemSelected(item) }) - when (item) { - is FeeItem.Custom -> CustomFeeBlock( - modifier = itemModifier, - customFee = item, - isSelected = isSelected, - iconBackgroundColor = iconBackgroundColor, - iconTint = iconTint, - onValueChange = feeSelectorIntents::onCustomFeeValueChange, - nonce = state.feeNonce, + Children( + stack = childStack, + animation = stackAnimation(fade(animationSpec = tween(durationMillis = 100))), + ) { current -> + when (feeDisplaySource) { + FeeSelectorParams.FeeDisplaySource.Screen -> { + TangemModalBottomSheetTitle( + title = current.configuration.title, + endIconRes = R.drawable.ic_close_24, + onEndClick = onDismiss, ) - else -> RegularFeeItemContent( - modifier = itemModifier, - title = item.title, - iconRes = item.iconRes, - iconBackgroundColor = iconBackgroundColor, - iconTint = iconTint, - preDot = stringReference( - item.fee.amount.value.format { - crypto( - symbol = item.fee.amount.currencySymbol, - decimals = item.fee.amount.decimals, - ).fee(canBeLower = state.feeExtraInfo.isFeeApproximate) - }, - ), - postDot = if (state.feeExtraInfo.isFeeConvertibleToFiat && feeFiatRateUM != null) { - getFiatReference( - value = item.fee.amount.value, - rate = feeFiatRateUM.rate, - appCurrency = feeFiatRateUM.appCurrency, - ) - } else { - null - }, - ellipsizeOffset = item.fee.amount.currencySymbol.length, - showDivider = !isSelected && !isLastItem, + } + FeeSelectorParams.FeeDisplaySource.BottomSheet -> { + TangemModalBottomSheetTitle( + title = current.configuration.title, + startIconRes = R.drawable.ic_back_24, + onStartClick = onDismiss, ) } } } } -@Suppress("LongParameterList") @Composable -private fun CustomFeeBlock( - customFee: FeeItem.Custom, - isSelected: Boolean, - iconBackgroundColor: Color, - iconTint: Color, - onValueChange: (Int, String) -> Unit, - nonce: FeeNonce, +private fun Content( + childStack: ChildStack, modifier: Modifier = Modifier, ) { - Column(modifier = modifier) { - Row( - modifier = Modifier - .padding(all = 12.dp) - .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_FEE_ITEM), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - modifier = Modifier - .size(36.dp) - .background(color = iconBackgroundColor, shape = CircleShape) - .padding(6.dp) - .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_ICON), - painter = painterResource(R.drawable.ic_edit_v2_24), - tint = iconTint, - contentDescription = null, - ) - Text( - text = stringResourceSafe(R.string.common_custom), - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.subtitle2, - modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_TITLE), - ) - } - AnimatedVisibility( - visible = isSelected, - label = "Custom Fee Selected Animation", - enter = expandVertically().plus(fadeIn()), - exit = shrinkVertically().plus(fadeOut()), - ) { - ExpandedCustomFeeItems( - customFeeFields = customFee.customValues, - onValueChange = onValueChange, - nonce = nonce, - ) - } + Children( + stack = childStack, + animation = stackAnimation(fade(animationSpec = tween(durationMillis = 100))), + modifier = modifier + .fillMaxSize() + .animateContentSize(), + ) { + it.instance.Content(Modifier.fillMaxSize()) } -} - -@Composable -private fun ExpandedCustomFeeItems( - customFeeFields: ImmutableList, - onValueChange: (Int, String) -> Unit, - nonce: FeeNonce, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier) { - customFeeFields.fastForEachIndexed { index, field -> - val isShowDivider = index != customFeeFields.size - 1 || nonce is FeeNonce.Nonce - if (field.label != null) { - InputRowEnterInfoAmountV2( - text = field.value, - decimals = field.decimals, - symbol = field.symbol, - title = field.title, - titleColor = TangemTheme.colors.text.tertiary, - info = field.label, - description = field.footer, - keyboardOptions = field.keyboardOptions, - keyboardActions = field.keyboardActions, - onValueChange = { onValueChange(index, it) }, - showDivider = isShowDivider, - isReadOnly = field.isReadonly, - ) - } else { - InputRowEnterInfoAmountV2( - text = field.value, - decimals = field.decimals, - title = field.title, - titleColor = TangemTheme.colors.text.tertiary, - symbol = field.symbol, - description = field.footer, - onValueChange = { onValueChange(index, it) }, - keyboardOptions = field.keyboardOptions, - keyboardActions = field.keyboardActions, - showDivider = isShowDivider, - ) - } - } - - if (nonce is FeeNonce.Nonce) { - InputRowEnter( - text = nonce.nonce?.toString().orEmpty(), - title = resourceReference(R.string.send_nonce), - description = resourceReference(R.string.send_nonce_footer), - onValueChange = nonce.onNonceChange, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - placeholder = resourceReference(R.string.send_nonce_hint), - titleColor = TangemTheme.colors.text.secondary, - showDivider = false, - modifier = Modifier - .background( - color = TangemTheme.colors.background.primary, - shape = TangemTheme.shapes.roundedCornersXMedium, - ) - .testTag(SendSelectNetworkFeeBottomSheetTestTags.NONCE_INPUT_ITEM), - ) - } - } -} - -@Composable -private fun RegularFeeItemContent( - title: TextReference, - @DrawableRes iconRes: Int, - iconBackgroundColor: Color, - iconTint: Color, - modifier: Modifier = Modifier, - preDot: TextReference? = null, - postDot: TextReference? = null, - ellipsizeOffset: Int? = null, - showDivider: Boolean = true, -) { - Column(modifier = modifier) { - Row( - modifier = Modifier - .padding(all = 12.dp) - .testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_FEE_ITEM), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - modifier = Modifier - .size(36.dp) - .background(color = iconBackgroundColor, shape = CircleShape) - .padding(6.dp) - .testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_ICON), - painter = painterResource(iconRes), - tint = iconTint, - contentDescription = null, - ) - FeeDescription( - title = title, - preDot = preDot, - postDot = postDot, - ellipsizeOffset = ellipsizeOffset, - ) - } - if (showDivider) { - HorizontalDivider( - modifier = Modifier.padding(start = 60.dp, end = 12.dp), - color = TangemTheme.colors.stroke.primary, - thickness = 0.5.dp, - ) - } - } -} - -@Composable -private fun FeeDescription( - title: TextReference, - modifier: Modifier = Modifier, - preDot: TextReference? = null, - postDot: TextReference? = null, - ellipsizeOffset: Int? = null, -) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = title.resolveReference(), - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.subtitle2, - modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE), - ) - if (preDot != null) { - FeeValueContent(preDot = preDot, postDot = postDot, ellipsizeOffset = ellipsizeOffset) - } - } -} - -@Composable -private fun FeeValueContent(preDot: TextReference, postDot: TextReference?, ellipsizeOffset: Int? = null) { - val ellipsis = if (ellipsizeOffset == null) { - TextEllipsis.End - } else { - TextEllipsis.OffsetEnd(ellipsizeOffset) - } - val textColor = TangemTheme.colors.text.tertiary - val textStyle = TangemTheme.typography.caption1 - Row { - EllipsisText( - text = preDot.resolveReference(), - style = textStyle, - color = textColor, - textAlign = TextAlign.End, - ellipsis = ellipsis, - modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.TOKEN_AMOUNT), - ) - if (postDot != null) { - Text( - text = StringsSigns.DOT, - style = textStyle, - color = textColor, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing4) - .testTag(SendSelectNetworkFeeBottomSheetTestTags.DOT_SIGN), - ) - Text( - text = postDot.resolveReference(), - style = textStyle, - color = textColor, - modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.FIAT_AMOUNT), - ) - } - } -} - -@Preview(showBackground = true, device = Devices.PIXEL_7_PRO) -@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun FeeSelectorBS_Preview( - @PreviewParameter(FeeSelectorUMContentProvider::class) - state: FeeSelectorUM.Content, -) { - TangemThemePreview { - FeeSelectorModalBottomSheet( - onDismiss = {}, - state = state, - feeSelectorIntents = StubFeeSelectorIntents(), - feeDisplaySource = FeeSelectorParams.FeeDisplaySource.BottomSheet, - ) - } -} - -private class FeeSelectorUMContentProvider : CollectionPreviewParameterProvider( - collection = listOf( - FeeSelectorUM.Content( - isPrimaryButtonEnabled = false, - feeItems = persistentListOf( - FeeItem.Suggested( - title = resourceReference( - id = R.string.wc_fee_suggested, - formatArgs = wrappedList("Tangem"), - ), - fee = Fee.Common(Amount(value = BigDecimal("0.1"), blockchain = Blockchain.Ethereum)), - ), - FeeItem.Slow(fee = Fee.Common(Amount(value = BigDecimal("0.01"), blockchain = Blockchain.Ethereum))), - FeeItem.Market( - fee = Fee.Common( - Amount( - value = BigDecimal("0.02"), - blockchain = Blockchain.Ethereum, - ), - ), - ), - FeeItem.Fast(fee = Fee.Common(Amount(value = BigDecimal("0.03"), blockchain = Blockchain.Ethereum))), - customFeeItem, - ), - selectedFeeItem = FeeItem.Slow( - fee = Fee.Common( - Amount( - value = BigDecimal("0.01"), - blockchain = Blockchain.Ethereum, - ), - ), - ), - feeExtraInfo = FeeExtraInfo( - isFeeApproximate = true, - isFeeConvertibleToFiat = true, - isTronToken = false, - ), - feeFiatRateUM = FeeFiatRateUM( - rate = BigDecimal.TEN, - appCurrency = AppCurrency.Default, - ), - feeNonce = FeeNonce.None, - fees = TransactionFee.Single(customFeeItem.fee), - ), - ), -) - -private val customFeeItem = FeeItem.Custom( - fee = Fee.Common(Amount(value = BigDecimal("0.05"), blockchain = Blockchain.Ethereum)), - customValues = persistentListOf( - CustomFeeFieldUM( - value = "0.119806", - onValueChange = {}, - keyboardOptions = KeyboardOptions(), - keyboardActions = KeyboardActions(), - symbol = "ETH", - decimals = 8, - title = resourceReference(R.string.send_max_fee), - footer = resourceReference(R.string.send_custom_amount_fee_footer), - label = stringReference("~ 0,03 \$"), - isReadonly = false, - ), - CustomFeeFieldUM( - value = "40000", - onValueChange = {}, - keyboardOptions = KeyboardOptions(), - keyboardActions = KeyboardActions(), - symbol = "GWEI", - decimals = 8, - title = resourceReference(R.string.send_gas_price), - footer = resourceReference(R.string.send_gas_price_footer), - label = null, - isReadonly = false, - ), - CustomFeeFieldUM( - value = "31400", - onValueChange = {}, - keyboardOptions = KeyboardOptions(), - keyboardActions = KeyboardActions(), - symbol = null, - decimals = 8, - title = resourceReference(R.string.send_gas_limit), - footer = resourceReference(R.string.send_gas_limit_footer), - label = null, - isReadonly = false, - ), - ), -) \ No newline at end of file +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt index c17c778409..fe864a450f 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt @@ -12,6 +12,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.audits.AuditLabel +import com.tangem.core.ui.components.audits.AuditLabelUM +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.features.send.v2.api.FeeSelectorBlockComponent @@ -52,6 +55,12 @@ internal class PreviewFeeSelectorBlockComponent : FeeSelectorBlockComponent { tint = TangemTheme.colors.icon.informative, ) Spacer(modifier = Modifier.weight(1f)) + AuditLabel( + state = AuditLabelUM( + text = stringReference("ETH"), + type = AuditLabelUM.Type.General, + ), + ) Text( text = "~ 0.25 \$", style = TangemTheme.typography.body1, From 471343e66b4ba89ab1ef5ad3c4c307045d0d5384 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 4 Jan 2026 17:44:23 +0300 Subject: [PATCH 054/172] Updated on 2026-08-14 --- .../tangem/domain/transaction/Extensions.kt | 8 + .../gasless/GetAvailableFeeTokensUseCase.kt | 5 +- .../gasless/GetFeeForGaslessUseCase.kt | 135 ++------------- .../usecase/gasless/GetFeeForTokenUseCase.kt | 96 ++++++++++- .../usecase/gasless/TokenFeeCalculator.kt | 158 ++++++++++++++++++ 5 files changed, 269 insertions(+), 133 deletions(-) create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/Extensions.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/Extensions.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/Extensions.kt new file mode 100644 index 0000000000..a899479a7b --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/Extensions.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.transaction + +import arrow.core.raise.Raise +import com.tangem.domain.transaction.error.GetFeeError + +fun Raise.raiseIllegalStateError(error: String): Nothing { + raise(GetFeeError.DataError(IllegalStateException(error))) +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt index 019d021363..d46ccc06c3 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -12,6 +12,7 @@ import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.raiseIllegalStateError import java.math.BigDecimal class GetAvailableFeeTokensUseCase( @@ -82,8 +83,4 @@ class GetAvailableFeeTokensUseCase( } .toList() } - - private fun Raise.raiseIllegalStateError(error: String): Nothing { - raise(GetFeeError.DataError(IllegalStateException(error))) - } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt index d78a9f1cf1..cb48fefecd 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt @@ -5,14 +5,9 @@ import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager -import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.Token import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.blockchain.extensions.Result -import com.tangem.domain.demo.DemoTransactionSender import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -23,12 +18,11 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError.GaslessError -import com.tangem.domain.transaction.error.mapToFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.domain.transaction.raiseIllegalStateError import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import java.math.BigDecimal -import java.math.RoundingMode class GetFeeForGaslessUseCase( private val walletManagersFacade: WalletManagersFacade, @@ -39,6 +33,12 @@ class GetFeeForGaslessUseCase( private val getFeeUseCase: GetFeeUseCase, ) { + private val tokenFeeCalculator = TokenFeeCalculator( + walletManagersFacade = walletManagersFacade, + gaslessTransactionRepository = gaslessTransactionRepository, + demoConfig = demoConfig, + ) + suspend operator fun invoke( userWallet: UserWallet, network: Network, @@ -67,12 +67,12 @@ class GetFeeForGaslessUseCase( val walletManager = prepareWalletManager(userWallet, network) - val initialFee = calculateInitialFee( + val initialFee = tokenFeeCalculator.calculateInitialFee( userWallet = userWallet, network = network, walletManager = walletManager, transactionData = transactionData, - ) + ).bind() selectFeePaymentStrategy( userWallet = userWallet, @@ -103,27 +103,6 @@ class GetFeeForGaslessUseCase( return ethereumWalletManager } - private suspend fun Raise.calculateInitialFee( - userWallet: UserWallet, - network: Network, - walletManager: EthereumWalletManager, - transactionData: TransactionData, - ): TransactionFee { - val transactionSender = if (userWallet is UserWallet.Cold && - demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) - ) { - demoTransactionSender(userWallet, network) - } else { - walletManager - } - - val maybeFee = when (val result = transactionSender.getFee(transactionData = transactionData)) { - is Result.Success -> result.data - is Result.Failure -> raise(result.mapToFeeError()) - } - return maybeFee - } - private suspend fun Raise.selectFeePaymentStrategy( userWallet: UserWallet, walletManager: EthereumWalletManager, @@ -183,104 +162,12 @@ class GetFeeForGaslessUseCase( val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull() ?: raise(GaslessError.NoSupportedTokensFound) - return calculateTokenFee( + return tokenFeeCalculator.calculateTokenFee( walletManager = walletManager, tokenForPayFeeStatus = tokenForPayFeeStatus, nativeCurrencyStatus = nativeCurrencyStatus, initialFee = initialFee, - ) - } - - private suspend fun Raise.calculateTokenFee( - walletManager: EthereumWalletManager, - tokenForPayFeeStatus: CryptoCurrencyStatus, - nativeCurrencyStatus: CryptoCurrencyStatus, - initialFee: Fee.Ethereum, - ): TransactionFeeExtended { - val tokenForPayFee = tokenForPayFeeStatus.currency as? CryptoCurrency.Token - ?: raiseIllegalStateError("only tokens are supported") - - val transferFeeToContractAmount = createTokenAmount(tokenForPayFee, BigDecimal(FEE_TRANSFER_AMOUNT)) - val feeDestination = gaslessTransactionRepository.getTokenFeeReceiverAddress() - val feeTransferGasLimitResult = walletManager.getGasLimit( - amount = transferFeeToContractAmount, - destination = feeDestination, - callData = TransferERC20TokenCallData( - destination = feeDestination, - amount = transferFeeToContractAmount, - ), - ) - - val feeTransferGasLimit = when (feeTransferGasLimitResult) { - is Result.Failure -> raise(GetFeeError.DataError(feeTransferGasLimitResult.error)) - is Result.Success -> feeTransferGasLimitResult.data - } - - val baseGas = gaslessTransactionRepository.getBaseGasForTransaction() - - val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas - - val maxFeePerGas = when (initialFee) { - is Fee.Ethereum.EIP1559 -> initialFee.maxFeePerGas - is Fee.Ethereum.Legacy -> initialFee.gasPrice - is Fee.Ethereum.TokenCurrency -> raiseIllegalStateError("initialFee could only be native") - } - - val feeInNativeCurrency = maxTokenFeeGas - .multiply(maxFeePerGas) - .multiply(GAS_PRICE_MULTIPLIER.toBigInteger()) - - val nativeFiatRate = nativeCurrencyStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null") - val tokenFiatRate = tokenForPayFeeStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null") - val coinPriceInToken = nativeFiatRate.divide( - tokenFiatRate, - maxOf(nativeCurrencyStatus.currency.decimals, tokenForPayFee.decimals), - RoundingMode.DOWN, - ) - - val feeInTokenCurrency = coinPriceInToken.multiply(feeInNativeCurrency.toBigDecimal()) - - val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO - if (tokenBalance < feeInTokenCurrency) { - raise(GaslessError.NotEnoughFunds) - } - - val amount = createTokenAmount(tokenForPayFee, feeInTokenCurrency) - - val fee = Fee.Ethereum.TokenCurrency( - amount = amount, - gasLimit = maxTokenFeeGas, - coinPriceInToken = coinPriceInToken, - feeTransferGasLimit = feeTransferGasLimit, - baseGas = baseGas, - ) - return TransactionFeeExtended( - transactionFee = TransactionFee.Single(normal = fee), - feeTokenId = tokenForPayFee.id, - ) - } - - private fun createTokenAmount(token: CryptoCurrency.Token, value: BigDecimal): Amount = Amount( - token = Token( - symbol = token.symbol, - contractAddress = token.contractAddress, - decimals = token.decimals, - ), - value = value, - ) - - private suspend fun Raise.demoTransactionSender( - userWallet: UserWallet, - network: Network, - ): DemoTransactionSender { - return DemoTransactionSender( - walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network) - ?: raiseIllegalStateError("WalletManager is null"), - ) - } - - private fun Raise.raiseIllegalStateError(error: String): Nothing { - raise(GetFeeError.DataError(IllegalStateException(error))) + ).bind() } private companion object { diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt index f33773dca4..c1d82c2b18 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt @@ -1,15 +1,101 @@ package com.tangem.domain.transaction.usecase.gasless import arrow.core.Either -import arrow.core.left +import arrow.core.raise.Raise +import arrow.core.raise.either +import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.domain.transaction.raiseIllegalStateError +import com.tangem.domain.walletmanager.WalletManagersFacade -class GetFeeForTokenUseCase { +class GetFeeForTokenUseCase( + private val gaslessTransactionRepository: GaslessTransactionRepository, + private val walletManagersFacade: WalletManagersFacade, + private val demoConfig: DemoConfig, + private val currenciesRepository: CurrenciesRepository, + private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, +) { - operator fun invoke(token: CryptoCurrency, transactionData: TransactionData): Either { - return GetFeeError.UnknownError.left() + private val tokenFeeCalculator = TokenFeeCalculator( + walletManagersFacade = walletManagersFacade, + gaslessTransactionRepository = gaslessTransactionRepository, + demoConfig = demoConfig, + ) + + suspend operator fun invoke( + userWallet: UserWallet, + token: CryptoCurrency, + transactionData: TransactionData, + ): Either { + return either { + if (!gaslessTransactionRepository.isNetworkSupported(token.network)) { + raise(GetFeeError.GaslessError.NetworkIsNotSupported) + } + + val walletManager = prepareWalletManager(userWallet, token.network) + + val initialTxFee = tokenFeeCalculator.calculateInitialFee( + userWallet = userWallet, + network = token.network, + walletManager = walletManager, + transactionData = transactionData, + ).bind() + + val initialFeeEth = initialTxFee.normal as? Fee.Ethereum + ?: raiseIllegalStateError( + error = "only Fee.Ethereum supported, but was different", + ) + + val nativeCurrency = currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = token.network.id, + derivationPath = token.network.derivationPath, + ) + + val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + ).getOrNull()?.filter { + it.currency.network.id == token.network.id + } ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + + val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find { + it.currency.id == nativeCurrency.id + } ?: raiseIllegalStateError("native currency not found for network ${token.network.id}") + + val tokenCurrencyStatus = userCurrenciesStatusesByNetwork.find { + it.currency.id == token.id + } ?: raiseIllegalStateError("token currency not found for network ${token.network.id}") + + tokenFeeCalculator.calculateTokenFee( + walletManager = walletManager, + tokenForPayFeeStatus = tokenCurrencyStatus, + nativeCurrencyStatus = nativeCurrencyStatus, + initialFee = initialFeeEth, + ).bind() + } + } + + @Suppress("NullableToStringCall") + private suspend fun Raise.prepareWalletManager( + userWallet: UserWallet, + network: Network, + ): EthereumWalletManager { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = network, + ) + val ethereumWalletManager = walletManager as? EthereumWalletManager + ?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported") + return ethereumWalletManager } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt new file mode 100644 index 0000000000..392aa39519 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt @@ -0,0 +1,158 @@ +package com.tangem.domain.transaction.usecase.gasless + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.either +import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchain.extensions.Result +import com.tangem.domain.demo.DemoTransactionSender +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.GetFeeError.GaslessError +import com.tangem.domain.transaction.error.mapToFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.domain.transaction.raiseIllegalStateError +import com.tangem.domain.walletmanager.WalletManagersFacade +import java.math.BigDecimal +import java.math.RoundingMode + +internal class TokenFeeCalculator( + private val walletManagersFacade: WalletManagersFacade, + private val gaslessTransactionRepository: GaslessTransactionRepository, + private val demoConfig: DemoConfig, +) { + + suspend fun calculateInitialFee( + userWallet: UserWallet, + network: Network, + walletManager: EthereumWalletManager, + transactionData: TransactionData, + ): Either { + return either { + val transactionSender = if (userWallet is UserWallet.Cold && + demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) + ) { + demoTransactionSender(userWallet, network) + } else { + walletManager + } + + val maybeFee = when (val result = transactionSender.getFee(transactionData = transactionData)) { + is Result.Success -> result.data + is Result.Failure -> raise(result.mapToFeeError()) + } + maybeFee + } + } + + suspend fun calculateTokenFee( + walletManager: EthereumWalletManager, + tokenForPayFeeStatus: CryptoCurrencyStatus, + nativeCurrencyStatus: CryptoCurrencyStatus, + initialFee: Fee.Ethereum, + ): Either { + return either { + val tokenForPayFee = tokenForPayFeeStatus.currency as? CryptoCurrency.Token + ?: raiseIllegalStateError("only tokens are supported") + + val transferFeeToContractAmount = createTokenAmount( + token = tokenForPayFee, + value = BigDecimal(FEE_TRANSFER_AMOUNT), + ) + val feeDestination = gaslessTransactionRepository.getTokenFeeReceiverAddress() + val feeTransferGasLimitResult = walletManager.getGasLimit( + amount = transferFeeToContractAmount, + destination = feeDestination, + callData = TransferERC20TokenCallData( + destination = feeDestination, + amount = transferFeeToContractAmount, + ), + ) + + val feeTransferGasLimit = when (feeTransferGasLimitResult) { + is Result.Failure -> raise(GetFeeError.DataError(feeTransferGasLimitResult.error)) + is Result.Success -> feeTransferGasLimitResult.data + } + + val baseGas = gaslessTransactionRepository.getBaseGasForTransaction() + + val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas + + val maxFeePerGas = when (initialFee) { + is Fee.Ethereum.EIP1559 -> initialFee.maxFeePerGas + is Fee.Ethereum.Legacy -> initialFee.gasPrice + is Fee.Ethereum.TokenCurrency -> raiseIllegalStateError("initialFee could only be native") + } + + val feeInNativeCurrency = maxTokenFeeGas + .multiply(maxFeePerGas) + .multiply(GAS_PRICE_MULTIPLIER.toBigInteger()) + + val nativeFiatRate = nativeCurrencyStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null") + val tokenFiatRate = tokenForPayFeeStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null") + val coinPriceInToken = nativeFiatRate.divide( + tokenFiatRate, + maxOf(nativeCurrencyStatus.currency.decimals, tokenForPayFee.decimals), + RoundingMode.DOWN, + ) + + val feeInTokenCurrency = coinPriceInToken.multiply(feeInNativeCurrency.toBigDecimal()) + + val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO + if (tokenBalance < feeInTokenCurrency) { + raise(GaslessError.NotEnoughFunds) + } + + val amount = createTokenAmount(tokenForPayFee, feeInTokenCurrency) + + val fee = Fee.Ethereum.TokenCurrency( + amount = amount, + gasLimit = maxTokenFeeGas, + coinPriceInToken = coinPriceInToken, + feeTransferGasLimit = feeTransferGasLimit, + baseGas = baseGas, + ) + TransactionFeeExtended( + transactionFee = TransactionFee.Single(normal = fee), + feeTokenId = tokenForPayFee.id, + ) + } + } + + private fun createTokenAmount(token: CryptoCurrency.Token, value: BigDecimal): Amount = Amount( + token = Token( + symbol = token.symbol, + contractAddress = token.contractAddress, + decimals = token.decimals, + ), + value = value, + ) + + private suspend fun Raise.demoTransactionSender( + userWallet: UserWallet, + network: Network, + ): DemoTransactionSender { + return DemoTransactionSender( + walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network) + ?: raiseIllegalStateError("WalletManager is null"), + ) + } + + private companion object { + /** Amount in token units for fee transfer */ + const val FEE_TRANSFER_AMOUNT = 10000 + /** Gas price safety multiplier for fee calculation */ + const val GAS_PRICE_MULTIPLIER = 2 + } +} \ No newline at end of file From d9a539b64dabbaca7f6b0c1639071d8ac93d946a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 5 Jan 2026 05:47:09 +0200 Subject: [PATCH 055/172] Updated on 2026-08-14 --- .../core/ui/extensions/ImageReference.kt | 1 + core/ui/src/main/res/drawable/ic_p2p_logo.xml | 29 +++++ .../domain/staking/model/StakingTarget.kt | 8 +- .../staking/model/StakingTargetImage.kt | 10 ++ .../model/ethpool/P2PEthPoolStakingConfig.kt | 2 +- .../ui/StakingClaimRewardsValidatorContent.kt | 5 +- .../ui/StakingInitialInfoContent.kt | 119 +++++++++++++++--- .../impl/presentation/ui/StakingTargetIcon.kt | 35 ++++++ .../ui/StakingValidatorListContent.kt | 110 ++++++++++------ .../presentation/ui/block/ValidatorBlock.kt | 7 +- .../ui/utils/StakingImageUtils.kt | 23 ++++ 11 files changed, 287 insertions(+), 62 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_p2p_logo.xml create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingTargetImage.kt create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTargetIcon.kt create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/utils/StakingImageUtils.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/ImageReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/ImageReference.kt index 767065f05a..97397435f8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/ImageReference.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/ImageReference.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Immutable * It necessary to use [Immutable] annotation because all sealed interface has runtime stability. * All subclasses are stable. */ +@Immutable sealed class ImageReference { data class Url(val url: String) : ImageReference() diff --git a/core/ui/src/main/res/drawable/ic_p2p_logo.xml b/core/ui/src/main/res/drawable/ic_p2p_logo.xml new file mode 100644 index 0000000000..f7070f1376 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_p2p_logo.xml @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingTarget.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingTarget.kt index c6c99e7fab..9d7b65a30e 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingTarget.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingTarget.kt @@ -25,8 +25,8 @@ sealed interface StakingTarget { /** Whether this target is active and available for staking */ val isActive: Boolean - /** Image URL for display (validator logo or vault icon) */ - val image: String? + /** Image for display (validator logo or vault icon) */ + val image: StakingTargetImage? /** Whether this is a strategic partner (shows special badge in UI) */ val isStrategicPartner: Boolean @@ -40,7 +40,7 @@ sealed interface StakingTarget { override val rewardInfo: RewardInfo? = delegate.rewardInfo override val isPreferred: Boolean = delegate.preferred override val isActive: Boolean = delegate.status == Yield.Validator.ValidatorStatus.ACTIVE - override val image: String? = delegate.image + override val image: StakingTargetImage? = delegate.image?.let { StakingTargetImage.Url(it) } override val isStrategicPartner: Boolean = delegate.isStrategicPartner } @@ -56,7 +56,7 @@ sealed interface StakingTarget { ) override val isPreferred: Boolean = true override val isActive: Boolean = true - override val image: String? = null + override val image = StakingTargetImage.Local(StakingLocalImageType.P2P_VAULT) override val isStrategicPartner: Boolean = true } } diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingTargetImage.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingTargetImage.kt new file mode 100644 index 0000000000..bc6a9eae07 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingTargetImage.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.staking.model + +sealed interface StakingTargetImage { + data class Url(val url: String) : StakingTargetImage + data class Local(val type: StakingLocalImageType) : StakingTargetImage +} + +enum class StakingLocalImageType { + P2P_VAULT, +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStakingConfig.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStakingConfig.kt index 1e2949326b..27ea9df657 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStakingConfig.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStakingConfig.kt @@ -7,7 +7,7 @@ package com.tangem.domain.staking.model.ethpool */ object P2PEthPoolStakingConfig { - const val USE_TESTNET: Boolean = true + const val USE_TESTNET: Boolean = false val activeNetwork: P2PEthPoolNetwork get() = if (USE_TESTNET) P2PEthPoolNetwork.TESTNET else P2PEthPoolNetwork.MAINNET diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt index 20f8688108..b28afeb422 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt @@ -20,6 +20,8 @@ import com.tangem.features.staking.impl.presentation.model.StakingClickIntents import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.utils.getRewardTypeShortText +import com.tangem.features.staking.impl.presentation.ui.utils.toDrawableRes +import com.tangem.features.staking.impl.presentation.ui.utils.toImageUrl import com.tangem.utils.extensions.orZero @Composable @@ -42,7 +44,8 @@ internal fun StakingClaimRewardsValidatorContent( caption = item.getAprTextNeutral(), infoTitle = item.formattedFiatAmount, infoSubtitle = item.formattedCryptoAmount, - imageUrl = item.target?.image.orEmpty(), + imageUrl = item.target?.image.toImageUrl(), + iconRes = item.target?.image.toDrawableRes(), onImageError = { ValidatorImagePlaceholder() }, modifier = Modifier .roundedShapeItemDecoration(index, state.rewards.lastIndex, false) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt index 1a2d54564a..190331da23 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Icon import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape @@ -19,7 +20,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle @@ -32,9 +36,9 @@ import androidx.compose.ui.unit.Density import com.tangem.common.ui.navigationButtons.NavigationButtonsState import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerW12 import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer import com.tangem.core.ui.components.inputrow.InputRowDefault -import com.tangem.core.ui.components.inputrow.InputRowImageInfo import com.tangem.core.ui.components.list.roundedListWithDividersItems import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.extensions.* @@ -55,6 +59,7 @@ import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.previewdata.InitialStakingStatePreview import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub import com.tangem.features.staking.impl.presentation.state.utils.getRewardTypeShortText +import com.tangem.features.staking.impl.presentation.ui.utils.toImageReference import com.tangem.utils.StringsSigns.DOT import com.tangem.utils.extensions.orZero @@ -275,16 +280,9 @@ private fun ActiveStakingBlock( modifier: Modifier = Modifier, ) { val (icon, iconTint) = balance.type.getIcon() - InputRowImageInfo( - subtitle = balance.title, - caption = balance.subtitle ?: balance.getAprTextNeutral(), - infoTitle = balance.formattedFiatAmount.orMaskWithStars(isBalanceHidden), - infoSubtitle = balance.formattedCryptoAmount.orMaskWithStars(isBalanceHidden), - imageUrl = balance.getImage(), - iconRes = icon, - iconTint = iconTint, - subtitleEndIconRes = R.drawable.ic_staking_pending_transaction.takeIf { balance.isPending }, - onImageError = { ValidatorImagePlaceholder() }, + + Row( + verticalAlignment = Alignment.CenterVertically, modifier = modifier .background(TangemTheme.colors.background.action) .clickable( @@ -295,8 +293,96 @@ private fun ActiveStakingBlock( onAnalytic() onClick(balance) }, - ), - ) + ) + .padding(TangemTheme.dimens.spacing12), + ) { + StakingBalanceIcon( + balance = balance, + icon = icon, + iconTint = iconTint, + ) + + Column(modifier = Modifier.weight(1f)) { + Row { + Text( + text = balance.title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + if (balance.isPending) { + Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing4)) + Icon( + painter = rememberVectorPainter( + image = ImageVector.vectorResource(id = R.drawable.ic_staking_pending_transaction), + ), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size18), + ) + } + } + val caption = balance.subtitle ?: balance.getAprTextNeutral() + Text( + text = caption.resolveAnnotatedReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing2), + ) + } + + SpacerW12() + + Column(horizontalAlignment = Alignment.End) { + Text( + text = balance.formattedFiatAmount.orMaskWithStars(isBalanceHidden).resolveReference(), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + if (balance.formattedCryptoAmount != null) { + Text( + text = balance.formattedCryptoAmount.orMaskWithStars(isBalanceHidden).resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing2), + ) + } + } + } +} + +@Composable +private fun RowScope.StakingBalanceIcon(balance: BalanceState, icon: Int?, iconTint: Color) { + if (balance.hasImage() || icon != null) { + StakingTargetIcon( + image = if (balance.hasImage()) balance.target?.image.toImageReference() else null, + onImageError = if (icon != null) { + { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(TangemTheme.dimens.spacing36) + .clip(TangemTheme.shapes.roundedCornersXLarge) + .background(iconTint.copy(alpha = 0.08f)), + ) { + Icon( + painter = rememberVectorPainter( + image = ImageVector.vectorResource(id = icon), + ), + tint = iconTint, + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size18), + ) + } + } + } else { + { ValidatorImagePlaceholder() } + }, + modifier = Modifier + .size(TangemTheme.dimens.spacing36) + .clip(TangemTheme.shapes.roundedCornersXLarge), + ) + SpacerW12() + } } @Composable @@ -341,13 +427,12 @@ private fun BalanceType.getIcon() = when (this) { else -> null to TangemTheme.colors.icon.informative } -@Composable -private fun BalanceState.getImage() = when (type) { +private fun BalanceState.hasImage() = when (type) { BalanceType.UNSTAKING, BalanceType.UNSTAKED, BalanceType.LOCKED, - -> null - else -> target?.image + -> false + else -> target?.image != null } private val textGradientColors = listOf( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTargetIcon.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTargetIcon.kt new file mode 100644 index 0000000000..ba02bbd2fa --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTargetIcon.kt @@ -0,0 +1,35 @@ +package com.tangem.features.staking.impl.presentation.ui + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import com.tangem.core.ui.components.inputrow.inner.InputRowAsyncImage +import com.tangem.core.ui.extensions.ImageReference + +@Composable +internal fun StakingTargetIcon( + image: ImageReference?, + modifier: Modifier = Modifier, + onImageError: (@Composable () -> Unit)? = null, +) { + when (image) { + is ImageReference.Url -> { + InputRowAsyncImage( + imageUrl = image.url, + onImageError = onImageError, + modifier = modifier, + ) + } + is ImageReference.Res -> { + Image( + painter = painterResource(image.resId), + contentDescription = null, + modifier = modifier, + ) + } + null -> { + onImageError?.invoke() + } + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt index 3f79c1729d..147bb67a96 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt @@ -3,12 +3,16 @@ package com.tangem.features.staking.impl.presentation.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -20,7 +24,7 @@ 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.inputrow.InputRowImageSelector +import com.tangem.core.ui.components.SpacerW12 import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.format @@ -35,6 +39,7 @@ import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.previewdata.ValidatorStatePreviewData import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub import com.tangem.features.staking.impl.presentation.state.utils.getRewardTypeLongText +import com.tangem.features.staking.impl.presentation.ui.utils.toImageReference import com.tangem.utils.extensions.orZero /** @@ -61,47 +66,80 @@ internal fun StakingValidatorListContent( key = { targets[it].address }, contentType = { targets[it]::class.java }, ) { index -> - val item = targets[index] - - InputRowImageSelector( - subtitle = stringReference(item.name), - caption = item.getAprTextNeutral(), - imageUrl = item.image.orEmpty(), - isSelected = item == state.chosenTarget, - onSelect = { clickIntents.onTargetSelect(item) }, - modifier = Modifier - .roundedShapeItemDecoration( - currentIndex = index, - lastIndex = targets.lastIndex, - radius = TangemTheme.dimens.radius12, - addDefaultPadding = - false, - ) - .background(TangemTheme.colors.background.action), - subtitleExtraContent = { - ValidatorLabel(item.isStrategicPartner) - }, - selectorContent = { checked, _, _ -> - AnimatedVisibility( - modifier = Modifier, - visible = checked, - ) { - Icon( - painter = rememberVectorPainter( - image = ImageVector.vectorResource(id = R.drawable.ic_check_24), - ), - tint = TangemTheme.colors.icon.accent, - contentDescription = null, - ) - } - }, - onImageError = { ValidatorImagePlaceholder() }, + ValidatorListItem( + item = targets[index], + isSelected = targets[index] == state.chosenTarget, + index = index, + lastIndex = targets.lastIndex, + onSelect = { clickIntents.onTargetSelect(targets[index]) }, ) } } } } +@Composable +private fun ValidatorListItem( + item: StakingTarget, + isSelected: Boolean, + index: Int, + lastIndex: Int, + onSelect: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = lastIndex, + radius = TangemTheme.dimens.radius12, + addDefaultPadding = false, + ) + .background(TangemTheme.colors.background.action) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = onSelect, + ) + .padding(TangemTheme.dimens.spacing12), + ) { + StakingTargetIcon( + image = item.image.toImageReference(), + onImageError = { ValidatorImagePlaceholder() }, + modifier = Modifier + .size(TangemTheme.dimens.spacing36) + .clip(TangemTheme.shapes.roundedCornersXLarge), + ) + SpacerW12() + Column(modifier = Modifier.weight(1f)) { + Row { + Text( + text = stringReference(item.name).resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + ValidatorLabel(item.isStrategicPartner) + } + Text( + text = item.getAprTextNeutral().resolveAnnotatedReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing2), + ) + } + SpacerW12() + AnimatedVisibility(visible = isSelected) { + Icon( + painter = rememberVectorPainter( + image = ImageVector.vectorResource(id = R.drawable.ic_check_24), + ), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + } + } +} + /** * For FCA fixes remove coloring for now */ diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/ValidatorBlock.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/ValidatorBlock.kt index 44012d5abc..005d38c850 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/ValidatorBlock.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/ValidatorBlock.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SpacerWMax -import com.tangem.core.ui.components.inputrow.inner.InputRowAsyncImage import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent @@ -30,7 +29,9 @@ import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.StakingStates.ValidatorState import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.utils.getRewardTypeShortText +import com.tangem.features.staking.impl.presentation.ui.StakingTargetIcon import com.tangem.features.staking.impl.presentation.ui.ValidatorImagePlaceholder +import com.tangem.features.staking.impl.presentation.ui.utils.toImageReference import com.tangem.utils.extensions.orZero @Composable @@ -63,8 +64,8 @@ internal fun ValidatorBlock(validatorState: StakingStates.ValidatorState, isClic horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(12.dp), ) { - InputRowAsyncImage( - imageUrl = state.chosenTarget.image.orEmpty(), + StakingTargetIcon( + image = state.chosenTarget.image.toImageReference(), onImageError = { ValidatorImagePlaceholder() }, modifier = Modifier .size(24.dp) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/utils/StakingImageUtils.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/utils/StakingImageUtils.kt new file mode 100644 index 0000000000..7c1dc4d212 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/utils/StakingImageUtils.kt @@ -0,0 +1,23 @@ +package com.tangem.features.staking.impl.presentation.ui.utils + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.extensions.ImageReference +import com.tangem.core.ui.R as CoreR +import com.tangem.domain.staking.model.StakingLocalImageType +import com.tangem.domain.staking.model.StakingTargetImage + +fun StakingTargetImage?.toImageReference(): ImageReference? = when (this) { + is StakingTargetImage.Url -> url.takeIf { it.isNotBlank() }?.let { ImageReference.Url(it) } + is StakingTargetImage.Local -> ImageReference.Res(type.toDrawableRes()) + null -> null +} + +fun StakingTargetImage?.toImageUrl(): String? = (this as? StakingTargetImage.Url)?.url?.takeIf { it.isNotBlank() } + +@DrawableRes +fun StakingTargetImage?.toDrawableRes(): Int? = (this as? StakingTargetImage.Local)?.type?.toDrawableRes() + +@DrawableRes +fun StakingLocalImageType.toDrawableRes(): Int = when (this) { + StakingLocalImageType.P2P_VAULT -> CoreR.drawable.ic_p2p_logo +} \ No newline at end of file From d68ad52a4544f5fb78776f1d6e60914b12e902b9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 5 Jan 2026 13:10:02 +0300 Subject: [PATCH 056/172] Updated on 2026-08-14 --- .../tap/network/auth/DefaultAuthProvider.kt | 16 ++++ .../datasource/api/common/AuthProvider.kt | 2 + .../datasource/api/common/config/ApiConfig.kt | 2 + .../api/common/config/GaslessTxService.kt | 66 +++++++++++++++++ .../api/gasless/GaslessTxServiceApi.kt | 12 +++ .../gasless/models/GaslessServiceResponse.kt | 11 +++ .../gasless/models/GaslessSupportedTokens.kt | 9 +++ .../api/gasless/models/GaslessTokenDTO.kt | 14 ++++ .../com/tangem/datasource/di/NetworkModule.kt | 10 +++ .../api/common/config/ApiConfigTest.kt | 5 ++ .../managers/MockEnvironmentConfigStorage.kt | 1 + .../managers/ProdApiConfigsManagerTest.kt | 41 +++++++++++ .../DefaultGaslessTransactionRepository.kt | 73 +++++++++++++++++++ ...aslessTokenDtoToCryptoCurrencyConverter.kt | 12 +++ .../GaslessTransactionRepository.kt | 2 +- .../gasless/GetAvailableFeeTokensUseCase.kt | 4 +- 16 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessServiceResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSupportedTokens.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTokenDTO.kt create mode 100644 data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt create mode 100644 data/transaction/src/main/java/com/tangem/data/transaction/converters/GaslessTokenDtoToCryptoCurrencyConverter.kt diff --git a/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt index 597915ff66..abc3d9f69b 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt @@ -59,6 +59,22 @@ internal class DefaultAuthProvider( } } + override fun getGaslessServiceApiKey(apiEnvironment: Provider): ProviderSuspend { + return ProviderSuspend { + when (apiEnvironment.invoke()) { + ApiEnvironment.DEV, + ApiEnvironment.DEV_2, + ApiEnvironment.DEV_3, + -> environmentConfigStorage.getConfigSync().tangemApiKeyDev // TODO add gaslessTxApiKeyDev + ApiEnvironment.STAGE, + ApiEnvironment.STAGE_2, + -> environmentConfigStorage.getConfigSync().tangemApiKeyStage // TODO add gaslessTxApiKeyStage + ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().tangemApiKey // TODO add gaslessTxApiKey + else -> error("No gasless tx api config provided for ${apiEnvironment.invoke()}") + } ?: error("No gasless tx api config provided") + } + } + private suspend fun getWallets(): List { return if (shouldUseNewListRepository) { userWalletsListRepository.userWalletsSync() diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/AuthProvider.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/AuthProvider.kt index 002f8656ed..75cf5c12bd 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/AuthProvider.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/AuthProvider.kt @@ -18,6 +18,8 @@ interface AuthProvider { fun getApiKey(apiEnvironment: Provider): ProviderSuspend + fun getGaslessServiceApiKey(apiEnvironment: Provider): ProviderSuspend + /** * Returns map where keys(cardId) associated with cardPublicKey */ diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index 8e0a661871..13b9af6b94 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -31,6 +31,7 @@ sealed class ApiConfig { YieldSupply, MoonPay, News, + GaslessTxService, } private fun initializeId(): ID { @@ -45,6 +46,7 @@ sealed class ApiConfig { is YieldSupply -> ID.YieldSupply is MoonPay -> ID.MoonPay is News -> ID.News + is GaslessTxService -> ID.GaslessTxService } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt new file mode 100644 index 0000000000..ce201f58bb --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt @@ -0,0 +1,66 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.common.AuthProvider +import com.tangem.datasource.utils.RequestHeader +import com.tangem.utils.Provider +import com.tangem.utils.ProviderSuspend +import com.tangem.utils.info.AppInfoProvider +import com.tangem.utils.version.AppVersionProvider + +/** + * Gasless transactions [ApiConfig] + */ +internal class GaslessTxService( + private val authProvider: AuthProvider, + private val appVersionProvider: AppVersionProvider, + private val appInfoProvider: AppInfoProvider, +) : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() + + override val environmentConfigs: List = listOf( + createProdEnvironment(), + createDevEnvironment(), + ) + + private fun getInitialEnvironment(): ApiEnvironment { + return when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE, + DEBUG_BUILD_TYPE, + -> ApiEnvironment.DEV + INTERNAL_BUILD_TYPE, + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + } + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = PROD_BASE_URL, + headers = createHeaders(ApiEnvironment.PROD), + ) + + private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = DEV_BASE_URL, + headers = createHeaders(ApiEnvironment.DEV), + ) + + private fun createHeaders(environment: ApiEnvironment) = buildMap { + putAll(RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) + put( + key = "Authorization", + value = ProviderSuspend { + "Bearer ${authProvider.getGaslessServiceApiKey(Provider { environment }).invoke()}" + }, + ) + } + + private companion object { + private const val PROD_BASE_URL = "https://api.tangem.org/" + private const val DEV_BASE_URL = "[REDACTED_ENV_URL]" + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt new file mode 100644 index 0000000000..0413f4ac56 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.api.gasless + +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.gasless.models.GaslessServiceResponse +import com.tangem.datasource.api.gasless.models.GaslessSupportedTokens +import retrofit2.http.GET + +interface GaslessTxServiceApi { + + @GET("api/v1/tokens") + suspend fun getSupportedTokens(): ApiResponse> +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessServiceResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessServiceResponse.kt new file mode 100644 index 0000000000..a439b9e05a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessServiceResponse.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaslessServiceResponse( + @Json(name = "result") val result: T, + @Json(name = "success") val isSuccess: Boolean, + @Json(name = "timestamp") val timestamp: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSupportedTokens.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSupportedTokens.kt new file mode 100644 index 0000000000..21ef002c64 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSupportedTokens.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaslessSupportedTokens( + @Json(name = "tokens") val tokens: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTokenDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTokenDTO.kt new file mode 100644 index 0000000000..e107fdcc83 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTokenDTO.kt @@ -0,0 +1,14 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaslessTokenDTO( + @Json(name = "tokenAddress") val tokenAddress: String, + @Json(name = "tokenSymbol") val tokenSymbol: String, + @Json(name = "tokenName") val tokenName: String, + @Json(name = "decimals") val decimals: Int, + @Json(name = "chainId") val chainId: Int, + @Json(name = "chain") val chain: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index d3f942e798..8624353f33 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -15,6 +15,7 @@ import com.tangem.datasource.api.moonpay.MoonPayApi import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.api.onramp.OnrampApi import com.tangem.datasource.api.ethpool.P2PEthPoolApi +import com.tangem.datasource.api.gasless.GaslessTxServiceApi import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.TangemPayAuthApi import com.tangem.datasource.api.stakekit.StakeKitApi @@ -188,4 +189,13 @@ internal object NetworkModule { applyTimeoutAnnotations = false, ) } + + @Provides + @Singleton + fun provideGaslessTxServiceApi(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApi { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.GaslessTxService, + applyTimeoutAnnotations = false, + ) + } } \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt index d92d9ff26e..1f664d53c8 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt @@ -75,6 +75,11 @@ class ApiConfigTest { ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk()) ApiConfig.ID.News -> News(authProvider = appAuthProvider) ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = mockk()) + ApiConfig.ID.GaslessTxService -> GaslessTxService( + authProvider = appAuthProvider, + appVersionProvider = mockk(), + appInfoProvider = mockk(), + ) } } } diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt index 18c51f8e49..7a7a84ca41 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt @@ -33,6 +33,7 @@ internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage { const val BLOCK_AID_API_KEY = "block_aid_api_key" const val TANGEM_API_KEY = "tangem_api_key" const val TANGEM_API_KEY_DEV = "tangem_api_key_dev" + const val TANGEM_GASLESS_API_KEY = "tangem_gasless_api_key" const val TANGEM_API_KEY_STAGE = "tangem_api_key_stage" const val YIELD_MODULE_KEY = "yield_module_api_key" const val YIELD_MODULE_KEY_DEV = "yield_module_api_key_dev" diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index 29e697fc8a..22ff9ab8fb 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -12,6 +12,7 @@ import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_ import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD_TYPE import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.BLOCK_AID_API_KEY import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_API_KEY +import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_GASLESS_API_KEY import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.lib.auth.P2PEthPoolAuthProvider @@ -45,6 +46,7 @@ internal class ProdApiConfigsManagerTest { private val appAuthProvider = mockk() private val appInfoProvider = mockk() private val tangemApiKeyProvider = mockk>() + private val tangemGaslessApiKeyProvider = mockk>() private lateinit var manager: ProdApiConfigsManager @@ -63,6 +65,8 @@ internal class ProdApiConfigsManagerTest { every { stakeKitAuthProvider.getApiKey() } returns STAKE_KIT_API_KEY every { p2pEthPoolAuthProvider.getApiKey() } returns P2P_API_KEY every { appAuthProvider.getApiKey(any()) } returns tangemApiKeyProvider + every { appAuthProvider.getGaslessServiceApiKey(any()) } returns tangemGaslessApiKeyProvider + coEvery { tangemGaslessApiKeyProvider.invoke() } returns TANGEM_GASLESS_API_KEY coEvery { tangemApiKeyProvider.invoke() } returns TANGEM_API_KEY coEvery { appAuthProvider.getCardId() } returns APP_CARD_ID coEvery { appAuthProvider.getCardPublicKey() } returns APP_CARD_PUBLIC_KEY @@ -117,6 +121,11 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider) ApiConfig.ID.News -> News(authProvider = appAuthProvider) ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = appVersionProvider) + ApiConfig.ID.GaslessTxService -> GaslessTxService( + authProvider = appAuthProvider, + appVersionProvider = appVersionProvider, + appInfoProvider = appInfoProvider, + ) } } } @@ -133,6 +142,7 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.P2PEthPool -> createP2PModel() ApiConfig.ID.News -> createNewsModel() ApiConfig.ID.TangemPayAuth -> createTangemPayAuthModel() + ApiConfig.ID.GaslessTxService -> createGaslessTxServiceModel() } } @@ -274,6 +284,37 @@ internal class ProdApiConfigsManagerTest { ) } + private fun createGaslessTxServiceModel(): TestModel { + val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE, + DEBUG_BUILD_TYPE, + -> ApiEnvironment.DEV to "[REDACTED_ENV_URL]" + INTERNAL_BUILD_TYPE, + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD to "https://tangem.com/" + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + return TestModel( + id = ApiConfig.ID.GaslessTxService, + expected = ApiEnvironmentConfig( + environment = environment, + baseUrl = baseUrl, + headers = mapOf( + "Authorization" to ProviderSuspend { "Bearer $TANGEM_GASLESS_API_KEY" }, + "version" to ProviderSuspend { VERSION_NAME }, + "platform" to ProviderSuspend { "android" }, + "system_version" to ProviderSuspend { "Android 16" }, + "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "timezone" to ProviderSuspend { + TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() + }, + "device" to ProviderSuspend { "${Build.MANUFACTURER} ${Build.MODEL}".checkHeaderValueOrEmpty() }, + ), + ), + ) + } + private fun createBlockAidSdkModel(): TestModel { return TestModel( id = ApiConfig.ID.BlockAid, diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt new file mode 100644 index 0000000000..ae2209f795 --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -0,0 +1,73 @@ +package com.tangem.data.transaction + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.data.transaction.converters.GaslessTokenDtoToCryptoCurrencyConverter +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.gasless.GaslessTxServiceApi +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import java.math.BigInteger + +class DefaultGaslessTransactionRepository( + private val gaslessTxServiceApi: GaslessTxServiceApi, + private val coroutineDispatcherProvider: CoroutineDispatcherProvider, +) : GaslessTransactionRepository { + + private val gaslessTokenDtoToCryptoCurrency = GaslessTokenDtoToCryptoCurrencyConverter() + private val supportedTokensState = MutableStateFlow?>(null) + + override fun isNetworkSupported(network: Network): Boolean { + val blockchain = Blockchain.fromNetworkId(network.backendId) ?: return false + return SUPPORTED_BLOCKCHAINS.contains(blockchain) + } + + override suspend fun getSupportedTokens(): Set { + return withContext(coroutineDispatcherProvider.io) { + val storedTokens = supportedTokensState.value + if (storedTokens != null) { + return@withContext storedTokens + } + val supportedTokensData = gaslessTxServiceApi.getSupportedTokens().getOrThrow() + if (supportedTokensData.isSuccess) { + val supportedTokens = supportedTokensData.result.tokens.map { + gaslessTokenDtoToCryptoCurrency.convert(it) + }.toSet() + // update local cache + supportedTokensState.update { supportedTokens } + return@withContext supportedTokens + } else { + error("Gasless service returned unsuccessful response") + } + } + } + + override fun getTokenFeeReceiverAddress(): String { + return TOKEN_RECEIVER_ADDRESS + } + + override fun getBaseGasForTransaction(): BigInteger { + return BASE_GAS_FOR_TRANSACTION + } + + private companion object { + + const val TOKEN_RECEIVER_ADDRESS = "0x" + + val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000") + val SUPPORTED_BLOCKCHAINS = arrayOf( + Blockchain.Ethereum, + Blockchain.BSC, + Blockchain.Base, + Blockchain.Polygon, + Blockchain.Arbitrum, + Blockchain.XDC, + Blockchain.Optimism, + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/converters/GaslessTokenDtoToCryptoCurrencyConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/converters/GaslessTokenDtoToCryptoCurrencyConverter.kt new file mode 100644 index 0000000000..e6bff10c5f --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/converters/GaslessTokenDtoToCryptoCurrencyConverter.kt @@ -0,0 +1,12 @@ +package com.tangem.data.transaction.converters + +import com.tangem.datasource.api.gasless.models.GaslessTokenDTO +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.utils.converter.Converter + +internal class GaslessTokenDtoToCryptoCurrencyConverter : Converter { + + override fun convert(value: GaslessTokenDTO): CryptoCurrency { + TODO("implement conversion logic here") + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt index d8cee83001..2f066e08f2 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt @@ -8,7 +8,7 @@ interface GaslessTransactionRepository { fun isNetworkSupported(network: Network): Boolean - fun getSupportedTokens(): Set + suspend fun getSupportedTokens(): Set fun getTokenFeeReceiverAddress(): String diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt index d46ccc06c3..c170b631c8 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -72,7 +72,9 @@ class GetAvailableFeeTokensUseCase( } ?: raiseIllegalStateError("no native currency found") } - private fun getGaslessTokens(userCurrenciesStatuses: List): List { + private suspend fun getGaslessTokens( + userCurrenciesStatuses: List, + ): List { val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens() return userCurrenciesStatuses .asSequence() From 13b1bbc486d269d5b803b1cf8862b9b39b10e552 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 5 Jan 2026 17:52:37 +0300 Subject: [PATCH 057/172] Updated on 2026-08-14 --- .../api/gasless/GaslessTxServiceApi.kt | 9 ++ .../models/GaslessSignedTransactionResult.kt | 23 +++++ .../models/GaslessTransactionRequest.kt | 93 +++++++++++++++++++ .../ResponseCryptoCurrenciesFactory.kt | 2 +- data/transaction/build.gradle.kts | 3 + .../DefaultGaslessTransactionRepository.kt | 24 +++-- ...aslessTokenDtoToCryptoCurrencyConverter.kt | 12 --- .../GaslessTransactionRepository.kt | 2 +- .../gasless/GetAvailableFeeTokensUseCase.kt | 5 +- .../gasless/GetFeeForGaslessUseCase.kt | 4 +- 10 files changed, 154 insertions(+), 23 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResult.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt delete mode 100644 data/transaction/src/main/java/com/tangem/data/transaction/converters/GaslessTokenDtoToCryptoCurrencyConverter.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt index 0413f4ac56..fcb9fe8c20 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt @@ -2,11 +2,20 @@ package com.tangem.datasource.api.gasless import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.gasless.models.GaslessServiceResponse +import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResult import com.tangem.datasource.api.gasless.models.GaslessSupportedTokens +import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.POST interface GaslessTxServiceApi { @GET("api/v1/tokens") suspend fun getSupportedTokens(): ApiResponse> + + @POST("api/v1/sign") + suspend fun signGaslessTransaction( + @Body transaction: GaslessTransactionRequest, + ): ApiResponse> } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResult.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResult.kt new file mode 100644 index 0000000000..0a2fc092de --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResult.kt @@ -0,0 +1,23 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Result of gasless transaction signing. + * Contains the signed transaction data and gas parameters. + */ +@JsonClass(generateAdapter = true) +data class GaslessSignedTransactionResult( + @Json(name = "signedTransaction") + val signedTransaction: String, + + @Json(name = "gasLimit") + val gasLimit: String, + + @Json(name = "maxFeePerGas") + val maxFeePerGas: String, + + @Json(name = "maxPriorityFeePerGas") + val maxPriorityFeePerGas: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt new file mode 100644 index 0000000000..f5f40bd9df --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt @@ -0,0 +1,93 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Request body for gasless transaction submission. + * Represents complete transaction with fee delegation metadata. + */ +@JsonClass(generateAdapter = true) +data class GaslessTransactionRequest( + @Json(name = "gaslessTransaction") + val gaslessTransaction: GaslessTransactionData, + + @Json(name = "signature") + val signature: String, + + @Json(name = "userAddress") + val userAddress: String, + + @Json(name = "chainId") + val chainId: Int, + + @Json(name = "eip7702auth") + val eip7702Auth: Eip7702Authorization? = null, +) + +@JsonClass(generateAdapter = true) +data class GaslessTransactionData( + @Json(name = "transaction") + val transaction: TransactionData, + + @Json(name = "fee") + val fee: FeeData, + + @Json(name = "nonce") + val nonce: String, +) + +@JsonClass(generateAdapter = true) +data class TransactionData( + @Json(name = "to") + val to: String, + + @Json(name = "value") + val value: String, + + @Json(name = "data") + val data: String, +) + +@JsonClass(generateAdapter = true) +data class FeeData( + @Json(name = "feeToken") + val feeToken: String, + + @Json(name = "maxTokenFee") + val maxTokenFee: String, + + @Json(name = "coinPriceInToken") + val coinPriceInToken: String, + + @Json(name = "feeTransferGasLimit") + val feeTransferGasLimit: String, + + @Json(name = "baseGas") + val baseGas: String, +) + +/** + * EIP-7702 authorization for account abstraction. + * Optional field, used only when EOA delegation is required. + */ +@JsonClass(generateAdapter = true) +data class Eip7702Authorization( + @Json(name = "chainId") + val chainId: Int, + + @Json(name = "address") + val address: String, + + @Json(name = "nonce") + val nonce: String, + + @Json(name = "yParity") + val yParity: Int, + + @Json(name = "r") + val r: String, + + @Json(name = "s") + val s: String, +) \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt index eccf6e4952..06bc2f1e85 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt @@ -127,7 +127,7 @@ class ResponseCryptoCurrenciesFactory @Inject constructor( } } - private fun createToken(blockchain: Blockchain, sdkToken: Token, network: Network): CryptoCurrency.Token { + fun createToken(blockchain: Blockchain, sdkToken: Token, network: Network): CryptoCurrency.Token { val id = getTokenId(network, sdkToken) return CryptoCurrency.Token( diff --git a/data/transaction/build.gradle.kts b/data/transaction/build.gradle.kts index fa74ec6c43..cb26912c17 100644 --- a/data/transaction/build.gradle.kts +++ b/data/transaction/build.gradle.kts @@ -24,6 +24,9 @@ dependencies { implementation(projects.core.datasource) implementation(projects.core.utils) + /** Common */ + implementation(projects.data.common) + /** Domain */ implementation(projects.libs.blockchainSdk) implementation(projects.domain.legacy) diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt index ae2209f795..19822060a2 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -1,8 +1,9 @@ package com.tangem.data.transaction import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.Token import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.data.transaction.converters.GaslessTokenDtoToCryptoCurrencyConverter +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.gasless.GaslessTxServiceApi import com.tangem.domain.models.currency.CryptoCurrency @@ -17,9 +18,9 @@ import java.math.BigInteger class DefaultGaslessTransactionRepository( private val gaslessTxServiceApi: GaslessTxServiceApi, private val coroutineDispatcherProvider: CoroutineDispatcherProvider, + private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, ) : GaslessTransactionRepository { - private val gaslessTokenDtoToCryptoCurrency = GaslessTokenDtoToCryptoCurrencyConverter() private val supportedTokensState = MutableStateFlow?>(null) override fun isNetworkSupported(network: Network): Boolean { @@ -27,16 +28,27 @@ class DefaultGaslessTransactionRepository( return SUPPORTED_BLOCKCHAINS.contains(blockchain) } - override suspend fun getSupportedTokens(): Set { + override suspend fun getSupportedTokens(network: Network): Set { return withContext(coroutineDispatcherProvider.io) { val storedTokens = supportedTokensState.value - if (storedTokens != null) { + if (storedTokens != null && storedTokens.isNotEmpty()) { return@withContext storedTokens } + val supportedTokensData = gaslessTxServiceApi.getSupportedTokens().getOrThrow() if (supportedTokensData.isSuccess) { - val supportedTokens = supportedTokensData.result.tokens.map { - gaslessTokenDtoToCryptoCurrency.convert(it) + val supportedTokens = supportedTokensData.result.tokens.mapNotNull { token -> + val blockchain = Blockchain.fromChainId(token.chainId) ?: return@mapNotNull null + responseCryptoCurrenciesFactory.createToken( + blockchain = blockchain, + sdkToken = Token( + contractAddress = token.tokenAddress, + name = token.tokenName, + symbol = token.tokenSymbol, + decimals = token.decimals, + ), + network = network, + ) }.toSet() // update local cache supportedTokensState.update { supportedTokens } diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/converters/GaslessTokenDtoToCryptoCurrencyConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/converters/GaslessTokenDtoToCryptoCurrencyConverter.kt deleted file mode 100644 index e6bff10c5f..0000000000 --- a/data/transaction/src/main/java/com/tangem/data/transaction/converters/GaslessTokenDtoToCryptoCurrencyConverter.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.data.transaction.converters - -import com.tangem.datasource.api.gasless.models.GaslessTokenDTO -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.utils.converter.Converter - -internal class GaslessTokenDtoToCryptoCurrencyConverter : Converter { - - override fun convert(value: GaslessTokenDTO): CryptoCurrency { - TODO("implement conversion logic here") - } -} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt index 2f066e08f2..728a81f317 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt @@ -8,7 +8,7 @@ interface GaslessTransactionRepository { fun isNetworkSupported(network: Network): Boolean - suspend fun getSupportedTokens(): Set + suspend fun getSupportedTokens(network: Network): Set fun getTokenFeeReceiverAddress(): String diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt index c170b631c8..1515c315e8 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -44,7 +44,7 @@ class GetAvailableFeeTokensUseCase( return@either listOf(nativeCurrencyStatus) } - val gaslessTokens = getGaslessTokens(userCurrenciesStatuses) + val gaslessTokens = getGaslessTokens(network, userCurrenciesStatuses) buildList { add(nativeCurrencyStatus) addAll(gaslessTokens) @@ -73,9 +73,10 @@ class GetAvailableFeeTokensUseCase( } private suspend fun getGaslessTokens( + network: Network, userCurrenciesStatuses: List, ): List { - val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens() + val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(network) return userCurrenciesStatuses .asSequence() .filter { currencyStatus -> diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt index cb48fefecd..84be8c0fde 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt @@ -149,7 +149,9 @@ class GetFeeForGaslessUseCase( error = "only Fee.Ethereum supported, but was ${initialTxFee.normal::class.qualifiedName}", ) - val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens() + val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens( + network = nativeCurrencyStatus.currency.network, + ) val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses .filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token } .sortedByDescending { it.value.amount } From 7c92156d19f6c267755c955756e77503f5dbe85f Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 5 Jan 2026 18:11:33 +0300 Subject: [PATCH 058/172] Updated on 2026-08-14 --- .../ModalBottomSheetWithBackHandling.kt | 49 ++---- .../modal/TangemModalBottomSheet.kt | 38 ++--- .../modal/TangemModalBottomSheetWithFooter.kt | 38 ++--- .../bottomsheets/sheet/TangemBottomSheet.kt | 39 ++--- .../core/ui/components/icons/IconTint.kt | 1 + .../token/internal/TokenFiatAmount.kt | 27 ++- .../components/token/state/TokenItemState.kt | 5 + features/send-v2/impl/build.gradle.kts | 1 + .../DefaultFeeSelectorComponent.kt | 20 ++- .../component/FeeSelectorComponentParams.kt | 2 + .../extended/FeeExtendedSelectorComponent.kt | 41 +++++ .../extended/entity/FeeExtendedSelectorUM.kt | 14 ++ .../entity/SelectedTokenItemConverter.kt | 23 +++ .../model/FeeExtendedSelectorModel.kt | 73 +++++++++ .../extended/ui/FeeExtendedSelectorContent.kt | 154 ++++++++++++++++++ .../speed/ui/FeeSpeedSelectorContent.kt | 16 +- .../feeselector/di/FeeSelectorModelModule.kt | 6 + .../v2/feeselector/model/FeeSelectorModel.kt | 8 +- .../ui/FeeSelectorModalBottomSheet.kt | 69 +++++--- 19 files changed, 472 insertions(+), 152 deletions(-) create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/FeeExtendedSelectorUM.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/SelectedTokenItemConverter.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt index 95ce4090db..8b8f7d3fd4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt @@ -1,32 +1,25 @@ package com.tangem.core.ui.components.bottomsheets.internal import androidx.activity.compose.BackHandler -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.key.* import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun ModalBottomSheetWithBackHandling( onDismissRequest: () -> Unit, - onBack: (() -> Unit), modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, sheetState: SheetState = rememberModalBottomSheetState(), sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, shape: Shape = BottomSheetDefaults.ExpandedShape, @@ -38,28 +31,9 @@ fun ModalBottomSheetWithBackHandling( contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets }, content: @Composable ColumnScope.() -> Unit, ) { - BackHandler(enabled = sheetState.targetValue != SheetValue.Hidden) { - onBack() - } - - val requester = remember { FocusRequester() } - val backPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current - ModalBottomSheet( onDismissRequest = onDismissRequest, - modifier = modifier - .focusRequester(requester) - .focusable() - .onPreviewKeyEvent { keyEvent -> - if (keyEvent.key == Key.Back && - keyEvent.type == KeyEventType.KeyUp && - !keyEvent.nativeKeyEvent.isCanceled - ) { - backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed() - return@onPreviewKeyEvent true - } - return@onPreviewKeyEvent false - }, + modifier = modifier, sheetState = sheetState, sheetMaxWidth = sheetMaxWidth, shape = shape, @@ -69,18 +43,17 @@ fun ModalBottomSheetWithBackHandling( scrimColor = scrimColor, dragHandle = dragHandle, contentWindowInsets = contentWindowInsets, - properties = ModalBottomSheetDefaults.properties( - // Set false otherwise the onPreviewKeyEvent doesn't work at all. - // The functionality of shouldDismissOnBackPress is achieved by the BackHandler. - shouldDismissOnBackPress = false, + properties = ModalBottomSheetProperties( + shouldDismissOnBackPress = onBack == null, ), - content = content, - ) + content = { + content() - LaunchedEffect(Unit) { - delay(timeMillis = 200) - requester.requestFocus() - } + BackHandler(enabled = onBack != null && sheetState.targetValue != SheetValue.Hidden) { + onBack?.invoke() + } + }, + ) } @OptIn(ExperimentalMaterial3Api::class) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt index a05b76f771..8731e72e07 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt @@ -207,32 +207,18 @@ inline fun BasicModalBottomSheet( noinline onBack: (() -> Unit)? = null, noinline bsContent: @Composable ColumnScope.() -> Unit, ) { - if (onBack != null) { - ModalBottomSheetWithBackHandling( - modifier = modifier, - onDismissRequest = config.onDismissRequest, - sheetState = sheetState, - containerColor = Color.Transparent, - shape = TangemTheme.shapes.roundedCornersLarge, - contentWindowInsets = { WindowInsetsZero }, - onBack = onBack, - dragHandle = null, - content = bsContent, - scrimColor = TangemTheme.colors.overlay.secondary, - ) - } else { - ModalBottomSheet( - modifier = modifier, - onDismissRequest = config.onDismissRequest, - sheetState = sheetState, - containerColor = Color.Transparent, - shape = TangemTheme.shapes.roundedCornersLarge, - contentWindowInsets = { WindowInsetsZero }, - dragHandle = null, - content = bsContent, - scrimColor = TangemTheme.colors.overlay.secondary, - ) - } + ModalBottomSheetWithBackHandling( + modifier = modifier, + onDismissRequest = config.onDismissRequest, + sheetState = sheetState, + containerColor = Color.Transparent, + shape = TangemTheme.shapes.roundedCornersLarge, + contentWindowInsets = { WindowInsetsZero }, + onBack = onBack, + dragHandle = null, + content = bsContent, + scrimColor = TangemTheme.colors.overlay.secondary, + ) } // region Preview diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt index 103aff8752..4f05ab4074 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt @@ -247,32 +247,18 @@ inline fun BasicModalBottomSheetWit } } - if (onBack != null) { - ModalBottomSheetWithBackHandling( - modifier = modifier, - onDismissRequest = config.onDismissRequest, - sheetState = sheetState, - containerColor = Color.Transparent, - shape = TangemTheme.shapes.roundedCornersLarge, - contentWindowInsets = { WindowInsetsZero }, - onBack = onBack, - dragHandle = null, - content = bsContent, - scrimColor = TangemTheme.colors.overlay.secondary, - ) - } else { - ModalBottomSheet( - modifier = modifier, - onDismissRequest = config.onDismissRequest, - sheetState = sheetState, - containerColor = Color.Transparent, - shape = TangemTheme.shapes.roundedCornersLarge, - contentWindowInsets = { WindowInsetsZero }, - dragHandle = null, - content = bsContent, - scrimColor = TangemTheme.colors.overlay.secondary, - ) - } + ModalBottomSheetWithBackHandling( + modifier = modifier, + onDismissRequest = config.onDismissRequest, + sheetState = sheetState, + containerColor = Color.Transparent, + shape = TangemTheme.shapes.roundedCornersLarge, + contentWindowInsets = { WindowInsetsZero }, + onBack = onBack, + dragHandle = null, + content = bsContent, + scrimColor = TangemTheme.colors.overlay.secondary, + ) } // region Preview diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt index 18bb77bdf7..d609afe29a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt @@ -2,7 +2,6 @@ package com.tangem.core.ui.components.bottomsheets.sheet import androidx.compose.foundation.layout.* import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue.Expanded import androidx.compose.material3.rememberModalBottomSheetState @@ -182,30 +181,16 @@ inline fun BasicBottomSheet( } } - if (onBack != null) { - ModalBottomSheetWithBackHandling( - modifier = modifier.statusBarsPadding(), - onDismissRequest = config.onDismissRequest, - sheetState = sheetState, - containerColor = containerColor, - shape = TangemTheme.shapes.bottomSheetLarge, - contentWindowInsets = { WindowInsetsZero }, - dragHandle = { TangemBottomSheetDraggableHeader(color = containerColor) }, - onBack = onBack, - content = bsContent, - scrimColor = TangemTheme.colors.overlay.secondary, - ) - } else { - ModalBottomSheet( - modifier = modifier.statusBarsPadding(), - onDismissRequest = config.onDismissRequest, - sheetState = sheetState, - containerColor = containerColor, - shape = TangemTheme.shapes.bottomSheetLarge, - contentWindowInsets = { WindowInsetsZero }, - dragHandle = { TangemBottomSheetDraggableHeader(color = containerColor) }, - content = bsContent, - scrimColor = TangemTheme.colors.overlay.secondary, - ) - } + ModalBottomSheetWithBackHandling( + modifier = modifier.statusBarsPadding(), + onDismissRequest = config.onDismissRequest, + sheetState = sheetState, + containerColor = containerColor, + shape = TangemTheme.shapes.bottomSheetLarge, + contentWindowInsets = { WindowInsetsZero }, + dragHandle = { TangemBottomSheetDraggableHeader(color = containerColor) }, + onBack = onBack, + content = bsContent, + scrimColor = TangemTheme.colors.overlay.secondary, + ) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/icons/IconTint.kt b/core/ui/src/main/java/com/tangem/core/ui/components/icons/IconTint.kt index db6a607f67..a41cdb95ef 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/icons/IconTint.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/icons/IconTint.kt @@ -7,4 +7,5 @@ enum class IconTint { Accent, Warning, Inactive, + Informative, } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt index 6b01ffbff7..3824de5dd7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt @@ -43,6 +43,9 @@ internal fun TokenFiatAmount(state: TokenFiatAmountState?, isBalanceHidden: Bool isFlickering = state.isFlickering, ) } + is TokenFiatAmountState.Icon -> { + IconAmount(modifier = modifier, state = state) + } is TokenFiatAmountState.Loading -> { RectangleShimmer(modifier = modifier.placeholderSize(), radius = 4.dp) } @@ -53,6 +56,16 @@ internal fun TokenFiatAmount(state: TokenFiatAmountState?, isBalanceHidden: Bool } } +@Composable +private fun IconAmount(state: TokenFiatAmountState.Icon, modifier: Modifier = Modifier) { + Icon( + modifier = modifier, + painter = rememberVectorPainter(image = ImageVector.vectorResource(state.iconRes)), + tint = state.tint.color(), + contentDescription = null, + ) +} + @Composable private fun ContentFiatAmount( text: String, @@ -76,11 +89,7 @@ private fun ContentFiatAmount( Icon( modifier = Modifier.size(12.dp), painter = rememberVectorPainter(image = ImageVector.vectorResource(icon.iconRes)), - tint = when (icon.tint) { - IconTint.Accent -> TangemTheme.colors.icon.accent - IconTint.Warning -> TangemTheme.colors.icon.attention - IconTint.Inactive -> TangemTheme.colors.icon.inactive - }, + tint = icon.tint.color(), contentDescription = null, ) } @@ -91,6 +100,14 @@ private fun ContentFiatAmount( } } +@Composable +private fun IconTint.color() = when (this) { + IconTint.Accent -> TangemTheme.colors.icon.accent + IconTint.Warning -> TangemTheme.colors.icon.attention + IconTint.Inactive -> TangemTheme.colors.icon.inactive + IconTint.Informative -> TangemTheme.colors.icon.informative +} + @Composable private fun FiatAmountText( text: String, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt index 7193eed339..72bc32e2b6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt @@ -235,6 +235,11 @@ sealed class TokenItemState { val isFlickering: Boolean = false, ) : FiatAmountState() + data class Icon( + val iconRes: Int, + val tint: IconTint = IconTint.Inactive, + ) : FiatAmountState() + data object Loading : FiatAmountState() data object Locked : FiatAmountState() diff --git a/features/send-v2/impl/build.gradle.kts b/features/send-v2/impl/build.gradle.kts index 32c849b301..66e97a0ebc 100644 --- a/features/send-v2/impl/build.gradle.kts +++ b/features/send-v2/impl/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { implementation(projects.domain.swap.models) implementation(projects.domain.account) implementation(projects.domain.account.status) + implementation(projects.domain.transaction) /** Compose libraries */ diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt index 807e95bcb3..8ac294c931 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt @@ -7,6 +7,7 @@ import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop import com.arkivanov.essenty.instancekeeper.getOrCreateSimple import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext @@ -16,6 +17,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.send.v2.api.FeeSelectorComponent import com.tangem.features.send.v2.api.params.FeeSelectorParams import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.extended.FeeExtendedSelectorComponent import com.tangem.features.send.v2.feeselector.component.speed.FeeSpeedSelectorComponent import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute @@ -29,13 +31,20 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: FeeSelectorParams.FeeSelectorDetailsParams, @Assisted private val onDismiss: () -> Unit, + private val feeExtendedSelectorComponent: FeeExtendedSelectorComponent.Factory, private val feeSpeedSelectorComponentFactory: FeeSpeedSelectorComponent.Factory, ) : FeeSelectorComponent, AppComponentContext by appComponentContext { private val stackNavigation = instanceKeeper.getOrCreateSimple { StackNavigation() } private val innerRouter = InnerRouter( stackNavigation = stackNavigation, - popCallback = { model.onChildBack() }, + popCallback = { + stackNavigation.pop { success -> + if (!success) { + dismiss() + } + } + }, ) private val model: FeeSelectorModel = getOrCreateModel(params = params, router = innerRouter) @@ -59,10 +68,11 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor( FeeSelectorModalBottomSheet( childStack = contentStack, - onDismiss = ::dismiss, state = state, feeSelectorIntents = model.intents, feeDisplaySource = params.feeDisplaySource, + onDismiss = onDismiss, + onBack = innerRouter::pop, ) } @@ -73,17 +83,21 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor( ) val componentParams = FeeSelectorComponentParams( + parentParams = params, state = model.uiState, intents = model.intents, ) return when (config) { + FeeSelectorRoute.NetworkFee -> feeExtendedSelectorComponent.create( + appComponentContext = appComponentContext, + params = componentParams, + ) FeeSelectorRoute.ChooseSpeed -> feeSpeedSelectorComponentFactory.create( appComponentContext = appComponentContext, params = componentParams, ) FeeSelectorRoute.ChooseToken -> TODO() - FeeSelectorRoute.NetworkFee -> TODO() } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/FeeSelectorComponentParams.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/FeeSelectorComponentParams.kt index cddc26216e..337a87186a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/FeeSelectorComponentParams.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/FeeSelectorComponentParams.kt @@ -1,10 +1,12 @@ package com.tangem.features.send.v2.feeselector.component import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.api.params.FeeSelectorParams import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents import kotlinx.coroutines.flow.MutableStateFlow internal class FeeSelectorComponentParams( + val parentParams: FeeSelectorParams.FeeSelectorDetailsParams, val state: MutableStateFlow, val intents: FeeSelectorIntents, ) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt new file mode 100644 index 0000000000..76f9baab92 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt @@ -0,0 +1,41 @@ +package com.tangem.features.send.v2.feeselector.component.extended + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +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.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.extended.model.FeeExtendedSelectorModel +import com.tangem.features.send.v2.feeselector.component.extended.ui.FeeExtendedSelectorContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class FeeExtendedSelectorComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: FeeSelectorComponentParams, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: FeeExtendedSelectorModel = getOrCreateModel(params = params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + FeeExtendedSelectorContent( + modifier = modifier, + state = state, + ) + } + + @AssistedFactory + interface Factory { + fun create( + appComponentContext: AppComponentContext, + params: FeeSelectorComponentParams, + ): FeeExtendedSelectorComponent + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/FeeExtendedSelectorUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/FeeExtendedSelectorUM.kt new file mode 100644 index 0000000000..a9406c3f73 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/FeeExtendedSelectorUM.kt @@ -0,0 +1,14 @@ +package com.tangem.features.send.v2.feeselector.component.extended.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.features.send.v2.api.entity.FeeItem +import com.tangem.features.send.v2.api.entity.FeeSelectorUM + +@Immutable +data class FeeExtendedSelectorUM( + val parent: FeeSelectorUM.Content, + val token: TokenItemState, + val fee: FeeItem, + val onFeeClick: () -> Unit, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/SelectedTokenItemConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/SelectedTokenItemConverter.kt new file mode 100644 index 0000000000..3652e62cc6 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/SelectedTokenItemConverter.kt @@ -0,0 +1,23 @@ +package com.tangem.features.send.v2.feeselector.component.extended.entity + +import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.utils.converter.Converter + +internal class SelectedTokenItemConverter( + appCurrency: AppCurrency, + onTokenClick: () -> Unit, +) : Converter { + + private val tokenItemConverter = TokenItemStateConverter( + appCurrency = appCurrency, + onItemClick = { _, _ -> onTokenClick() }, + ) + + override fun convert(value: CryptoCurrencyStatus): TokenItemState { + // TODO + return tokenItemConverter.convert(value) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt new file mode 100644 index 0000000000..4f586f70f0 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt @@ -0,0 +1,73 @@ +package com.tangem.features.send.v2.feeselector.component.extended.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +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.navigation.Router +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.extended.entity.FeeExtendedSelectorUM +import com.tangem.features.send.v2.feeselector.component.extended.entity.SelectedTokenItemConverter +import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +class FeeExtendedSelectorModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val router: Router, +) : Model() { + + private val params = paramsContainer.require() + private var appCurrency: AppCurrency = AppCurrency.Default + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + init { + modelScope.launch { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + uiState.value = getInitialState() + } + + params.state + .onEach(::onParentStateUpdated) + .launchIn(modelScope) + } + + private fun getInitialState(): FeeExtendedSelectorUM { + val status = params.parentParams.feeCryptoCurrencyStatus + val parentContentState = params.state.value as FeeSelectorUM.Content + + return FeeExtendedSelectorUM( + parent = parentContentState, + token = SelectedTokenItemConverter( + appCurrency = appCurrency, + onTokenClick = { + router.push(FeeSelectorRoute.ChooseToken) + }, + ).convert(status), + fee = parentContentState.selectedFeeItem, + onFeeClick = { + router.push(FeeSelectorRoute.ChooseSpeed) + }, + ) + } + + private fun onParentStateUpdated(state: FeeSelectorUM) { + uiState.update { current -> + current.copy( + parent = state as FeeSelectorUM.Content, + ) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt new file mode 100644 index 0000000000..ff39f78cdd --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt @@ -0,0 +1,154 @@ +package com.tangem.features.send.v2.feeselector.component.extended.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.amountScreen.utils.getFiatReference +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.icons.IconTint +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fee +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.send.v2.api.entity.* +import com.tangem.features.send.v2.feeselector.component.extended.entity.FeeExtendedSelectorUM +import com.tangem.features.send.v2.feeselector.component.speed.ui.RegularFeeItemContent +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal + +@Composable +fun FeeExtendedSelectorContent(state: FeeExtendedSelectorUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ) + .fillMaxWidth(), + ) { + TokenItem( + modifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.action), + state = state.token, + isBalanceHidden = false, + ) + + SpacerH8() + + val feeFiatRateUM = state.parent.feeFiatRateUM + val fee = state.fee.fee + + RegularFeeItemContent( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .clickable(onClick = state.onFeeClick) + .background( + TangemTheme.colors.background.action, + RoundedCornerShape(TangemTheme.dimens.radius14), + ), + title = state.fee.title, + iconRes = state.fee.iconRes, + iconBackgroundColor = TangemTheme.colors.icon.accent.copy(alpha = 0.1F), + iconTint = TangemTheme.colors.icon.accent, + preDot = stringReference( + fee.amount.value.format { + crypto( + symbol = fee.amount.currencySymbol, + decimals = fee.amount.decimals, + ).fee(canBeLower = state.parent.feeExtraInfo.isFeeApproximate) + }, + ), + postDot = if (state.parent.feeExtraInfo.isFeeConvertibleToFiat && feeFiatRateUM != null) { + getFiatReference( + value = fee.amount.value, + rate = feeFiatRateUM.rate, + appCurrency = feeFiatRateUM.appCurrency, + ) + } else { + null + }, + ellipsizeOffset = fee.amount.currencySymbol.length, + showSelectorIcon = true, + ) + } +} + +@Preview +@Composable +private fun Preview() { + val tokenItemState = TokenItemState.Content( + id = "1", + iconState = CurrencyIconState.Locked, + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Bitcoin")), + fiatAmountState = TokenItemState.FiatAmountState.Icon( + R.drawable.ic_select_18_24, + tint = IconTint.Informative, + ), + subtitle2State = null, + subtitleState = TokenItemState.SubtitleState.TextContent( + value = stringReference("Balance: 0,35853044 BTC"), + isAvailable = false, + ), + onItemClick = {}, + onItemLongClick = {}, + ) + + val lowFeeItem = + FeeItem.Market(Fee.Common(amount = Amount(value = BigDecimal("0.0002876"), blockchain = Blockchain.Ethereum))) + + TangemThemePreview { + FeeExtendedSelectorContent( + modifier = Modifier.background(TangemTheme.colors.background.tertiary), + state = FeeExtendedSelectorUM( + parent = FeeSelectorUM.Content( + isPrimaryButtonEnabled = true, + feeItems = persistentListOf(lowFeeItem), + selectedFeeItem = lowFeeItem, + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = false, + isFeeConvertibleToFiat = true, + isTronToken = false, + ), + feeNonce = FeeNonce.None, + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal("2500"), + appCurrency = AppCurrency.Default, + ), + fees = TransactionFee.Single(lowFeeItem.fee), + ), + token = tokenItemState, + fee = FeeItem.Market( + fee = Fee.Common( + Amount( + value = BigDecimal("0.02"), + blockchain = Blockchain.Ethereum, + ), + ), + ), + onFeeClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt index 87eab44b5c..2a4a9e6bd5 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt @@ -16,9 +16,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.* import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign @@ -35,6 +37,7 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.ui.amountScreen.utils.getFiatReference import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.components.inputrow.InputRowEnter @@ -309,7 +312,7 @@ private fun ExpandedCustomFeeItems( } @Composable -private fun RegularFeeItemContent( +internal fun RegularFeeItemContent( title: TextReference, @DrawableRes iconRes: Int, iconBackgroundColor: Color, @@ -318,6 +321,7 @@ private fun RegularFeeItemContent( preDot: TextReference? = null, postDot: TextReference? = null, ellipsizeOffset: Int? = null, + showSelectorIcon: Boolean = false, ) { Column(modifier = modifier) { Row( @@ -343,6 +347,16 @@ private fun RegularFeeItemContent( postDot = postDot, ellipsizeOffset = ellipsizeOffset, ) + + if (showSelectorIcon) { + SpacerWMax() + + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_select_18_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + } } } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt index 295b4f178e..52552917e3 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt @@ -2,6 +2,7 @@ package com.tangem.features.send.v2.feeselector.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model +import com.tangem.features.send.v2.feeselector.component.extended.model.FeeExtendedSelectorModel import com.tangem.features.send.v2.feeselector.model.FeeSelectorBlockModel import com.tangem.features.send.v2.feeselector.component.speed.model.FeeSpeedSelectorModel import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel @@ -19,6 +20,11 @@ internal interface FeeSelectorModelModule { @ClassKey(FeeSelectorModel::class) fun bindModel(model: FeeSelectorModel): Model + @Binds + @IntoMap + @ClassKey(FeeExtendedSelectorModel::class) + fun bindExtendedModel(model: FeeExtendedSelectorModel): Model + @Binds @IntoMap @ClassKey(FeeSelectorBlockModel::class) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt index 706881dbe0..5f82459764 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt @@ -14,8 +14,8 @@ import javax.inject.Inject @ModelScoped internal class FeeSelectorModel @Inject constructor( paramsContainer: ParamsContainer, + feeSelectorLogicFactory: FeeSelectorLogic.Factory, override val dispatchers: CoroutineDispatcherProvider, - private val feeSelectorLogicFactory: FeeSelectorLogic.Factory, private val sendFeatureToggles: SendFeatureToggles, ) : Model() { @@ -28,16 +28,12 @@ internal class FeeSelectorModel @Inject constructor( val uiState = feeSelectorLogic.uiState val intents: FeeSelectorIntents = feeSelectorLogic - fun onChildBack() { - // TODO("Not yet implemented") - } - fun getInitialRoute(): FeeSelectorRoute { if (sendFeatureToggles.isGaslessTransactionsEnabled.not()) { return FeeSelectorRoute.ChooseSpeed } // TODO: Add logic to choose initial route based on other conditions - return FeeSelectorRoute.ChooseSpeed + return FeeSelectorRoute.NetworkFee } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt index 7bf68aab96..7deca5c48b 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt @@ -27,6 +27,7 @@ import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute import com.tangem.features.send.v2.impl.R +@Suppress("LongParameterList") @Composable internal fun FeeSelectorModalBottomSheet( childStack: ChildStack, @@ -34,6 +35,7 @@ internal fun FeeSelectorModalBottomSheet( feeSelectorIntents: FeeSelectorIntents, feeDisplaySource: FeeSelectorParams.FeeDisplaySource, onDismiss: () -> Unit, + onBack: () -> Unit, ) { if (state !is FeeSelectorUM.Content) return @@ -44,25 +46,44 @@ internal fun FeeSelectorModalBottomSheet( content = TangemBottomSheetConfigContent.Empty, ), containerColor = TangemTheme.colors.background.tertiary, + onBack = if (childStack.backStack.isNotEmpty()) onBack else null, title = { - FeeTitle(childStack = childStack, feeDisplaySource = feeDisplaySource, onDismiss = onDismiss) + FeeTitle( + childStack = childStack, + feeDisplaySource = feeDisplaySource, + onBack = onBack, + ) }, content = { Content(childStack = childStack) }, - footer = if (state.selectedFeeItem is FeeItem.Custom) { - { - PrimaryButton( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - enabled = state.isPrimaryButtonEnabled, - text = stringResourceSafe(R.string.common_done), - onClick = feeSelectorIntents::onDoneClick, - ) + footer = when { + childStack.active.configuration is FeeSelectorRoute.NetworkFee -> { + { + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + enabled = state.isPrimaryButtonEnabled, + text = stringResourceSafe(R.string.common_confirm), + onClick = feeSelectorIntents::onDoneClick, + ) + } } - } else { - null + childStack.active.configuration is FeeSelectorRoute.ChooseSpeed && + state.selectedFeeItem is FeeItem.Custom -> { + { + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + enabled = state.isPrimaryButtonEnabled, + text = stringResourceSafe(R.string.common_done), + onClick = feeSelectorIntents::onDoneClick, + ) + } + } + else -> null }, ) } @@ -71,7 +92,7 @@ internal fun FeeSelectorModalBottomSheet( private fun FeeTitle( childStack: ChildStack, feeDisplaySource: FeeSelectorParams.FeeDisplaySource, - onDismiss: () -> Unit, + onBack: () -> Unit, ) { Children( stack = childStack, @@ -79,17 +100,25 @@ private fun FeeTitle( ) { current -> when (feeDisplaySource) { FeeSelectorParams.FeeDisplaySource.Screen -> { - TangemModalBottomSheetTitle( - title = current.configuration.title, - endIconRes = R.drawable.ic_close_24, - onEndClick = onDismiss, - ) + if (childStack.backStack.isNotEmpty()) { + TangemModalBottomSheetTitle( + title = current.configuration.title, + startIconRes = R.drawable.ic_back_24, + onStartClick = onBack, + ) + } else { + TangemModalBottomSheetTitle( + title = current.configuration.title, + endIconRes = R.drawable.ic_close_24, + onEndClick = onBack, + ) + } } FeeSelectorParams.FeeDisplaySource.BottomSheet -> { TangemModalBottomSheetTitle( title = current.configuration.title, startIconRes = R.drawable.ic_back_24, - onStartClick = onDismiss, + onStartClick = onBack, ) } } From 680794d447e6de5bc9cd3f541d65e9fcaec6d920 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 6 Jan 2026 13:15:40 +0300 Subject: [PATCH 059/172] Updated on 2026-08-14 --- .../api/gasless/GaslessTxServiceApi.kt | 4 +- ...t => GaslessSignedTransactionResultDTO.kt} | 2 +- .../models/GaslessTransactionRequest.kt | 4 +- .../DefaultGaslessTransactionRepository.kt | 35 ++++++++++ ...GaslessSignedTransactionResultConverter.kt | 22 +++++++ .../GaslessTransactionRequestBuilder.kt | 55 ++++++++++++++++ .../GaslessTxDataToGaslessRequestConverter.kt | 43 ++++++++++++ .../GaslessTransactionRepository.kt | 47 +++++++++++++ .../models/Eip7702Authorization.kt | 36 ++++++++++ .../models/GaslessSignedTransactionResult.kt | 37 +++++++++++ .../models/GaslessTransactionData.kt | 66 +++++++++++++++++++ .../CreateAndSendGaslessTransactionUseCase.kt | 11 ++++ 12 files changed, 357 insertions(+), 5 deletions(-) rename core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/{GaslessSignedTransactionResult.kt => GaslessSignedTransactionResultDTO.kt} (92%) create mode 100644 data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessSignedTransactionResultConverter.kt create mode 100644 data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt create mode 100644 data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/models/Eip7702Authorization.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessSignedTransactionResult.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt index fcb9fe8c20..193dc620e8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt @@ -2,7 +2,7 @@ package com.tangem.datasource.api.gasless import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.gasless.models.GaslessServiceResponse -import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResult +import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO import com.tangem.datasource.api.gasless.models.GaslessSupportedTokens import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest import retrofit2.http.Body @@ -17,5 +17,5 @@ interface GaslessTxServiceApi { @POST("api/v1/sign") suspend fun signGaslessTransaction( @Body transaction: GaslessTransactionRequest, - ): ApiResponse> + ): ApiResponse> } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResult.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResultDTO.kt similarity index 92% rename from core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResult.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResultDTO.kt index 0a2fc092de..c8bd1b89b4 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResult.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResultDTO.kt @@ -8,7 +8,7 @@ import com.squareup.moshi.JsonClass * Contains the signed transaction data and gas parameters. */ @JsonClass(generateAdapter = true) -data class GaslessSignedTransactionResult( +data class GaslessSignedTransactionResultDTO( @Json(name = "signedTransaction") val signedTransaction: String, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt index f5f40bd9df..948567af63 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt @@ -22,7 +22,7 @@ data class GaslessTransactionRequest( val chainId: Int, @Json(name = "eip7702auth") - val eip7702Auth: Eip7702Authorization? = null, + val eip7702Auth: Eip7702AuthorizationDTO? = null, ) @JsonClass(generateAdapter = true) @@ -72,7 +72,7 @@ data class FeeData( * Optional field, used only when EOA delegation is required. */ @JsonClass(generateAdapter = true) -data class Eip7702Authorization( +data class Eip7702AuthorizationDTO( @Json(name = "chainId") val chainId: Int, diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt index 19822060a2..e68999f26f 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -4,11 +4,16 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter +import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.gasless.GaslessTxServiceApi import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessSignedTransactionResult +import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -22,6 +27,8 @@ class DefaultGaslessTransactionRepository( ) : GaslessTransactionRepository { private val supportedTokensState = MutableStateFlow?>(null) + private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder() + private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter() override fun isNetworkSupported(network: Network): Boolean { val blockchain = Blockchain.fromNetworkId(network.backendId) ?: return false @@ -63,6 +70,34 @@ class DefaultGaslessTransactionRepository( return TOKEN_RECEIVER_ADDRESS } + override suspend fun sendGaslessTransaction( + gaslessTransactionData: GaslessTransactionData, + signature: String, + userAddress: String, + network: Network, + eip7702Auth: Eip7702Authorization?, + ): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) { + val blockchain = Blockchain.fromNetworkId(network.backendId) + ?: error("Cannot determine blockchain for network id: ${network.backendId}") + + val transactionRequest = gaslessTransactionRequestBuilder.build( + gaslessTransaction = gaslessTransactionData, + signature = signature, + userAddress = userAddress, + chainId = blockchain.getChainId() ?: error("ChainId is null for blockchain: $blockchain"), + eip7702Auth = eip7702Auth, + ) + + val response = gaslessTxServiceApi.signGaslessTransaction(transactionRequest).getOrThrow() + + if (!response.isSuccess) { + error("Gasless service returned unsuccessful response") + } + + // Convert DTO to domain model + signedTransactionResultConverter.convert(response.result) + } + override fun getBaseGasForTransaction(): BigInteger { return BASE_GAS_FOR_TRANSACTION } diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessSignedTransactionResultConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessSignedTransactionResultConverter.kt new file mode 100644 index 0000000000..4c8a6e62ef --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessSignedTransactionResultConverter.kt @@ -0,0 +1,22 @@ +package com.tangem.data.transaction.convertes + +import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO as GaslessSignedTransactionResultDTO +import com.tangem.domain.transaction.models.GaslessSignedTransactionResult +import com.tangem.utils.converter.Converter + +/** + * Converts DTO GaslessSignedTransactionResult from API to domain model. + * Transforms string representations of gas parameters to BigInteger for type safety. + */ +class GaslessSignedTransactionResultConverter : + Converter { + + override fun convert(value: GaslessSignedTransactionResultDTO): GaslessSignedTransactionResult { + return GaslessSignedTransactionResult( + signedTransaction = value.signedTransaction, + gasLimit = value.gasLimit.toBigInteger(), + maxFeePerGas = value.maxFeePerGas.toBigInteger(), + maxPriorityFeePerGas = value.maxPriorityFeePerGas.toBigInteger(), + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt new file mode 100644 index 0000000000..b7caa4b13a --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt @@ -0,0 +1,55 @@ +package com.tangem.data.transaction.convertes + +import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest +import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessTransactionData +import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO + +/** + * Builder for creating complete GaslessTransactionRequest from domain model. + * Combines transaction data with signature and user information. + */ +class GaslessTransactionRequestBuilder( + private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(), +) { + + /** + * Creates complete gasless transaction request. + * + * @param gaslessTransaction domain model of transaction + * @param signature transaction signature in hex format (with 0x prefix) + * @param userAddress user's Ethereum address + * @param chainId blockchain network chain ID + * @param eip7702Auth optional EIP-7702 authorization for account abstraction + * @return complete request ready for API submission + */ + fun build( + gaslessTransaction: GaslessTransactionData, + signature: String, + userAddress: String, + chainId: Int, + eip7702Auth: Eip7702Authorization? = null, + ): GaslessTransactionRequest { + return GaslessTransactionRequest( + gaslessTransaction = converter.convert(gaslessTransaction), + signature = signature, + userAddress = userAddress, + chainId = chainId, + eip7702Auth = eip7702Auth?.toDTO(), + ) + } + + /** + * Converts domain Eip7702Authorization to DTO. + */ + private fun Eip7702Authorization.toDTO(): Eip7702AuthorizationDTO { + return Eip7702AuthorizationDTO( + chainId = chainId, + address = address, + nonce = nonce.toString(), + yParity = yParity, + r = r, + s = s, + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt new file mode 100644 index 0000000000..16b1683a11 --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt @@ -0,0 +1,43 @@ +package com.tangem.data.transaction.convertes + +import com.tangem.datasource.api.gasless.models.FeeData +import com.tangem.datasource.api.gasless.models.TransactionData +import com.tangem.domain.transaction.models.GaslessTransactionData +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.toHexString +import com.tangem.datasource.api.gasless.models.GaslessTransactionData as GaslessTransactionDataDTO + +/** + * Converts domain GaslessTransactionData to DTO for API requests. + * Note: This converter only handles the transaction data conversion. + * Additional fields (signature, userAddress, chainId) must be added separately + * to create complete GaslessTransactionRequest. + */ +class GaslessTxDataToGaslessRequestConverter : Converter { + + override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO { + return GaslessTransactionDataDTO( + transaction = convertTransaction(value.transaction), + fee = convertFee(value.fee), + nonce = value.nonce.toString(), + ) + } + + private fun convertTransaction(transaction: com.tangem.domain.transaction.models.Transaction): TransactionData { + return TransactionData( + to = transaction.to, + value = transaction.value.toString(), + data = transaction.data.toHexString(), + ) + } + + private fun convertFee(fee: com.tangem.domain.transaction.models.Fee): FeeData { + return FeeData( + feeToken = fee.feeToken, + maxTokenFee = fee.maxTokenFee.toString(), + coinPriceInToken = fee.coinPriceInToken.toString(), + feeTransferGasLimit = fee.feeTransferGasLimit.toString(), + baseGas = fee.baseGas.toString(), + ) + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt index 728a81f317..84074cf002 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt @@ -2,6 +2,9 @@ package com.tangem.domain.transaction import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessSignedTransactionResult +import com.tangem.domain.transaction.models.GaslessTransactionData import java.math.BigInteger interface GaslessTransactionRepository { @@ -12,6 +15,50 @@ interface GaslessTransactionRepository { fun getTokenFeeReceiverAddress(): String + /** + * Sends gasless transaction to the gasless service for signing and returns the signed result. + * + * Flow: + * 1. User signs the gasless transaction data locally with their private key + * 2. This method submits the transaction + user signature to the gasless service + * 3. Service validates the signature and transaction data + * 4. Service adds its own signature for fee delegation (pays gas in tokens) + * 5. Service constructs the final EIP-1559 transaction with all signatures + * 6. Service returns the fully signed transaction ready for broadcasting + * + * The gasless service acts as a relayer that: + * - Pays network gas fees on behalf of the user + * - Receives payment in the specified token from the user's wallet + * - Ensures atomic execution (either both transfers succeed or both fail) + * + * @param gaslessTransactionData domain model containing: + * - transaction: target contract call data (to, value, data) + * - fee: token payment configuration (feeToken, maxTokenFee, etc.) + * - nonce: user's contract nonce to prevent replay attacks + * @param signature user's ECDSA signature of the gasless transaction in hex format (0x...) + * Signs keccak256 hash of the transaction data + * @param userAddress user's Ethereum address (EOA or contract wallet) + * @param network blockchain network (Ethereum, Polygon, BSC, etc.) + * Used to determine chainId for the request + * @param eip7702Auth optional EIP-7702 authorization for EOA delegation to smart contract + * Required only when user's EOA needs to temporarily act as contract wallet + * Contains signature authorizing delegation to entry point contract + * @return [GaslessSignedTransactionResult] containing: + * - signedTransaction: complete RLP-encoded transaction ready to broadcast + * - gasLimit: actual gas limit allocated by the service + * - maxFeePerGas: maximum fee per gas (base + priority) in wei + * - maxPriorityFeePerGas: tip for validators in wei (EIP-1559) + * @throws IllegalStateException if network is not supported or chainId cannot be determined + * @throws Exception if service returns error or network request fails + */ + suspend fun sendGaslessTransaction( + gaslessTransactionData: GaslessTransactionData, + signature: String, + userAddress: String, + network: Network, + eip7702Auth: Eip7702Authorization? = null, + ): GaslessSignedTransactionResult + /** * Hardcoded value as baseGas */ diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/Eip7702Authorization.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/Eip7702Authorization.kt new file mode 100644 index 0000000000..88e835c1cd --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/Eip7702Authorization.kt @@ -0,0 +1,36 @@ +package com.tangem.domain.transaction.models + +import java.math.BigInteger + +/** + * EIP-7702 authorization for account abstraction. + * Used for delegating EOA (Externally Owned Account) control to a smart contract. + * + * EIP-7702 allows EOAs to temporarily act as smart contract wallets by delegating + * their authority to a contract address for a specific transaction. + * + * @property chainId blockchain network chain ID + * @property address contract address to delegate authority to (entry point contract) + * @property nonce authorization nonce to prevent replay attacks + * @property yParity recovery ID for signature (0 or 1) + * @property r ECDSA signature component R + * @property s ECDSA signature component S + * + * @see EIP-7702 Specification + */ +data class Eip7702Authorization( + val chainId: Int, + val address: String, + val nonce: BigInteger, + val yParity: Int, + val r: String, + val s: String, +) { + init { + require(chainId > 0) { "Chain ID must be positive" } + require(address.isNotBlank()) { "Address must not be blank" } + require(yParity in 0..1) { "yParity must be 0 or 1" } + require(r.isNotBlank()) { "Signature component R must not be blank" } + require(s.isNotBlank()) { "Signature component S must not be blank" } + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessSignedTransactionResult.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessSignedTransactionResult.kt new file mode 100644 index 0000000000..3d41d103dd --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessSignedTransactionResult.kt @@ -0,0 +1,37 @@ +package com.tangem.domain.transaction.models + +import java.math.BigInteger + +/** + * Result of gasless transaction signing from the gasless service. + * Contains the fully signed transaction ready for broadcasting and gas parameters. + * + * After the user signs the gasless transaction data locally, the service: + * 1. Validates the signature + * 2. Adds its own signature for fee delegation + * 3. Constructs the final transaction + * 4. Returns this signed transaction with gas parameters + * + * @property signedTransaction complete signed transaction in RLP-encoded hex format (0x...) + * ready to be broadcast to the blockchain network + * @property gasLimit maximum amount of gas units allocated for the transaction + * @property maxFeePerGas maximum total fee per gas unit (base fee + priority fee) in wei + * @property maxPriorityFeePerGas maximum priority fee (tip) per gas unit in wei for EIP-1559 + */ +data class GaslessSignedTransactionResult( + val signedTransaction: String, + val gasLimit: BigInteger, + val maxFeePerGas: BigInteger, + val maxPriorityFeePerGas: BigInteger, +) { + init { + require(signedTransaction.isNotBlank()) { "Signed transaction must not be blank" } + require(signedTransaction.startsWith("0x")) { "Signed transaction must be in hex format with 0x prefix" } + require(gasLimit > BigInteger.ZERO) { "Gas limit must be positive" } + require(maxFeePerGas > BigInteger.ZERO) { "Max fee per gas must be positive" } + require(maxPriorityFeePerGas >= BigInteger.ZERO) { "Max priority fee per gas must be non-negative" } + require(maxPriorityFeePerGas <= maxFeePerGas) { + "Max priority fee per gas cannot exceed max fee per gas" + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt new file mode 100644 index 0000000000..c32dce0495 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt @@ -0,0 +1,66 @@ +package com.tangem.domain.transaction.models + +import java.math.BigInteger + +/** + * Domain model for gasless transaction. + * Represents complete transaction data with fee delegation metadata. + */ +data class GaslessTransactionData( + /** Transaction details */ + val transaction: Transaction, + /** Fee payment configuration */ + val fee: Fee, + /** Nonce from user's contract */ + val nonce: BigInteger, +) + +/** + * Core transaction data. + * + * @property to destination address + * @property value transaction value in wei (currently always 0 for gasless) + * @property data encoded transaction data (contract call) + */ +data class Transaction( + val to: String, + val value: BigInteger, + val data: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Transaction + + if (to != other.to) return false + if (value != other.value) return false + if (!data.contentEquals(other.data)) return false + + return true + } + + override fun hashCode(): Int { + var result = to.hashCode() + result = 31 * result + value.hashCode() + result = 31 * result + data.contentHashCode() + return result + } +} + +/** + * Fee configuration for gasless transaction. + * + * @property feeToken token address used for fee payment + * @property maxTokenFee maximum fee amount in fee token units + * @property coinPriceInToken price of native coin in fee token units + * @property feeTransferGasLimit gas limit for fee token transfer + * @property baseGas base gas cost (currently constant BASE_GAS, may vary in future) + */ +data class Fee( + val feeToken: String, + val maxTokenFee: BigInteger, + val coinPriceInToken: BigInteger, + val feeTransferGasLimit: BigInteger, + val baseGas: BigInteger, +) \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt new file mode 100644 index 0000000000..941749629f --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.transaction.usecase.gasless + +import com.tangem.blockchain.common.TransactionData +import com.tangem.domain.transaction.models.TransactionFeeExtended + +class CreateAndSendGaslessTransactionUseCase { + + operator fun invoke(transactionData: TransactionData, fee: TransactionFeeExtended) { + // Implementation goes here + } +} \ No newline at end of file From a61004a3c1d3e1f23a46714b6eeff0504dde1d4c Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 6 Jan 2026 13:44:56 +0300 Subject: [PATCH 060/172] Updated on 2026-08-14 --- .../tangem/core/decompose/utils/Extensions.kt | 18 +++ features/send-v2/api/build.gradle.kts | 3 + .../send/v2/api/entity/FeeSelectorUM.kt | 2 + .../send/v2/api/params/FeeSelectorParams.kt | 4 + .../DefaultFeeSelectorComponent.kt | 19 ++- .../entity/SelectedTokenItemConverter.kt | 54 +++++++-- .../model/FeeExtendedSelectorModel.kt | 39 ++++--- .../extended/ui/FeeExtendedSelectorContent.kt | 33 ++++++ .../speed/ui/FeeSpeedSelectorContent.kt | 110 ++++++++++++------ .../model/FeeSelectorBlockModel.kt | 4 +- .../v2/feeselector/model/FeeSelectorLogic.kt | 83 ++++++++++--- .../v2/feeselector/model/FeeSelectorModel.kt | 58 ++++++++- .../FeeSelectorLoadedTransformer.kt | 15 ++- .../v2/feeselector/route/FeeSelectorRoute.kt | 2 +- .../feeselector/ui/FeeSelectorBlockContent.kt | 52 ++++++++- ...firmationNotificationsTransformerV2Test.kt | 34 ++++++ ...firmationNotificationsTransformerV2Test.kt | 34 ++++++ .../success/ui/SendWithSwapSuccessContent.kt | 1 + 18 files changed, 459 insertions(+), 106 deletions(-) create mode 100644 core/decompose/src/main/kotlin/com/tangem/core/decompose/utils/Extensions.kt diff --git a/core/decompose/src/main/kotlin/com/tangem/core/decompose/utils/Extensions.kt b/core/decompose/src/main/kotlin/com/tangem/core/decompose/utils/Extensions.kt new file mode 100644 index 0000000000..f21a549bec --- /dev/null +++ b/core/decompose/src/main/kotlin/com/tangem/core/decompose/utils/Extensions.kt @@ -0,0 +1,18 @@ +package com.tangem.core.decompose.utils + +import com.arkivanov.decompose.router.stack.StackNavigation +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * Retrieves the current stack of items from the [com.arkivanov.decompose.router.stack.ChildStack] + * @return A list representing the current stack of items + */ +suspend fun StackNavigation.stack(): List { + return suspendCancellableCoroutine { continuation -> + this.navigate( + transformer = { stack -> stack }, + onComplete = { newStack, oldStack -> continuation.resume(newStack) }, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/api/build.gradle.kts b/features/send-v2/api/build.gradle.kts index 454fcee782..a44ab7f1f9 100644 --- a/features/send-v2/api/build.gradle.kts +++ b/features/send-v2/api/build.gradle.kts @@ -21,6 +21,9 @@ dependencies { /** Common */ implementation(projects.common.ui) + /** Domain */ + implementation(projects.domain.transaction) + /** Domain models */ api(projects.domain.models) implementation(projects.domain.appCurrency.models) diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt index 89ddb338b6..40c2047420 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt @@ -7,6 +7,7 @@ import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError import com.tangem.features.send.v2.api.R import com.tangem.features.send.v2.api.entity.FeeItem.* @@ -61,6 +62,7 @@ data class FeeExtraInfo( val isFeeApproximate: Boolean, val isFeeConvertibleToFiat: Boolean, val isTronToken: Boolean, + val feeCryptoCurrencyStatus: CryptoCurrencyStatus, ) sealed class FeeNonce { diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt index 2c1ae24ec6..d45422c572 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt @@ -6,12 +6,14 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback import com.tangem.features.send.v2.api.entity.FeeSelectorUM sealed class FeeSelectorParams { abstract val state: FeeSelectorUM + abstract val onLoadFeeExtended: (suspend () -> Either)? abstract val onLoadFee: suspend () -> Either abstract val cryptoCurrencyStatus: CryptoCurrencyStatus abstract val feeCryptoCurrencyStatus: CryptoCurrencyStatus @@ -22,6 +24,7 @@ sealed class FeeSelectorParams { data class FeeSelectorBlockParams( override val state: FeeSelectorUM, + override val onLoadFeeExtended: (suspend () -> Either)? = null, override val onLoadFee: suspend () -> Either, override val cryptoCurrencyStatus: CryptoCurrencyStatus, override val feeCryptoCurrencyStatus: CryptoCurrencyStatus, @@ -33,6 +36,7 @@ sealed class FeeSelectorParams { data class FeeSelectorDetailsParams( override val state: FeeSelectorUM, + override val onLoadFeeExtended: (suspend () -> Either)? = null, override val onLoadFee: suspend () -> Either, override val cryptoCurrencyStatus: CryptoCurrencyStatus, override val feeCryptoCurrencyStatus: CryptoCurrencyStatus, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt index 8ac294c931..5232daa424 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt @@ -5,10 +5,8 @@ import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState -import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.pop -import com.arkivanov.essenty.instancekeeper.getOrCreateSimple import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel @@ -26,7 +24,6 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -@Suppress("PropertyUsedBeforeDeclaration") internal class DefaultFeeSelectorComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: FeeSelectorParams.FeeSelectorDetailsParams, @@ -35,22 +32,22 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor( private val feeSpeedSelectorComponentFactory: FeeSpeedSelectorComponent.Factory, ) : FeeSelectorComponent, AppComponentContext by appComponentContext { - private val stackNavigation = instanceKeeper.getOrCreateSimple { StackNavigation() } - private val innerRouter = InnerRouter( - stackNavigation = stackNavigation, + private val model: FeeSelectorModel = getOrCreateModel(params = params) + + val innerRouter = InnerRouter( + stackNavigation = model.stackNavigation, popCallback = { - stackNavigation.pop { success -> + model.stackNavigation.pop { success -> if (!success) { dismiss() } } }, ) - private val model: FeeSelectorModel = getOrCreateModel(params = params, router = innerRouter) private val contentStack = childStack( key = "FeeSelectorDetailsStack", - source = stackNavigation, + source = model.stackNavigation, serializer = FeeSelectorRoute.serializer(), initialConfiguration = model.getInitialRoute(), handleBackButton = false, @@ -69,7 +66,7 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor( FeeSelectorModalBottomSheet( childStack = contentStack, state = state, - feeSelectorIntents = model.intents, + feeSelectorIntents = model, feeDisplaySource = params.feeDisplaySource, onDismiss = onDismiss, onBack = innerRouter::pop, @@ -85,7 +82,7 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor( val componentParams = FeeSelectorComponentParams( parentParams = params, state = model.uiState, - intents = model.intents, + intents = model, ) return when (config) { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/SelectedTokenItemConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/SelectedTokenItemConverter.kt index 3652e62cc6..0959d98daf 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/SelectedTokenItemConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/SelectedTokenItemConverter.kt @@ -1,23 +1,59 @@ package com.tangem.features.send.v2.feeselector.component.extended.entity import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.ui.components.icons.IconTint import com.tangem.core.ui.components.token.state.TokenItemState +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.CryptoCurrencyStatus +import com.tangem.features.send.v2.impl.R +import com.tangem.utils.Provider import com.tangem.utils.converter.Converter internal class SelectedTokenItemConverter( - appCurrency: AppCurrency, - onTokenClick: () -> Unit, + private val appCurrencyProvider: Provider, + private val onTokenClick: () -> Unit, ) : Converter { - private val tokenItemConverter = TokenItemStateConverter( - appCurrency = appCurrency, - onItemClick = { _, _ -> onTokenClick() }, - ) - override fun convert(value: CryptoCurrencyStatus): TokenItemState { - // TODO - return tokenItemConverter.convert(value) + if (value.value !is CryptoCurrencyStatus.Loaded) { + return TokenItemState.Loading(id = value.currency.id.value) + } + + val appCurrency = appCurrencyProvider() + val tokenItemConverter = TokenItemStateConverter( + appCurrency = appCurrencyProvider(), + onItemClick = { _, _ -> onTokenClick() }, + ) + + val state = tokenItemConverter.convert(value) + + return when (state) { + is TokenItemState.Content -> { + val balance = value.value.fiatAmount.format { + fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) + } + + state.copy( + fiatAmountState = TokenItemState.FiatAmountState.Icon( + iconRes = R.drawable.ic_select_18_24, + tint = IconTint.Informative, + ), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = resourceReference( + R.string.common_balance, + wrappedList(balance), + ), + isAvailable = false, + ), + subtitle2State = null, + + ) + } + else -> state + } } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt index 4f586f70f0..2a8728c552 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt @@ -13,6 +13,7 @@ import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentPar import com.tangem.features.send.v2.feeselector.component.extended.entity.FeeExtendedSelectorUM import com.tangem.features.send.v2.feeselector.component.extended.entity.SelectedTokenItemConverter import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute +import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -29,44 +30,48 @@ class FeeExtendedSelectorModel @Inject constructor( private val params = paramsContainer.require() private var appCurrency: AppCurrency = AppCurrency.Default + private val tokenConverter = SelectedTokenItemConverter( + appCurrencyProvider = Provider { appCurrency }, + onTokenClick = { router.push(FeeSelectorRoute.ChooseToken) }, + ) + + init { + initAppCurrency() + } val uiState: StateFlow field = MutableStateFlow(getInitialState()) init { - modelScope.launch { - appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } - uiState.value = getInitialState() - } - params.state + .filterIsInstance() .onEach(::onParentStateUpdated) .launchIn(modelScope) } private fun getInitialState(): FeeExtendedSelectorUM { - val status = params.parentParams.feeCryptoCurrencyStatus val parentContentState = params.state.value as FeeSelectorUM.Content return FeeExtendedSelectorUM( parent = parentContentState, - token = SelectedTokenItemConverter( - appCurrency = appCurrency, - onTokenClick = { - router.push(FeeSelectorRoute.ChooseToken) - }, - ).convert(status), + token = tokenConverter.convert(parentContentState.feeExtraInfo.feeCryptoCurrencyStatus), fee = parentContentState.selectedFeeItem, - onFeeClick = { - router.push(FeeSelectorRoute.ChooseSpeed) - }, + onFeeClick = { router.push(FeeSelectorRoute.ChooseSpeed) }, ) } - private fun onParentStateUpdated(state: FeeSelectorUM) { + private fun initAppCurrency() { + modelScope.launch { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + } + } + + private fun onParentStateUpdated(state: FeeSelectorUM.Content) { uiState.update { current -> current.copy( - parent = state as FeeSelectorUM.Content, + parent = state, + token = tokenConverter.convert(state.feeExtraInfo.feeCryptoCurrencyStatus), + fee = state.selectedFeeItem, ) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt index ff39f78cdd..a94b018eae 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt @@ -29,6 +29,9 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview 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.v2.api.entity.* import com.tangem.features.send.v2.feeselector.component.extended.entity.FeeExtendedSelectorUM import com.tangem.features.send.v2.feeselector.component.speed.ui.RegularFeeItemContent @@ -95,6 +98,7 @@ fun FeeExtendedSelectorContent(state: FeeExtendedSelectorUM, modifier: Modifier } } +@Suppress("LongMethod") @Preview @Composable private fun Preview() { @@ -115,6 +119,34 @@ private fun Preview() { onItemLongClick = {}, ) + val cryptoCurrencyStatus = CryptoCurrencyStatus( + currency = CryptoCurrency.Coin( + id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), + network = Network( + id = Network.ID( + value = "bitcoin", + derivationPath = Network.DerivationPath.None, + ), + backendId = "bitcoin", + name = "Bitcoin", + currencySymbol = "BTC", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("bitcoin"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ), + name = "Bitcoin", + symbol = "BTC", + decimals = 8, + iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png", + isCustom = false, + ), + value = CryptoCurrencyStatus.Loading, + ) + val lowFeeItem = FeeItem.Market(Fee.Common(amount = Amount(value = BigDecimal("0.0002876"), blockchain = Blockchain.Ethereum))) @@ -130,6 +162,7 @@ private fun Preview() { isFeeApproximate = false, isFeeConvertibleToFiat = true, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeNonce = FeeNonce.None, feeFiatRateUM = FeeFiatRateUM( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt index 2a4a9e6bd5..4f5034555d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt @@ -50,6 +50,9 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.SendSelectNetworkFeeBottomSheetTestTags 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.v2.api.entity.* import com.tangem.features.send.v2.feeselector.component.speed.FeeSpeedSelectorIntents import com.tangem.features.send.v2.feeselector.component.speed.StubFeeSpeedSelectorIntents @@ -473,6 +476,7 @@ private class FeeSpeedSelectorUMContentProvider : CollectionPreviewParameterProv isFeeApproximate = true, isFeeConvertibleToFiat = true, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal.TEN, @@ -484,44 +488,74 @@ private class FeeSpeedSelectorUMContentProvider : CollectionPreviewParameterProv ), ) -private val customFeeItem = FeeItem.Custom( - fee = Fee.Common(Amount(value = BigDecimal("0.05"), blockchain = Blockchain.Ethereum)), - customValues = persistentListOf( - CustomFeeFieldUM( - value = "0.119806", - onValueChange = {}, - keyboardOptions = KeyboardOptions(), - keyboardActions = KeyboardActions(), - symbol = "ETH", +private val cryptoCurrencyStatus + get() = CryptoCurrencyStatus( + currency = CryptoCurrency.Coin( + id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), + network = Network( + id = Network.ID( + value = "bitcoin", + derivationPath = Network.DerivationPath.None, + ), + backendId = "bitcoin", + name = "Bitcoin", + currencySymbol = "BTC", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("bitcoin"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ), + name = "Bitcoin", + symbol = "BTC", decimals = 8, - title = resourceReference(R.string.send_max_fee), - footer = resourceReference(R.string.send_custom_amount_fee_footer), - label = stringReference("~ 0,03 \$"), - isReadonly = false, + iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png", + isCustom = false, ), - CustomFeeFieldUM( - value = "40000", - onValueChange = {}, - keyboardOptions = KeyboardOptions(), - keyboardActions = KeyboardActions(), - symbol = "GWEI", - decimals = 8, - title = resourceReference(R.string.send_gas_price), - footer = resourceReference(R.string.send_gas_price_footer), - label = null, - isReadonly = false, + value = CryptoCurrencyStatus.Loading, + ) + +private val customFeeItem + get() = FeeItem.Custom( + fee = Fee.Common(Amount(value = BigDecimal("0.05"), blockchain = Blockchain.Ethereum)), + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.119806", + onValueChange = {}, + keyboardOptions = KeyboardOptions(), + keyboardActions = KeyboardActions(), + symbol = "ETH", + decimals = 8, + title = resourceReference(R.string.send_max_fee), + footer = resourceReference(R.string.send_custom_amount_fee_footer), + label = stringReference("~ 0,03 \$"), + isReadonly = false, + ), + CustomFeeFieldUM( + value = "40000", + onValueChange = {}, + keyboardOptions = KeyboardOptions(), + keyboardActions = KeyboardActions(), + symbol = "GWEI", + decimals = 8, + title = resourceReference(R.string.send_gas_price), + footer = resourceReference(R.string.send_gas_price_footer), + label = null, + isReadonly = false, + ), + CustomFeeFieldUM( + value = "31400", + onValueChange = {}, + keyboardOptions = KeyboardOptions(), + keyboardActions = KeyboardActions(), + symbol = null, + decimals = 8, + title = resourceReference(R.string.send_gas_limit), + footer = resourceReference(R.string.send_gas_limit_footer), + label = null, + isReadonly = false, + ), ), - CustomFeeFieldUM( - value = "31400", - onValueChange = {}, - keyboardOptions = KeyboardOptions(), - keyboardActions = KeyboardActions(), - symbol = null, - decimals = 8, - title = resourceReference(R.string.send_gas_limit), - footer = resourceReference(R.string.send_gas_limit_footer), - label = null, - isReadonly = false, - ), - ), -) \ No newline at end of file + ) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorBlockModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorBlockModel.kt index e8f6e511c9..36fdc82031 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorBlockModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorBlockModel.kt @@ -25,14 +25,14 @@ import javax.inject.Inject @ModelScoped internal class FeeSelectorBlockModel @Inject constructor( paramsContainer: ParamsContainer, + feeSelectorLogicFactory: FeeSelectorLogic.Factory, override val dispatchers: CoroutineDispatcherProvider, - private val feeSelectorLogicFactory: FeeSelectorLogic.Factory, private val analyticsEventHandler: AnalyticsEventHandler, private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase, private val urlOpener: UrlOpener, ) : Model(), FeeSelectorModelCallback { - private val params = paramsContainer.require() + private val params = paramsContainer.require() private val feeSelectorLogic = feeSelectorLogicFactory.create( params = params, modelScope = modelScope, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt index fd8f950d3e..41ba48e249 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt @@ -1,11 +1,18 @@ package com.tangem.features.send.v2.feeselector.model +import arrow.core.Either import arrow.core.getOrElse import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.NonceInserted import com.tangem.features.send.v2.api.entity.FeeItem import com.tangem.features.send.v2.api.entity.FeeNonce @@ -38,6 +45,8 @@ internal class FeeSelectorLogic @AssistedInject constructor( private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger, private val feeSelectorAlertFactory: FeeSelectorAlertFactory, private val analyticsEventHandler: AnalyticsEventHandler, + private val sendFeatureToggles: SendFeatureToggles, + private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, ) : FeeSelectorIntents { private var appCurrency: AppCurrency = AppCurrency.Default @@ -51,10 +60,6 @@ internal class FeeSelectorLogic @AssistedInject constructor( loadFee() } - fun updateState(feeSelectorUM: FeeSelectorUM) { - uiState.value = feeSelectorUM - } - private fun initAppCurrency() { modelScope.launch { appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } @@ -66,18 +71,18 @@ internal class FeeSelectorLogic @AssistedInject constructor( uiState.update(FeeSelectorLoadingTransformer) } modelScope.launch { - params.onLoadFee() + callLoadFee() .fold( ifLeft = { error -> uiState.update(FeeSelectorErrorTransformer(error)) }, ifRight = { fee -> uiState.update( FeeSelectorLoadedTransformer( cryptoCurrencyStatus = params.cryptoCurrencyStatus, - feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, + feeCryptoCurrencyStatus = fee.selectedTokenOrNull ?: params.feeCryptoCurrencyStatus, appCurrency = appCurrency, fees = fee, feeStateConfiguration = params.feeStateConfiguration, - isFeeApproximate = isFeeApproximate(fee.normal.amount.type), + isFeeApproximate = isFeeApproximate(fee.transactionFee.normal.amount.type), feeSelectorIntents = this@FeeSelectorLogic, ), ) @@ -98,9 +103,6 @@ internal class FeeSelectorLogic @AssistedInject constructor( ) } uiState.update(FeeItemSelectedTransformer(feeItem)) - if (feeItem !is FeeItem.Custom) { - onDoneClick() - } } override fun onCustomFeeValueChange(index: Int, value: String) { @@ -141,8 +143,6 @@ internal class FeeSelectorLogic @AssistedInject constructor( ), ) } - - (params as? FeeSelectorParams.FeeSelectorDetailsParams)?.callback?.onFeeResult(uiState.value) } private fun subscribeOnFeeReloadTriggerUpdates() { @@ -170,10 +170,10 @@ internal class FeeSelectorLogic @AssistedInject constructor( private fun checkLoadFee() { modelScope.launch { - params.onLoadFee().fold( + callLoadFee().fold( ifRight = { newFee -> feeSelectorAlertFactory.getFeeUpdatedAlert( - newTransactionFee = newFee, + newTransactionFee = newFee.transactionFee, feeSelectorUM = uiState.value, proceedAction = { modelScope.launch { @@ -195,6 +195,61 @@ internal class FeeSelectorLogic @AssistedInject constructor( } } + @Suppress("UnreachableCode") + private suspend fun callLoadFee(): Either { + val extended = params.onLoadFeeExtended + return if (extended != null && sendFeatureToggles.isGaslessTransactionsEnabled) { + extended().fold( + ifLeft = { error -> + if (error is GetFeeError.GaslessError) { + params.onLoadFee().map { LoadedFeeResult.Basic(it) } + } else { + Either.Left(error) + } + }, + ifRight = { fee -> + Either.Right( + LoadedFeeResult.Extended( + fee = fee, + selectedToken = if (params.feeCryptoCurrencyStatus.currency.id != fee.feeTokenId) { + getSingleCryptoCurrencyStatusUseCase + .invokeMultiWalletSync( + userWalletId = TODO(), + cryptoCurrencyId = fee.feeTokenId, + ).getOrNull() + } else { + params.feeCryptoCurrencyStatus + }, + ), + ) + }, + ) + } else { + params.onLoadFee().map { LoadedFeeResult.Basic(it) } + } + } + + sealed class LoadedFeeResult { + data class Extended( + val fee: TransactionFeeExtended, + val selectedToken: CryptoCurrencyStatus?, + ) : LoadedFeeResult() + + data class Basic(val fee: TransactionFee) : LoadedFeeResult() + + val selectedTokenOrNull: CryptoCurrencyStatus? + get() = when (this) { + is Extended -> selectedToken + is Basic -> null + } + + val transactionFee: TransactionFee + get() = when (this) { + is Extended -> fee.transactionFee + is Basic -> fee + } + } + @AssistedFactory interface Factory { fun create(params: FeeSelectorParams, modelScope: CoroutineScope): FeeSelectorLogic diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt index 5f82459764..5bf01617b0 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt @@ -1,13 +1,20 @@ package com.tangem.features.send.v2.feeselector.model import androidx.compose.runtime.Stable +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.pop 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.utils.stack import com.tangem.features.send.v2.api.SendFeatureToggles +import com.tangem.features.send.v2.api.entity.FeeItem +import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.params.FeeSelectorParams import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.isSingleItem +import kotlinx.coroutines.launch import javax.inject.Inject @Stable @@ -17,16 +24,61 @@ internal class FeeSelectorModel @Inject constructor( feeSelectorLogicFactory: FeeSelectorLogic.Factory, override val dispatchers: CoroutineDispatcherProvider, private val sendFeatureToggles: SendFeatureToggles, -) : Model() { +) : Model(), FeeSelectorIntents { - private val params = paramsContainer.require() + private val params = paramsContainer.require() private val feeSelectorLogic = feeSelectorLogicFactory.create( params = params, modelScope = modelScope, ) + val stackNavigation = StackNavigation() val uiState = feeSelectorLogic.uiState - val intents: FeeSelectorIntents = feeSelectorLogic + + override fun onFeeItemSelected(feeItem: FeeItem) { + feeSelectorLogic.onFeeItemSelected(feeItem) + + if (contentState().selectedFeeItem is FeeItem.Custom) { + return + } + + modelScope.launch { + val stack = stackNavigation.stack() + if (stack.isSingleItem() && stack[0] is FeeSelectorRoute.ChooseSpeed) { + // In case of only speed selection, we finish the flow immediately after selection + feeSelectorLogic.onDoneClick() + params.callback.onFeeResult(uiState.value) + } else { + stackNavigation.pop() + } + } + } + + override fun onCustomFeeValueChange(index: Int, value: String) { + feeSelectorLogic.onCustomFeeValueChange(index, value) + } + + override fun onNonceChange(value: String) { + feeSelectorLogic.onNonceChange(value) + } + + override fun onDoneClick() { + modelScope.launch { + val stack = stackNavigation.stack() + if (stack.isSingleItem()) { + feeSelectorLogic.onDoneClick() + params.callback.onFeeResult(uiState.value) + } else { + stackNavigation.pop() + } + } + } + + private fun contentState(): FeeSelectorUM.Content { + val currentState = uiState.value + require(currentState is FeeSelectorUM.Content) { "Current state must be FeeSelectorUM.Content" } + return currentState + } fun getInitialRoute(): FeeSelectorRoute { if (sendFeatureToggles.isGaslessTransactionsEnabled.not()) { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt index 69a2e8ece2..4b0668785a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt @@ -1,13 +1,13 @@ package com.tangem.features.send.v2.feeselector.model.transformers import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.send.v2.api.entity.* import com.tangem.features.send.v2.api.params.FeeSelectorParams import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents +import com.tangem.features.send.v2.feeselector.model.FeeSelectorLogic.LoadedFeeResult import com.tangem.lib.crypto.BlockchainUtils.isTron import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.ImmutableList @@ -17,7 +17,7 @@ internal class FeeSelectorLoadedTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val feeCryptoCurrencyStatus: CryptoCurrencyStatus, private val appCurrency: AppCurrency, - private val fees: TransactionFee, + private val fees: LoadedFeeResult, private val feeStateConfiguration: FeeSelectorParams.FeeStateConfiguration, private val isFeeApproximate: Boolean, private val feeSelectorIntents: FeeSelectorIntents, @@ -25,7 +25,7 @@ internal class FeeSelectorLoadedTransformer( private val feeItemsConverter = FeeItemConverter( feeStateConfiguration = feeStateConfiguration, - normalFee = fees.normal, + normalFee = fees.transactionFee.normal, feeSelectorIntents = feeSelectorIntents, appCurrency = appCurrency, cryptoCurrencyStatus = feeCryptoCurrencyStatus, @@ -37,7 +37,9 @@ internal class FeeSelectorLoadedTransformer( } else { null } - val feeItems: ImmutableList = feeItemsConverter.convert(FeeItemConverter.Input(fees, prevCustomFee)) + val feeItems: ImmutableList = feeItemsConverter.convert( + FeeItemConverter.Input(fees.transactionFee, prevCustomFee), + ) val selectedFee = when (prevState) { is FeeSelectorUM.Content -> feeItems.first { it.isSameClass(prevState.selectedFeeItem) } @@ -50,7 +52,7 @@ internal class FeeSelectorLoadedTransformer( return FeeSelectorUM.Content( isPrimaryButtonEnabled = true, - fees = fees, + fees = fees.transactionFee, feeItems = feeItems, selectedFeeItem = selectedFee, feeExtraInfo = FeeExtraInfo( @@ -58,6 +60,7 @@ internal class FeeSelectorLoadedTransformer( isFeeConvertibleToFiat = feeCryptoCurrencyStatus.currency.network.hasFiatFeeRate, isTronToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token && isTron(cryptoCurrencyStatus.currency.network.rawId), + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, ), feeFiatRateUM = feeCryptoCurrencyStatus.value.fiatRate?.let { rate -> FeeFiatRateUM( @@ -65,7 +68,7 @@ internal class FeeSelectorLoadedTransformer( appCurrency = appCurrency, ) }, - feeNonce = if (fees.normal is Fee.Ethereum) { + feeNonce = if (fees.transactionFee.normal is Fee.Ethereum) { FeeNonce.Nonce( nonce = nonce, onNonceChange = feeSelectorIntents::onNonceChange, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/route/FeeSelectorRoute.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/route/FeeSelectorRoute.kt index f122f8fed0..270230164d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/route/FeeSelectorRoute.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/route/FeeSelectorRoute.kt @@ -15,7 +15,7 @@ internal sealed interface FeeSelectorRoute : Route { @Serializable data object NetworkFee : FeeSelectorRoute { - override val title: TextReference = resourceReference(R.string.common_fee_selector_title) + override val title: TextReference = resourceReference(R.string.common_network_fee_title) } @Serializable diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt index 27b688a281..19a1c86d96 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt @@ -42,6 +42,9 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.FeeSelectorBlockTestTags 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.domain.transaction.error.GetFeeError import com.tangem.features.send.v2.api.entity.* import com.tangem.features.send.v2.impl.R @@ -238,11 +241,48 @@ private fun FeeSelectorBlockContent_Preview(@PreviewParameter(FeeSelectorUMProvi } private class FeeSelectorUMProvider : PreviewParameterProvider { - private val maxFeeItem = FeeItem.Market( - fee = Fee.Common(amount = Amount(value = BigDecimal("100000000"), blockchain = Blockchain.Hedera)), - ) - private val lowFeeItem = - FeeItem.Market(Fee.Common(amount = Amount(value = BigDecimal("0.0002876"), blockchain = Blockchain.Ethereum))) + private val maxFeeItem + get() = FeeItem.Market( + fee = Fee.Common(amount = Amount(value = BigDecimal("100000000"), blockchain = Blockchain.Hedera)), + ) + private val lowFeeItem + get() = FeeItem.Market( + Fee.Common( + amount = Amount( + value = BigDecimal("0.0002876"), + blockchain = Blockchain.Ethereum, + ), + ), + ) + + val cryptoCurrencyStatus + get() = CryptoCurrencyStatus( + currency = CryptoCurrency.Coin( + id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), + network = Network( + id = Network.ID( + value = "bitcoin", + derivationPath = Network.DerivationPath.None, + ), + backendId = "bitcoin", + name = "Bitcoin", + currencySymbol = "BTC", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("bitcoin"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ), + name = "Bitcoin", + symbol = "BTC", + decimals = 8, + iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png", + isCustom = false, + ), + value = CryptoCurrencyStatus.Loading, + ) override val values: Sequence = sequenceOf( FeeSelectorUM.Content( @@ -253,6 +293,7 @@ private class FeeSelectorUMProvider : PreviewParameterProvider { isFeeApproximate = false, isFeeConvertibleToFiat = true, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeNonce = FeeNonce.None, feeFiatRateUM = FeeFiatRateUM( @@ -269,6 +310,7 @@ private class FeeSelectorUMProvider : PreviewParameterProvider { isFeeApproximate = false, isFeeConvertibleToFiat = true, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeNonce = FeeNonce.None, feeFiatRateUM = FeeFiatRateUM( diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt index b44e37a21d..92dc0a0d66 100644 --- a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt @@ -8,6 +8,8 @@ import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler 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.v2.api.entity.* import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformerV2 @@ -28,6 +30,34 @@ class NFTSendConfirmationNotificationsTransformerV2Test { private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$") private val analyticsCategoryName = "test_category" + val cryptoCurrencyStatus = CryptoCurrencyStatus( + currency = CryptoCurrency.Coin( + id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), + network = Network( + id = Network.ID( + value = "bitcoin", + derivationPath = Network.DerivationPath.None, + ), + backendId = "bitcoin", + name = "Bitcoin", + currencySymbol = "BTC", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("bitcoin"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ), + name = "Bitcoin", + symbol = "BTC", + decimals = 8, + iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png", + isCustom = false, + ), + value = CryptoCurrencyStatus.Loading, + ) + @Test fun `GIVEN non content state WHEN transform THEN returns original state`() = runTest { // GIVEN @@ -191,6 +221,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), @@ -274,6 +305,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), @@ -345,6 +377,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), @@ -428,6 +461,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt index 4e3050ec47..ee1d34d98e 100644 --- a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt @@ -10,6 +10,8 @@ import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler 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.v2.api.entity.* import com.tangem.features.send.v2.common.ui.state.ConfirmUM import io.mockk.mockk @@ -30,6 +32,34 @@ class SendConfirmationNotificationsTransformerV2Test { private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$") private val analyticsCategoryName = "test_category" + val cryptoCurrencyStatus = CryptoCurrencyStatus( + currency = CryptoCurrency.Coin( + id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), + network = Network( + id = Network.ID( + value = "bitcoin", + derivationPath = Network.DerivationPath.None, + ), + backendId = "bitcoin", + name = "Bitcoin", + currencySymbol = "BTC", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("bitcoin"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ), + name = "Bitcoin", + symbol = "BTC", + decimals = 8, + iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png", + isCustom = false, + ), + value = CryptoCurrencyStatus.Loading, + ) + @Test fun `GIVEN non content state WHEN transform THEN returns original state`() = runTest { // GIVEN @@ -247,6 +277,7 @@ class SendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), @@ -330,6 +361,7 @@ class SendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), @@ -401,6 +433,7 @@ class SendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), @@ -484,6 +517,7 @@ class SendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt index c106e29968..44ca767ac2 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt @@ -333,6 +333,7 @@ private fun SendWithSwapSuccessContent_Preview() { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = SwapAmountContentPreview.cryptoCurrencyStatus, ), feeFiatRateUM = null, feeNonce = FeeNonce.None, From 2b1cdd61c3709c6e04acdc8736997ebe09a4b940 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 6 Jan 2026 15:07:38 +0300 Subject: [PATCH 061/172] Updated on 2026-08-14 --- .../tap/di/domain/TransactionDomainModule.kt | 58 +++++++++++++++++++ .../transaction/di/TransactionDataModule.kt | 18 ++++++ 2 files changed, 76 insertions(+) diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 8965f60090..b7cdcf3670 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -5,12 +5,18 @@ import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.WalletAddressServiceRepository import com.tangem.domain.transaction.usecase.* +import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCase +import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase +import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -265,4 +271,56 @@ internal object TransactionDomainModule { ): SendLargeSolanaTransactionUseCase { return SendLargeSolanaTransactionUseCase(cardSdkConfigRepository, walletManagersFacade) } + + @Provides + @Singleton + fun provideGetAvailableFeeTokensUseCase( + gaslessTransactionRepository: GaslessTransactionRepository, + currenciesRepository: CurrenciesRepository, + getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + ): GetAvailableFeeTokensUseCase { + return GetAvailableFeeTokensUseCase( + gaslessTransactionRepository = gaslessTransactionRepository, + currenciesRepository = currenciesRepository, + getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + ) + } + + @Provides + @Singleton + fun provideGetFeeForGaslessUseCase( + walletManagersFacade: WalletManagersFacade, + demoConfig: DemoConfig, + gaslessTransactionRepository: GaslessTransactionRepository, + currenciesRepository: CurrenciesRepository, + getFeeUseCase: GetFeeUseCase, + getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + ): GetFeeForGaslessUseCase { + return GetFeeForGaslessUseCase( + walletManagersFacade = walletManagersFacade, + demoConfig = demoConfig, + gaslessTransactionRepository = gaslessTransactionRepository, + currenciesRepository = currenciesRepository, + getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + getFeeUseCase = getFeeUseCase, + ) + } + + @Provides + @Singleton + fun provideGetFeeForTokenUseCase( + walletManagersFacade: WalletManagersFacade, + demoConfig: DemoConfig, + gaslessTransactionRepository: GaslessTransactionRepository, + currenciesRepository: CurrenciesRepository, + getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + ): GetFeeForTokenUseCase { + return GetFeeForTokenUseCase( + gaslessTransactionRepository = gaslessTransactionRepository, + walletManagersFacade = walletManagersFacade, + demoConfig = demoConfig, + currenciesRepository = currenciesRepository, + getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + ) + } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt index b2f5a28882..d7be2b093c 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt @@ -1,13 +1,17 @@ package com.tangem.data.transaction.di +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.transaction.DefaultFeeRepository +import com.tangem.data.transaction.DefaultGaslessTransactionRepository import com.tangem.data.transaction.DefaultTransactionRepository import com.tangem.data.transaction.DefaultWalletAddressServiceRepository import com.tangem.data.transaction.error.DefaultFeeErrorResolver +import com.tangem.datasource.api.gasless.GaslessTxServiceApi import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.WalletAddressServiceRepository import com.tangem.domain.transaction.error.FeeErrorResolver @@ -65,4 +69,18 @@ internal object TransactionDataModule { fun providerFeeErrorResolver(): FeeErrorResolver { return DefaultFeeErrorResolver() } + + @Provides + @Singleton + fun provideGaslessTransactionRepository( + gaslessTxServiceApi: GaslessTxServiceApi, + coroutineDispatcherProvider: CoroutineDispatcherProvider, + responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, + ): GaslessTransactionRepository { + return DefaultGaslessTransactionRepository( + gaslessTxServiceApi, + coroutineDispatcherProvider, + responseCryptoCurrenciesFactory, + ) + } } \ No newline at end of file From b01b575e638b83a4db8c3db97800fe408517007a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 6 Jan 2026 16:21:19 +0300 Subject: [PATCH 062/172] Updated on 2026-08-14 --- .../com/tangem/datasource/di/ApiConfigsModule.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index 7bf7ad5ce5..42d7ef09e6 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -97,4 +97,18 @@ internal object ApiConfigsModule { fun provideMoonPayConfig(): ApiConfig { return MoonPay() } + + @Provides + @IntoSet + fun provideGaslessServiceConfig( + appVersionProvider: AppVersionProvider, + authProvider: AuthProvider, + appInfoProvider: AppInfoProvider, + ): ApiConfig { + return GaslessTxService( + authProvider = authProvider, + appVersionProvider = appVersionProvider, + appInfoProvider = appInfoProvider, + ) + } } \ No newline at end of file From f81e51f4c458cd6389399292f2fb07f910f08770 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 6 Jan 2026 17:04:09 +0300 Subject: [PATCH 063/172] Updated on 2026-08-14 --- .../MockedGaslessTransactionRepository.kt | 74 +++++++++++++++++++ .../transaction/di/TransactionDataModule.kt | 16 ++-- 2 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt new file mode 100644 index 0000000000..72e1e7c662 --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt @@ -0,0 +1,74 @@ +package com.tangem.data.transaction + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.Token +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessSignedTransactionResult +import com.tangem.domain.transaction.models.GaslessTransactionData +import java.math.BigInteger + +class MockedGaslessTransactionRepository( + private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, +) : GaslessTransactionRepository { + + override fun isNetworkSupported(network: Network): Boolean { + val blockchain = Blockchain.fromNetworkId(network.backendId) ?: return false + return SUPPORTED_BLOCKCHAINS.contains(blockchain) + } + + override suspend fun getSupportedTokens(network: Network): Set { + val usdcPolygon = responseCryptoCurrenciesFactory.createToken( + blockchain = Blockchain.Polygon, + sdkToken = Token( + contractAddress = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + name = "USD Coin", + symbol = "USDC", + decimals = 6, + ), + network = network, + ) + return setOf(usdcPolygon) + } + + override fun getTokenFeeReceiverAddress(): String { + return TOKEN_RECEIVER_ADDRESS + } + + override suspend fun sendGaslessTransaction( + gaslessTransactionData: GaslessTransactionData, + signature: String, + userAddress: String, + network: Network, + eip7702Auth: Eip7702Authorization?, + ): GaslessSignedTransactionResult = GaslessSignedTransactionResult( + signedTransaction = "0x000", + gasLimit = 100000.toBigInteger(), + maxFeePerGas = 21000.toBigInteger(), + maxPriorityFeePerGas = 21000.toBigInteger(), + ) + + override fun getBaseGasForTransaction(): BigInteger { + return BASE_GAS_FOR_TRANSACTION + } + + private companion object { + + const val TOKEN_RECEIVER_ADDRESS = "0x" + + val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000") + val SUPPORTED_BLOCKCHAINS = arrayOf( + Blockchain.Ethereum, + Blockchain.BSC, + Blockchain.Base, + Blockchain.Polygon, + Blockchain.Arbitrum, + Blockchain.XDC, + Blockchain.Optimism, + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt index d7be2b093c..d21fc04dce 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt @@ -2,11 +2,10 @@ package com.tangem.data.transaction.di import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.transaction.DefaultFeeRepository -import com.tangem.data.transaction.DefaultGaslessTransactionRepository import com.tangem.data.transaction.DefaultTransactionRepository import com.tangem.data.transaction.DefaultWalletAddressServiceRepository +import com.tangem.data.transaction.MockedGaslessTransactionRepository import com.tangem.data.transaction.error.DefaultFeeErrorResolver -import com.tangem.datasource.api.gasless.GaslessTxServiceApi import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.demo.models.DemoConfig @@ -73,14 +72,13 @@ internal object TransactionDataModule { @Provides @Singleton fun provideGaslessTransactionRepository( - gaslessTxServiceApi: GaslessTxServiceApi, - coroutineDispatcherProvider: CoroutineDispatcherProvider, responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, ): GaslessTransactionRepository { - return DefaultGaslessTransactionRepository( - gaslessTxServiceApi, - coroutineDispatcherProvider, - responseCryptoCurrenciesFactory, - ) + return MockedGaslessTransactionRepository(responseCryptoCurrenciesFactory) + // return DefaultGaslessTransactionRepository( + // gaslessTxServiceApi, + // coroutineDispatcherProvider, + // responseCryptoCurrenciesFactory, + // ) } } \ No newline at end of file From 3baec6cc7e0d15c555c61b6302fa56c774887b84 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 6 Jan 2026 17:19:24 +0300 Subject: [PATCH 064/172] Updated on 2026-08-14 --- .../tap/di/domain/TransactionDomainModule.kt | 9 + .../token/internal/TokenFiatAmount.kt | 5 + .../components/token/state/TokenItemState.kt | 2 + .../IsGaslessFeeSupportedForNetwork.kt | 16 ++ .../send/v2/api/entity/FeeSelectorUM.kt | 1 + .../send/v2/api/params/FeeSelectorParams.kt | 4 + .../DefaultFeeSelectorBlockComponent.kt | 1 + .../DefaultFeeSelectorComponent.kt | 11 +- .../model/FeeExtendedSelectorModel.kt | 1 - .../SelectedTokenItemConverter.kt | 2 +- .../token/FeeTokenSelectorComponent.kt | 42 ++++ .../token/FeeTokenSelectorIntents.kt | 15 ++ .../token/entity/FeeTokenSelectorUM.kt | 11 + .../token/model/FeeTokenForListConverter.kt | 55 +++++ .../token/model/FeeTokenSelectorModel.kt | 93 +++++++ .../token/ui/FeeTokenSelectorContent.kt | 226 ++++++++++++++++++ .../feeselector/di/FeeSelectorModelModule.kt | 8 +- .../feeselector/model/FeeSelectorIntents.kt | 3 + .../v2/feeselector/model/FeeSelectorLogic.kt | 67 ++++-- .../v2/feeselector/model/FeeSelectorModel.kt | 15 +- .../FeeSelectorLoadedTransformer.kt | 7 + .../v2/send/confirm/SendConfirmComponent.kt | 1 + .../confirm/NFTSendConfirmComponent.kt | 1 + .../confirm/SendWithSwapConfirmComponent.kt | 1 + .../components/common/WcNavigationUtils.kt | 1 + .../send/WcSendTransactionComponent.kt | 1 + .../model/WcSendTransactionModel.kt | 3 + 27 files changed, 580 insertions(+), 22 deletions(-) create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/IsGaslessFeeSupportedForNetwork.kt rename features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/{entity => model}/SelectedTokenItemConverter.kt (99%) create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorIntents.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenSelectorUM.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenForListConverter.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index b7cdcf3670..ea4bd0c0da 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -17,6 +17,7 @@ import com.tangem.domain.transaction.usecase.* import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -323,4 +324,12 @@ internal object TransactionDomainModule { getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, ) } + + @Provides + @Singleton + fun provideIsGaslessFeeSupportedForNetwork( + gaslessTransactionRepository: GaslessTransactionRepository, + ): IsGaslessFeeSupportedForNetwork { + return IsGaslessFeeSupportedForNetwork(gaslessTransactionRepository) + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt index 3824de5dd7..0651618f73 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt @@ -2,6 +2,7 @@ package com.tangem.core.ui.components.token.internal import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -52,6 +53,10 @@ internal fun TokenFiatAmount(state: TokenFiatAmountState?, isBalanceHidden: Bool is TokenFiatAmountState.Locked -> { LockedRectangle(modifier = modifier.placeholderSize()) } + is TokenFiatAmountState.Empty -> { + // Empty box for proper measurements + Box(modifier) + } null -> Unit } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt index 72bc32e2b6..d4e8f3e33e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt @@ -243,6 +243,8 @@ sealed class TokenItemState { data object Loading : FiatAmountState() data object Locked : FiatAmountState() + + data object Empty : FiatAmountState() } @Immutable diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/IsGaslessFeeSupportedForNetwork.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/IsGaslessFeeSupportedForNetwork.kt new file mode 100644 index 0000000000..67c8c36205 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/IsGaslessFeeSupportedForNetwork.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.transaction.usecase.gasless + +import com.tangem.domain.models.network.Network +import com.tangem.domain.transaction.GaslessTransactionRepository + +/** + * Use case to check if gasless fee is supported for a given network. + */ +class IsGaslessFeeSupportedForNetwork( + private val gaslessTransactionRepository: GaslessTransactionRepository, +) { + + operator fun invoke(network: Network): Boolean { + return gaslessTransactionRepository.isNetworkSupported(network) + } +} \ No newline at end of file diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt index 40c2047420..a62fec37f1 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt @@ -63,6 +63,7 @@ data class FeeExtraInfo( val isFeeConvertibleToFiat: Boolean, val isTronToken: Boolean, val feeCryptoCurrencyStatus: CryptoCurrencyStatus, + val availableFeeCurrencies: ImmutableList? = null, ) sealed class FeeNonce { diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt index d45422c572..8e46ee3710 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt @@ -5,6 +5,7 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents @@ -13,6 +14,7 @@ import com.tangem.features.send.v2.api.entity.FeeSelectorUM sealed class FeeSelectorParams { abstract val state: FeeSelectorUM + abstract val userWalletId: UserWalletId abstract val onLoadFeeExtended: (suspend () -> Either)? abstract val onLoadFee: suspend () -> Either abstract val cryptoCurrencyStatus: CryptoCurrencyStatus @@ -24,6 +26,7 @@ sealed class FeeSelectorParams { data class FeeSelectorBlockParams( override val state: FeeSelectorUM, + override val userWalletId: UserWalletId, override val onLoadFeeExtended: (suspend () -> Either)? = null, override val onLoadFee: suspend () -> Either, override val cryptoCurrencyStatus: CryptoCurrencyStatus, @@ -36,6 +39,7 @@ sealed class FeeSelectorParams { data class FeeSelectorDetailsParams( override val state: FeeSelectorUM, + override val userWalletId: UserWalletId, override val onLoadFeeExtended: (suspend () -> Either)? = null, override val onLoadFee: suspend () -> Either, override val cryptoCurrencyStatus: CryptoCurrencyStatus, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt index 8b1a940af1..bf86789265 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt @@ -54,6 +54,7 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor( feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen, analyticsCategoryName = params.analyticsCategoryName, analyticsSendSource = params.analyticsSendSource, + userWalletId = params.userWalletId, ), onDismiss = { model.feeSelectorBottomSheet.dismiss() diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt index 5232daa424..dc62754b5c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt @@ -17,6 +17,7 @@ import com.tangem.features.send.v2.api.params.FeeSelectorParams import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams import com.tangem.features.send.v2.feeselector.component.extended.FeeExtendedSelectorComponent import com.tangem.features.send.v2.feeselector.component.speed.FeeSpeedSelectorComponent +import com.tangem.features.send.v2.feeselector.component.token.FeeTokenSelectorComponent import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute import com.tangem.features.send.v2.feeselector.ui.FeeSelectorModalBottomSheet @@ -28,8 +29,9 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: FeeSelectorParams.FeeSelectorDetailsParams, @Assisted private val onDismiss: () -> Unit, - private val feeExtendedSelectorComponent: FeeExtendedSelectorComponent.Factory, + private val feeExtendedSelectorComponentFactory: FeeExtendedSelectorComponent.Factory, private val feeSpeedSelectorComponentFactory: FeeSpeedSelectorComponent.Factory, + private val feeTokenSelectorComponentFactory: FeeTokenSelectorComponent.Factory, ) : FeeSelectorComponent, AppComponentContext by appComponentContext { private val model: FeeSelectorModel = getOrCreateModel(params = params) @@ -86,7 +88,7 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor( ) return when (config) { - FeeSelectorRoute.NetworkFee -> feeExtendedSelectorComponent.create( + FeeSelectorRoute.NetworkFee -> feeExtendedSelectorComponentFactory.create( appComponentContext = appComponentContext, params = componentParams, ) @@ -94,7 +96,10 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor( appComponentContext = appComponentContext, params = componentParams, ) - FeeSelectorRoute.ChooseToken -> TODO() + FeeSelectorRoute.ChooseToken -> feeTokenSelectorComponentFactory.create( + appComponentContext = appComponentContext, + params = componentParams, + ) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt index 2a8728c552..75d481da3c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt @@ -11,7 +11,6 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams import com.tangem.features.send.v2.feeselector.component.extended.entity.FeeExtendedSelectorUM -import com.tangem.features.send.v2.feeselector.component.extended.entity.SelectedTokenItemConverter import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/SelectedTokenItemConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/SelectedTokenItemConverter.kt similarity index 99% rename from features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/SelectedTokenItemConverter.kt rename to features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/SelectedTokenItemConverter.kt index 0959d98daf..af61ddfe05 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/SelectedTokenItemConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/SelectedTokenItemConverter.kt @@ -1,4 +1,4 @@ -package com.tangem.features.send.v2.feeselector.component.extended.entity +package com.tangem.features.send.v2.feeselector.component.extended.model import com.tangem.common.ui.tokens.TokenItemStateConverter import com.tangem.core.ui.components.icons.IconTint diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt new file mode 100644 index 0000000000..0fc8cce54a --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt @@ -0,0 +1,42 @@ +package com.tangem.features.send.v2.feeselector.component.token + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +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.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.token.model.FeeTokenSelectorModel +import com.tangem.features.send.v2.feeselector.component.token.ui.FeeTokenSelectorContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class FeeTokenSelectorComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: FeeSelectorComponentParams, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: FeeTokenSelectorModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + FeeTokenSelectorContent( + state = state, + intents = model, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory { + fun create( + appComponentContext: AppComponentContext, + params: FeeSelectorComponentParams, + ): FeeTokenSelectorComponent + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorIntents.kt new file mode 100644 index 0000000000..7e9e5b99c6 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorIntents.kt @@ -0,0 +1,15 @@ +package com.tangem.features.send.v2.feeselector.component.token + +import androidx.compose.runtime.Stable +import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents +import com.tangem.features.send.v2.feeselector.model.StubFeeSelectorIntents + +@Stable +internal interface FeeTokenSelectorIntents : FeeSelectorIntents { + fun onLearnMoreClick() +} + +@Stable +internal class StubFeeTokenSelectorIntents : FeeTokenSelectorIntents, FeeSelectorIntents by StubFeeSelectorIntents() { + override fun onLearnMoreClick() {} +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenSelectorUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenSelectorUM.kt new file mode 100644 index 0000000000..5de55c5a70 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenSelectorUM.kt @@ -0,0 +1,11 @@ +package com.tangem.features.send.v2.feeselector.component.token.entity + +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import kotlinx.collections.immutable.ImmutableList + +data class FeeTokenSelectorUM( + val parent: FeeSelectorUM.Content, + val selectedToken: TokenItemState, + val tokens: ImmutableList, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenForListConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenForListConverter.kt new file mode 100644 index 0000000000..aeec5346f3 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenForListConverter.kt @@ -0,0 +1,55 @@ +package com.tangem.features.send.v2.feeselector.component.token.model + +import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +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.CryptoCurrencyStatus +import com.tangem.features.send.v2.impl.R +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter + +internal class FeeTokenForListConverter( + private val appCurrencyProvider: Provider, + private val onTokenClick: () -> Unit, +) : Converter { + + override fun convert(value: CryptoCurrencyStatus): TokenItemState { + if (value.value !is CryptoCurrencyStatus.Loaded) { + return TokenItemState.Loading(id = value.currency.id.value) + } + + val appCurrency = appCurrencyProvider() + val tokenItemConverter = TokenItemStateConverter( + appCurrency = appCurrencyProvider(), + onItemClick = { _, _ -> onTokenClick() }, + ) + + val state = tokenItemConverter.convert(value) + + return when (state) { + is TokenItemState.Content -> { + val balance = value.value.fiatAmount.format { + fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) + } + + state.copy( + fiatAmountState = TokenItemState.FiatAmountState.Empty, + subtitleState = TokenItemState.SubtitleState.TextContent( + value = resourceReference( + R.string.common_balance, + wrappedList(balance), + ), + isAvailable = false, + ), + subtitle2State = null, + + ) + } + else -> state + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt new file mode 100644 index 0000000000..fd5ac4c138 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt @@ -0,0 +1,93 @@ +package com.tangem.features.send.v2.feeselector.component.token.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.common.TangemBlogUrlBuilder +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.navigation.url.UrlOpener +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.token.FeeTokenSelectorIntents +import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenSelectorUM +import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class FeeTokenSelectorModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val urlOpener: UrlOpener, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, +) : Model(), FeeTokenSelectorIntents, + FeeSelectorIntents by paramsContainer.require().intents { + + private val params = paramsContainer.require() + private var appCurrency = AppCurrency.Default + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + init { + initAppCurrency() + + params.state + .onEach { uiState.value = stateFromParent(it as FeeSelectorUM.Content) } + .launchIn(modelScope) + } + + private fun initAppCurrency() { + modelScope.launch { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + } + } + + private fun getInitialState(): FeeTokenSelectorUM { + val parentState = params.state.value as FeeSelectorUM.Content + return stateFromParent(parentState) + } + + private fun stateFromParent(newParentState: FeeSelectorUM.Content): FeeTokenSelectorUM { + val parentState = newParentState + val selectedCurrencyStatus = parentState.feeExtraInfo.feeCryptoCurrencyStatus + val selectedTokenUM = convertToken(selectedCurrencyStatus) + + return FeeTokenSelectorUM( + parent = parentState, + selectedToken = selectedTokenUM, + tokens = parentState.feeExtraInfo.availableFeeCurrencies?.map { status -> + convertToken(status) + }?.toImmutableList() ?: persistentListOf(selectedTokenUM), + ) + } + + private fun convertToken(status: CryptoCurrencyStatus): TokenItemState { + return FeeTokenForListConverter( + appCurrencyProvider = Provider { appCurrency }, + onTokenClick = { onTokenSelected(status) }, + ).convert(status) + } + + override fun onLearnMoreClick() { + modelScope.launch { + urlOpener.openUrl( + TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee), + ) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt new file mode 100644 index 0000000000..97f3d02076 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt @@ -0,0 +1,226 @@ +package com.tangem.features.send.v2.feeselector.component.token.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.* +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.selectedBorder +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +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.v2.api.entity.* +import com.tangem.features.send.v2.feeselector.component.token.FeeTokenSelectorIntents +import com.tangem.features.send.v2.feeselector.component.token.StubFeeTokenSelectorIntents +import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenSelectorUM +import com.tangem.features.send.v2.impl.R +import com.tangem.utils.StringsSigns +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal + +@Composable +internal fun FeeTokenSelectorContent( + state: FeeTokenSelectorUM, + intents: FeeTokenSelectorIntents, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ), + ) { + Title(onLearnMoreClick = intents::onLearnMoreClick) + SpacerH8() + TokenListContent(state) + } +} + +@Composable +private fun Title(modifier: Modifier = Modifier, onLearnMoreClick: () -> Unit) { + val linkText = stringResourceSafe(R.string.common_learn_more) + val fullString = stringResourceSafe(R.string.fee_selector_choose_token_description, linkText) + val linkTextPosition = fullString.length - linkText.length + val defaultColor = TangemTheme.colors.text.secondary + val linkColor = TangemTheme.colors.text.accent + val annotatedString = remember(defaultColor, linkColor, onLearnMoreClick) { + buildAnnotatedString { + withStyle(SpanStyle(defaultColor)) { + append(fullString.substring(0, linkTextPosition)) + } + withLink( + link = LinkAnnotation.Clickable( + tag = "learn_more", + linkInteractionListener = { onLearnMoreClick() }, + ), + block = { + withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.None)) { + append( + fullString.substring(linkTextPosition, fullString.length).replace( + ' ', + StringsSigns.NON_BREAKING_SPACE, + ), + ) + } + }, + ) + } + } + + Text( + modifier = modifier.padding(horizontal = 32.dp), + text = annotatedString, + style = TangemTheme.typography.caption2, + textAlign = TextAlign.Center, + ) +} + +@Composable +internal fun TokenListContent(state: FeeTokenSelectorUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.tokens.fastForEach { item -> + key(item.id) { + TokenItem( + modifier = Modifier + .selectedBorder(isSelected = state.selectedToken.id == item.id) + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.action), + state = item, + isBalanceHidden = false, + ) + } + } + } +} + +@Suppress("LongMethod") +@Preview +@Composable +private fun Preview() { + val cryptoCurrencyStatus = CryptoCurrencyStatus( + currency = CryptoCurrency.Coin( + id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), + network = Network( + id = Network.ID( + value = "bitcoin", + derivationPath = Network.DerivationPath.None, + ), + backendId = "bitcoin", + name = "Bitcoin", + currencySymbol = "BTC", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("bitcoin"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ), + name = "Bitcoin", + symbol = "BTC", + decimals = 8, + iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png", + isCustom = false, + ), + value = CryptoCurrencyStatus.Loading, + ) + + val parentUM = FeeSelectorUM.Content( + isPrimaryButtonEnabled = false, + feeItems = persistentListOf( + FeeItem.Market( + fee = Fee.Common( + Amount( + value = BigDecimal("0.02"), + blockchain = Blockchain.Ethereum, + ), + ), + ), + ), + selectedFeeItem = FeeItem.Slow( + fee = Fee.Common( + Amount( + value = BigDecimal("0.01"), + blockchain = Blockchain.Ethereum, + ), + ), + ), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = true, + isFeeConvertibleToFiat = true, + isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, + ), + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal.TEN, + appCurrency = AppCurrency.Default, + ), + feeNonce = FeeNonce.None, + fees = TransactionFee.Single( + Fee.Common( + Amount( + value = BigDecimal("0.01"), + blockchain = Blockchain.Ethereum, + ), + ), + ), + ) + + val tokenItemState = TokenItemState.Content( + id = "1", + iconState = CurrencyIconState.Locked, + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Bitcoin")), + fiatAmountState = TokenItemState.FiatAmountState.Empty, + subtitle2State = null, + subtitleState = TokenItemState.SubtitleState.TextContent( + value = stringReference("Balance: 0,35853044 BTC"), + isAvailable = false, + ), + onItemClick = {}, + onItemLongClick = {}, + ) + + val state = FeeTokenSelectorUM( + parent = parentUM, + selectedToken = tokenItemState, + tokens = persistentListOf(tokenItemState, tokenItemState.copy(id = "2")), + ) + + TangemThemePreview { + FeeTokenSelectorContent( + state = state, + intents = StubFeeTokenSelectorIntents(), + modifier = Modifier + .background(TangemTheme.colors.background.tertiary), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt index 52552917e3..1891f991d3 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt @@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.features.send.v2.feeselector.component.extended.model.FeeExtendedSelectorModel import com.tangem.features.send.v2.feeselector.model.FeeSelectorBlockModel import com.tangem.features.send.v2.feeselector.component.speed.model.FeeSpeedSelectorModel +import com.tangem.features.send.v2.feeselector.component.token.model.FeeTokenSelectorModel import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel import dagger.Binds import dagger.Module @@ -33,5 +34,10 @@ internal interface FeeSelectorModelModule { @Binds @IntoMap @ClassKey(FeeSpeedSelectorModel::class) - fun bindFeeSelectorSpeedModel(model: FeeSpeedSelectorModel): Model + fun bindFeeSpeedSelectorModel(model: FeeSpeedSelectorModel): Model + + @Binds + @IntoMap + @ClassKey(FeeTokenSelectorModel::class) + fun bindFeeTokenSelectorModel(model: FeeTokenSelectorModel): Model } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorIntents.kt index ad255feda4..ebb0c7a91b 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorIntents.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorIntents.kt @@ -1,11 +1,13 @@ package com.tangem.features.send.v2.feeselector.model import androidx.compose.runtime.Stable +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.send.v2.api.entity.FeeItem @Stable internal interface FeeSelectorIntents { fun onFeeItemSelected(feeItem: FeeItem) + fun onTokenSelected(status: CryptoCurrencyStatus) fun onCustomFeeValueChange(index: Int, value: String) fun onNonceChange(value: String) fun onDoneClick() @@ -13,6 +15,7 @@ internal interface FeeSelectorIntents { internal class StubFeeSelectorIntents : FeeSelectorIntents { override fun onFeeItemSelected(feeItem: FeeItem) {} + override fun onTokenSelected(status: CryptoCurrencyStatus) {} override fun onCustomFeeValueChange(index: Int, value: String) {} override fun onNonceChange(value: String) {} override fun onDoneClick() {} diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt index 41ba48e249..81983520fd 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt @@ -2,6 +2,7 @@ package com.tangem.features.send.v2.feeselector.model import arrow.core.Either import arrow.core.getOrElse +import arrow.core.raise.either import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -12,6 +13,8 @@ import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase +import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.NonceInserted import com.tangem.features.send.v2.api.entity.FeeItem @@ -32,6 +35,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @Suppress("LongParameterList") @@ -47,6 +51,8 @@ internal class FeeSelectorLogic @AssistedInject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val sendFeatureToggles: SendFeatureToggles, private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val getAvailableFeeTokensUseCase: GetAvailableFeeTokensUseCase, ) : FeeSelectorIntents { private var appCurrency: AppCurrency = AppCurrency.Default @@ -105,6 +111,20 @@ internal class FeeSelectorLogic @AssistedInject constructor( uiState.update(FeeItemSelectedTransformer(feeItem)) } + override fun onTokenSelected(status: CryptoCurrencyStatus) { + uiState.update { state -> + if (state is FeeSelectorUM.Content) { + state.copy( + feeExtraInfo = state.feeExtraInfo.copy( + feeCryptoCurrencyStatus = status, + ), + ) + } else { + state + } + } + } + override fun onCustomFeeValueChange(index: Int, value: String) { uiState.update( FeeSelectorCustomValueChangedTransformer( @@ -208,20 +228,7 @@ internal class FeeSelectorLogic @AssistedInject constructor( } }, ifRight = { fee -> - Either.Right( - LoadedFeeResult.Extended( - fee = fee, - selectedToken = if (params.feeCryptoCurrencyStatus.currency.id != fee.feeTokenId) { - getSingleCryptoCurrencyStatusUseCase - .invokeMultiWalletSync( - userWalletId = TODO(), - cryptoCurrencyId = fee.feeTokenId, - ).getOrNull() - } else { - params.feeCryptoCurrencyStatus - }, - ), - ) + populateExtendedFee(fee) }, ) } else { @@ -229,10 +236,42 @@ internal class FeeSelectorLogic @AssistedInject constructor( } } + private suspend fun populateExtendedFee( + fee: TransactionFeeExtended, + ): Either = either { + val selectedToken = if (params.feeCryptoCurrencyStatus.currency.id != fee.feeTokenId) { + getSingleCryptoCurrencyStatusUseCase + .invokeMultiWalletSync( + userWalletId = params.userWalletId, + cryptoCurrencyId = fee.feeTokenId, + ).getOrElse { + raise(GetFeeError.DataError(IllegalStateException("No token found for id: ${fee.feeTokenId}"))) + } + } else { + params.feeCryptoCurrencyStatus + } + + val userWallet = getUserWalletUseCase(params.userWalletId).mapLeft { + GetFeeError.DataError(IllegalStateException("No wallet found for id: ${params.userWalletId}")) + }.bind() + + val availableTokens = getAvailableFeeTokensUseCase.invoke( + userWallet = userWallet, + network = params.cryptoCurrencyStatus.currency.network, + ).bind() + + LoadedFeeResult.Extended( + fee = fee, + selectedToken = selectedToken, + availableTokens = availableTokens, + ) + } + sealed class LoadedFeeResult { data class Extended( val fee: TransactionFeeExtended, val selectedToken: CryptoCurrencyStatus?, + val availableTokens: List, ) : LoadedFeeResult() data class Basic(val fee: TransactionFee) : LoadedFeeResult() diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt index 5bf01617b0..c80fac2068 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt @@ -7,6 +7,8 @@ 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.utils.stack +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.send.v2.api.entity.FeeItem import com.tangem.features.send.v2.api.entity.FeeSelectorUM @@ -24,6 +26,7 @@ internal class FeeSelectorModel @Inject constructor( feeSelectorLogicFactory: FeeSelectorLogic.Factory, override val dispatchers: CoroutineDispatcherProvider, private val sendFeatureToggles: SendFeatureToggles, + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) : Model(), FeeSelectorIntents { private val params = paramsContainer.require() @@ -54,6 +57,11 @@ internal class FeeSelectorModel @Inject constructor( } } + override fun onTokenSelected(status: CryptoCurrencyStatus) { + feeSelectorLogic.onTokenSelected(status) + stackNavigation.pop() + } + override fun onCustomFeeValueChange(index: Int, value: String) { feeSelectorLogic.onCustomFeeValueChange(index, value) } @@ -85,7 +93,10 @@ internal class FeeSelectorModel @Inject constructor( return FeeSelectorRoute.ChooseSpeed } - // TODO: Add logic to choose initial route based on other conditions - return FeeSelectorRoute.NetworkFee + return if (isGaslessFeeSupportedForNetwork(params.feeCryptoCurrencyStatus.currency.network)) { + FeeSelectorRoute.NetworkFee + } else { + FeeSelectorRoute.ChooseSpeed + } } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt index 4b0668785a..75fb5135d8 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt @@ -11,6 +11,7 @@ import com.tangem.features.send.v2.feeselector.model.FeeSelectorLogic.LoadedFeeR import com.tangem.lib.crypto.BlockchainUtils.isTron import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList @Suppress("LongParameterList") internal class FeeSelectorLoadedTransformer( @@ -61,6 +62,7 @@ internal class FeeSelectorLoadedTransformer( isTronToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token && isTron(cryptoCurrencyStatus.currency.network.rawId), feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + availableFeeCurrencies = getAvailableFeeCurrencies(), ), feeFiatRateUM = feeCryptoCurrencyStatus.value.fiatRate?.let { rate -> FeeFiatRateUM( @@ -78,4 +80,9 @@ internal class FeeSelectorLoadedTransformer( }, ) } + + private fun getAvailableFeeCurrencies(): ImmutableList? { + if (fees !is LoadedFeeResult.Extended) return null + return fees.availableTokens.toImmutableList() + } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt index 02c4c88993..fef494deef 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt @@ -92,6 +92,7 @@ internal class SendConfirmComponent( feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen, analyticsCategoryName = params.analyticsCategoryName, analyticsSendSource = params.analyticsSendSource, + userWalletId = params.userWallet.walletId, ), onResult = model::onFeeResult, ) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt index 8bf26ce732..fec1c74cb7 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt @@ -78,6 +78,7 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen, analyticsCategoryName = params.analyticsCategoryName, analyticsSendSource = params.analyticsSendSource, + userWalletId = params.userWallet.walletId, ), onResult = model::onFeeResult, ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index 0b4b6b742e..dc79a0ee29 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -96,6 +96,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( feeDisplaySource = FeeDisplaySource.Screen, analyticsCategoryName = params.analyticsCategoryName, analyticsSendSource = params.analyticsSendSource, + userWalletId = params.userWallet.walletId, ), onResult = model::onFeeResult, ) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt index eaf30bde46..d4686d0460 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt @@ -57,6 +57,7 @@ internal fun getWcCommonScreen( feeDisplaySource = FeeDisplaySource.BottomSheet, analyticsCategoryName = WcAnalyticEvents.WC_CATEGORY_NAME, analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.WalletConnect, + userWalletId = model.userWallet.walletId, ), ) } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt index abb3dcce53..cf234d50b6 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt @@ -76,6 +76,7 @@ internal class WcSendTransactionComponent( feeDisplaySource = FeeSelectorParams.FeeDisplaySource.BottomSheet, analyticsCategoryName = WcAnalyticEvents.WC_CATEGORY_NAME, analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.WalletConnect, + userWalletId = model.userWallet.walletId, ), onResult = model::updateFee, ) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt index d305e4a233..f02eb11f51 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt @@ -105,6 +105,9 @@ internal class WcSendTransactionModel @Inject constructor( private val securityStatusState = MutableStateFlow>(Lce.Loading(partialContent = null)) + val userWallet + get() = useCase.wallet + init { @Suppress("UnusedPrivateMember") modelScope.launch { From 8564f21d01125ffb277a200663b86f50f1355472 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 7 Jan 2026 14:23:34 +0300 Subject: [PATCH 065/172] Updated on 2026-08-14 --- .../DefaultGaslessTransactionRepository.kt | 41 +++--- .../GaslessTxDataToGaslessRequestConverter.kt | 4 +- .../models/GaslessTransactionData.kt | 91 ++++++------- .../CreateAndSendGaslessTransactionUseCase.kt | 126 +++++++++++++++++- .../usecase/gasless/Eip712TypedDataBuilder.kt | 124 +++++++++++++++++ .../gasless/GetAvailableFeeTokensUseCase.kt | 9 +- .../gasless/GetFeeForGaslessUseCase.kt | 17 ++- .../usecase/gasless/TokenFeeCalculator.kt | 38 +++++- gradle/tangem_dependencies.toml | 2 +- 9 files changed, 369 insertions(+), 83 deletions(-) create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt index e68999f26f..7a4a698b9e 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -26,7 +26,7 @@ class DefaultGaslessTransactionRepository( private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, ) : GaslessTransactionRepository { - private val supportedTokensState = MutableStateFlow?>(null) + private val supportedTokensState = MutableStateFlow>>(hashMapOf()) private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder() private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter() @@ -37,28 +37,37 @@ class DefaultGaslessTransactionRepository( override suspend fun getSupportedTokens(network: Network): Set { return withContext(coroutineDispatcherProvider.io) { - val storedTokens = supportedTokensState.value + val storedTokens = supportedTokensState.value[network.id] if (storedTokens != null && storedTokens.isNotEmpty()) { return@withContext storedTokens } val supportedTokensData = gaslessTxServiceApi.getSupportedTokens().getOrThrow() if (supportedTokensData.isSuccess) { - val supportedTokens = supportedTokensData.result.tokens.mapNotNull { token -> - val blockchain = Blockchain.fromChainId(token.chainId) ?: return@mapNotNull null - responseCryptoCurrenciesFactory.createToken( - blockchain = blockchain, - sdkToken = Token( - contractAddress = token.tokenAddress, - name = token.tokenName, - symbol = token.tokenSymbol, - decimals = token.decimals, - ), - network = network, - ) - }.toSet() + val networkBlockchain = Blockchain.fromNetworkId(network.backendId) + ?: error("Cannot determine blockchain for network id: ${network.backendId}") + val supportedTokens = supportedTokensData.result.tokens + .filter { + it.chainId == networkBlockchain.getChainId() + } + .map { token -> + responseCryptoCurrenciesFactory.createToken( + blockchain = networkBlockchain, + sdkToken = Token( + contractAddress = token.tokenAddress, + name = token.tokenName, + symbol = token.tokenSymbol, + decimals = token.decimals, + ), + network = network, + ) + }.toSet() // update local cache - supportedTokensState.update { supportedTokens } + supportedTokensState.update { current -> + val newMap = current.toMutableMap() + newMap[network.id] = supportedTokens + newMap + } return@withContext supportedTokens } else { error("Gasless service returned unsuccessful response") diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt index 16b1683a11..44005be4c1 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt @@ -23,7 +23,7 @@ class GaslessTxDataToGaslessRequestConverter : Converter { + return either { + catch( + block = { + transactionData as? TransactionData.Uncompiled ?: error("Uncompiled transaction data required") + + val tokenForFeeStatus = getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWalletId, + fee.feeTokenId, + ).getOrNull() ?: error("Token for fee not found") + + val blockchain = Blockchain.fromId(tokenForFeeStatus.currency.network.rawId) + + val gaslessTransactionData = createGaslessTransactionData( + userWalletId = userWalletId, + transactionData = transactionData, + txFee = fee, + tokenFeeStatus = tokenForFeeStatus, + ) + + val chainId = + blockchain.getChainId() ?: error("ChainId not found for blockchain ${blockchain.name}") + + val eip712Data = Eip712TypedDataBuilder.build( + gaslessTransaction = gaslessTransactionData, + chainId = chainId, + verifyingContract = transactionData.sourceAddress, + ) + + val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data) + }, + catch = { + raise(SendTransactionError.DataError(it.message)) + }, + ) + } + } + + private suspend fun createGaslessTransactionData( + userWalletId: UserWalletId, + transactionData: TransactionData.Uncompiled, + txFee: TransactionFeeExtended, + tokenFeeStatus: CryptoCurrencyStatus, + ): GaslessTransactionData { + val bigIntegerAmount = + transactionData.amount.value?.movePointRight(transactionData.amount.decimals)?.toBigInteger() + ?: error("Amount value is null") + val txData = (transactionData.extras as? EthereumTransactionExtras)?.callData ?: error("Call data required") + + val transaction = GaslessTransactionData.Transaction( + to = getDestinationAddress(transactionData), + value = bigIntegerAmount, + data = txData.data, + ) + + val tokenForFee = tokenFeeStatus.currency as? CryptoCurrency.Token + ?: error("only CryptoCurrency.Token supported for fee") + + val txFeeInTokenCurrency = txFee.transactionFee.normal as? Fee.Ethereum.TokenCurrency ?: error( + "only Fee.Ethereum.TokenCurrency supported for gasless fee", + ) + val fee = GaslessTransactionData.Fee( + feeToken = tokenForFee.contractAddress, + maxTokenFee = txFeeInTokenCurrency.gasLimit, + coinPriceInToken = txFeeInTokenCurrency.coinPriceInToken, + feeTransferGasLimit = txFeeInTokenCurrency.feeTransferGasLimit, + baseGas = txFeeInTokenCurrency.baseGas, + ) + + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, tokenForFee.network) + ?: error("WalletManager not found for network ${tokenForFee.network.id}") + val gaslessDataProvider = walletManager as? EthereumGaslessDataProvider ?: error( + "WalletManager for network ${tokenForFee.network.id} does not support gasless transactions", + ) + + val nonceResult = gaslessDataProvider.getGaslessContractNonce( + userAddress = transactionData.sourceAddress, + ) + val nonce = when (nonceResult) { + is com.tangem.blockchain.extensions.Result.Failure -> BigInteger.ZERO + is com.tangem.blockchain.extensions.Result.Success -> nonceResult.data + } + + return GaslessTransactionData( + transaction = transaction, + fee = fee, + nonce = nonce, + ) + } + + private fun getDestinationAddress(txData: TransactionData.Uncompiled): String { + val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData + return if (ethereumCallData is EthereumYieldSupplySendCallData) { + ethereumCallData.destinationAddress + } else { + txData.destinationAddress + } } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt new file mode 100644 index 0000000000..6164e008db --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt @@ -0,0 +1,124 @@ +package com.tangem.domain.transaction.usecase.gasless + +import com.tangem.common.extensions.toHexString +import com.tangem.domain.transaction.models.GaslessTransactionData +import org.json.JSONArray +import org.json.JSONObject + +/** + * Builder for creating EIP-712 typed data JSON for gasless transaction signing. + * + * The EIP-712 standard allows users to sign typed, structured data instead of raw bytes. + * This provides better UX as wallets can show users exactly what they're signing. + * + * Example usage: + * ```kotlin + * val typedDataJson = Eip712TypedDataBuilder.build( + * gaslessTransaction = gaslessTransactionData, + * chainId = 1, + * verifyingContract = "0x1234..." + * ) + * val signature = wallet.signTypedData(typedDataJson) + * ``` + */ +object Eip712TypedDataBuilder { + + private const val DOMAIN_NAME = "Tangem7702GaslessExecutor" + private const val DOMAIN_VERSION = "1" + private const val PRIMARY_TYPE = "GaslessTransaction" + + /** + * Builds EIP-712 typed data JSON for gasless transaction. + * + * @param gaslessTransaction domain model with transaction and fee data + * @param chainId blockchain network chain ID + * @param verifyingContract address of the deployed gasless executor contract + * @return JSON string ready for EIP-712 signing + */ + fun build(gaslessTransaction: GaslessTransactionData, chainId: Int, verifyingContract: String): String { + val typedData = JSONObject().apply { + put("types", buildTypes()) + put("primaryType", PRIMARY_TYPE) + put("domain", buildDomain(chainId, verifyingContract)) + put("message", buildMessage(gaslessTransaction)) + } + return typedData.toString() + } + + /** + * Builds the type definitions for all structures. + * This schema is fixed and defines the structure of the data being signed. + */ + @Suppress("NestedScopeFunctions") + private fun buildTypes(): JSONObject { + return JSONObject().apply { + put("EIP712Domain", JSONArray().apply { + put(typeProperty("name", "string")) + put(typeProperty("version", "string")) + put(typeProperty("chainId", "uint256")) + put(typeProperty("verifyingContract", "address")) + }) + put("Transaction", JSONArray().apply { + put(typeProperty("to", "address")) + put(typeProperty("value", "uint256")) + put(typeProperty("data", "bytes")) + }) + put("Fee", JSONArray().apply { + put(typeProperty("feeToken", "address")) + put(typeProperty("maxTokenFee", "uint256")) + put(typeProperty("coinPriceInToken", "uint256")) + put(typeProperty("feeTransferGasLimit", "uint256")) + put(typeProperty("baseGas", "uint256")) + }) + put("GaslessTransaction", JSONArray().apply { + put(typeProperty("transaction", "Transaction")) + put(typeProperty("fee", "Fee")) + put(typeProperty("nonce", "uint256")) + }) + } + } + + /** + * Creates a type property JSON object. + */ + private fun typeProperty(name: String, type: String): JSONObject { + return JSONObject().apply { + put("name", name) + put("type", type) + } + } + + /** + * Builds the domain separator. + */ + private fun buildDomain(chainId: Int, verifyingContract: String): JSONObject { + return JSONObject().apply { + put("name", DOMAIN_NAME) + put("version", DOMAIN_VERSION) + put("chainId", chainId) + put("verifyingContract", verifyingContract) + } + } + + /** + * Builds the message data from gasless transaction. + */ + @Suppress("NestedScopeFunctions") + private fun buildMessage(gaslessTransaction: GaslessTransactionData): JSONObject { + return JSONObject().apply { + put("transaction", JSONObject().apply { + put("to", gaslessTransaction.transaction.to) + put("value", gaslessTransaction.transaction.value.toString()) + put("data", gaslessTransaction.transaction.data.toHexString()) + }) + put("fee", JSONObject().apply { + put("feeToken", gaslessTransaction.fee.feeToken) + put("maxTokenFee", gaslessTransaction.fee.maxTokenFee.toString()) + put("coinPriceInToken", gaslessTransaction.fee.coinPriceInToken.toString()) + put("feeTransferGasLimit", gaslessTransaction.fee.feeTransferGasLimit.toString()) + put("baseGas", gaslessTransaction.fee.baseGas.toString()) + }) + put("nonce", gaslessTransaction.nonce.toString()) + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt index 1515c315e8..f3881c6787 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -77,12 +77,17 @@ class GetAvailableFeeTokensUseCase( userCurrenciesStatuses: List, ): List { val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(network) + .mapNotNull { + (it as? CryptoCurrency.Token)?.contractAddress?.lowercase() + }.toSet() return userCurrenciesStatuses .asSequence() + .filter { it.currency.network.id == network.id } .filter { currencyStatus -> - currencyStatus.currency is CryptoCurrency.Token && + val token = currencyStatus.currency + token is CryptoCurrency.Token && currencyStatus.value.amount?.let { amount -> amount > BigDecimal.ZERO } == true && - supportedGaslessTokens.contains(currencyStatus.currency) + supportedGaslessTokens.contains(token.contractAddress.lowercase()) } .toList() } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt index 84be8c0fde..a8ef3aa70b 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt @@ -151,11 +151,17 @@ class GetFeeForGaslessUseCase( val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens( network = nativeCurrencyStatus.currency.network, - ) + ).mapNotNull { currency -> + (currency as? CryptoCurrency.Token)?.contractAddress + }.toSet() + val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses .filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token } .sortedByDescending { it.value.amount } - .filter { it.currency in supportedGaslessTokens } + .filter { status -> + val token = status.currency as? CryptoCurrency.Token ?: return@filter false + token.contractAddress.lowercase() in supportedGaslessTokens + } /** * Selects token with highest balance to maximize chances of successful fee payment. @@ -171,11 +177,4 @@ class GetFeeForGaslessUseCase( initialFee = initialFee, ).bind() } - - private companion object { - /** Amount in token units for fee transfer */ - const val FEE_TRANSFER_AMOUNT = 10000 - /** Gas price safety multiplier for fee calculation */ - const val GAS_PRICE_MULTIPLIER = 2 - } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt index 392aa39519..e1c07c4617 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt @@ -101,13 +101,21 @@ internal class TokenFeeCalculator( val nativeFiatRate = nativeCurrencyStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null") val tokenFiatRate = tokenForPayFeeStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null") - val coinPriceInToken = nativeFiatRate.divide( + + val coinPriceInTokenInBigDecimal = nativeFiatRate.divide( tokenFiatRate, maxOf(nativeCurrencyStatus.currency.decimals, tokenForPayFee.decimals), - RoundingMode.DOWN, + RoundingMode.UP, ) - val feeInTokenCurrency = coinPriceInToken.multiply(feeInNativeCurrency.toBigDecimal()) + val coinPriceInTokenBigInt = coinPriceInTokenInBigDecimal + .movePointRight(tokenForPayFee.decimals) + .increaseByPercent(PERCENT_TO_INCREASE_TOKEN_PRICE) + .toBigInteger() + + val feeInTokenCurrency = coinPriceInTokenInBigDecimal.multiply( + feeInNativeCurrency.toBigDecimal(), + ) val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO if (tokenBalance < feeInTokenCurrency) { @@ -119,7 +127,7 @@ internal class TokenFeeCalculator( val fee = Fee.Ethereum.TokenCurrency( amount = amount, gasLimit = maxTokenFeeGas, - coinPriceInToken = coinPriceInToken, + coinPriceInToken = coinPriceInTokenBigInt, feeTransferGasLimit = feeTransferGasLimit, baseGas = baseGas, ) @@ -151,8 +159,28 @@ internal class TokenFeeCalculator( private companion object { /** Amount in token units for fee transfer */ - const val FEE_TRANSFER_AMOUNT = 10000 + const val FEE_TRANSFER_AMOUNT = 0.01 // calculate using decimals /** Gas price safety multiplier for fee calculation */ const val GAS_PRICE_MULTIPLIER = 2 + const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1 + + /** + * Increases BigDecimal value by specified percentage. + * + * @param percent percentage to increase by (e.g., 1 for 1%, 10 for 10%) + * @return value increased by specified percentage (value * (1 + percent/100)) + * + * Example: + * ``` + * BigDecimal("100").increaseByPercent(1) // 101 + * BigDecimal("100").increaseByPercent(10) // 110 + * BigDecimal("100").increaseByPercent(50) // 150 + * ``` + */ + private fun BigDecimal.increaseByPercent(percent: Int): BigDecimal { + require(percent >= 0) { "Percent must be non-negative" } + val multiplier = BigDecimal.ONE.add(BigDecimal(percent).divide(BigDecimal("100"))) + return this.multiply(multiplier) + } } } \ No newline at end of file diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index b5bf92e53a..342d7d6a54 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-1340" +tangemBlockchainSdk = "develop-1346" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 7740d6f4536007144e1837867628b714fd4b5d82 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 7 Jan 2026 15:45:42 +0300 Subject: [PATCH 066/172] Updated on 2026-08-14 --- .../tap/di/domain/TransactionDomainModule.kt | 6 ++-- .../MockedGaslessTransactionRepository.kt | 6 ++-- .../usecase/gasless/TokenFeeCalculator.kt | 4 ++- .../send/v2/api/entity/FeeSelectorUM.kt | 14 +++++++- .../send/v2/api/params/FeeSelectorParams.kt | 10 ++++-- .../DefaultFeeSelectorBlockComponent.kt | 1 + .../model/FeeExtendedSelectorModel.kt | 35 ++++++++---------- .../model/SelectedTokenItemConverter.kt | 21 +++++++---- .../extended/ui/FeeExtendedSelectorContent.kt | 9 +++-- .../speed/ui/FeeSpeedSelectorContent.kt | 12 +++++-- .../token/model/FeeTokenSelectorModel.kt | 4 ++- .../model/FeeSelectorAlertFactory.kt | 1 + .../v2/feeselector/model/FeeSelectorLogic.kt | 23 +++++++++++- .../FeeSelectorLoadedTransformer.kt | 5 +-- .../FeeSelectorTokenSelectedTransformer.kt | 26 ++++++++++++++ .../send/v2/send/DefaultSendComponent.kt | 1 + .../v2/send/confirm/SendConfirmComponent.kt | 3 ++ .../features/send/v2/send/model/SendModel.kt | 36 ++++++++++++++++--- 18 files changed, 167 insertions(+), 50 deletions(-) create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index ea4bd0c0da..5eb7159463 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -291,7 +291,6 @@ internal object TransactionDomainModule { @Singleton fun provideGetFeeForGaslessUseCase( walletManagersFacade: WalletManagersFacade, - demoConfig: DemoConfig, gaslessTransactionRepository: GaslessTransactionRepository, currenciesRepository: CurrenciesRepository, getFeeUseCase: GetFeeUseCase, @@ -299,7 +298,7 @@ internal object TransactionDomainModule { ): GetFeeForGaslessUseCase { return GetFeeForGaslessUseCase( walletManagersFacade = walletManagersFacade, - demoConfig = demoConfig, + demoConfig = DemoConfig, gaslessTransactionRepository = gaslessTransactionRepository, currenciesRepository = currenciesRepository, getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, @@ -311,7 +310,6 @@ internal object TransactionDomainModule { @Singleton fun provideGetFeeForTokenUseCase( walletManagersFacade: WalletManagersFacade, - demoConfig: DemoConfig, gaslessTransactionRepository: GaslessTransactionRepository, currenciesRepository: CurrenciesRepository, getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, @@ -319,7 +317,7 @@ internal object TransactionDomainModule { return GetFeeForTokenUseCase( gaslessTransactionRepository = gaslessTransactionRepository, walletManagersFacade = walletManagersFacade, - demoConfig = demoConfig, + demoConfig = DemoConfig, currenciesRepository = currenciesRepository, getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, ) diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt index 72e1e7c662..9ad092d702 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt @@ -25,9 +25,9 @@ class MockedGaslessTransactionRepository( val usdcPolygon = responseCryptoCurrenciesFactory.createToken( blockchain = Blockchain.Polygon, sdkToken = Token( - contractAddress = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", - name = "USD Coin", - symbol = "USDC", + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + name = "Tether", + symbol = "USDT", decimals = 6, ), network = network, diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt index e1c07c4617..612cd7cb24 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt @@ -114,7 +114,9 @@ internal class TokenFeeCalculator( .toBigInteger() val feeInTokenCurrency = coinPriceInTokenInBigDecimal.multiply( - feeInNativeCurrency.toBigDecimal(), + feeInNativeCurrency + .toBigDecimal() + .movePointLeft(nativeCurrencyStatus.currency.decimals), ) val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt index a62fec37f1..ef578be698 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt @@ -1,6 +1,8 @@ package com.tangem.features.send.v2.api.entity import androidx.compose.runtime.Immutable +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.analytics.models.AnalyticsParam @@ -44,7 +46,7 @@ sealed class FeeSelectorUM { is Custom, -> AnalyticsParam.FeeType.Custom is Fast -> AnalyticsParam.FeeType.Max - is Market -> AnalyticsParam.FeeType.Normal + is Market, is FeeItem.Loading -> AnalyticsParam.FeeType.Normal is Slow -> AnalyticsParam.FeeType.Min } } @@ -84,6 +86,16 @@ sealed class FeeItem { return this::class == other::class } + fun isLoading(): Boolean { + return this is Loading + } + + object Loading : FeeItem() { + override val title: TextReference = resourceReference(R.string.common_fee_selector_option_market) + override val iconRes: Int = R.drawable.ic_bird_24 + override val fee: Fee = Fee.Common(Amount(BigDecimal.ZERO, Blockchain.Unknown)) + } + data class Suggested( override val title: TextReference, override val fee: Fee, diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt index 8e46ee3710..8a6a6edf3e 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt @@ -15,7 +15,7 @@ import com.tangem.features.send.v2.api.entity.FeeSelectorUM sealed class FeeSelectorParams { abstract val state: FeeSelectorUM abstract val userWalletId: UserWalletId - abstract val onLoadFeeExtended: (suspend () -> Either)? + abstract val onLoadFeeExtended: (suspend (CryptoCurrencyStatus?) -> Either)? abstract val onLoadFee: suspend () -> Either abstract val cryptoCurrencyStatus: CryptoCurrencyStatus abstract val feeCryptoCurrencyStatus: CryptoCurrencyStatus @@ -27,7 +27,9 @@ sealed class FeeSelectorParams { data class FeeSelectorBlockParams( override val state: FeeSelectorUM, override val userWalletId: UserWalletId, - override val onLoadFeeExtended: (suspend () -> Either)? = null, + override val onLoadFeeExtended: ( + suspend (CryptoCurrencyStatus?) -> Either + )? = null, override val onLoadFee: suspend () -> Either, override val cryptoCurrencyStatus: CryptoCurrencyStatus, override val feeCryptoCurrencyStatus: CryptoCurrencyStatus, @@ -40,7 +42,9 @@ sealed class FeeSelectorParams { data class FeeSelectorDetailsParams( override val state: FeeSelectorUM, override val userWalletId: UserWalletId, - override val onLoadFeeExtended: (suspend () -> Either)? = null, + override val onLoadFeeExtended: ( + suspend (CryptoCurrencyStatus?) -> Either + )? = null, override val onLoadFee: suspend () -> Either, override val cryptoCurrencyStatus: CryptoCurrencyStatus, override val feeCryptoCurrencyStatus: CryptoCurrencyStatus, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt index bf86789265..80a1151aa7 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt @@ -47,6 +47,7 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor( params = FeeSelectorParams.FeeSelectorDetailsParams( state = model.uiState.value, onLoadFee = params.onLoadFee, + onLoadFeeExtended = params.onLoadFeeExtended, feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, cryptoCurrencyStatus = params.cryptoCurrencyStatus, callback = model, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt index 75d481da3c..0a68ace88e 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt @@ -29,10 +29,6 @@ class FeeExtendedSelectorModel @Inject constructor( private val params = paramsContainer.require() private var appCurrency: AppCurrency = AppCurrency.Default - private val tokenConverter = SelectedTokenItemConverter( - appCurrencyProvider = Provider { appCurrency }, - onTokenClick = { router.push(FeeSelectorRoute.ChooseToken) }, - ) init { initAppCurrency() @@ -44,19 +40,13 @@ class FeeExtendedSelectorModel @Inject constructor( init { params.state .filterIsInstance() - .onEach(::onParentStateUpdated) + .onEach { state -> uiState.value = convertState(state) } .launchIn(modelScope) } private fun getInitialState(): FeeExtendedSelectorUM { val parentContentState = params.state.value as FeeSelectorUM.Content - - return FeeExtendedSelectorUM( - parent = parentContentState, - token = tokenConverter.convert(parentContentState.feeExtraInfo.feeCryptoCurrencyStatus), - fee = parentContentState.selectedFeeItem, - onFeeClick = { router.push(FeeSelectorRoute.ChooseSpeed) }, - ) + return convertState(parentContentState) } private fun initAppCurrency() { @@ -65,13 +55,18 @@ class FeeExtendedSelectorModel @Inject constructor( } } - private fun onParentStateUpdated(state: FeeSelectorUM.Content) { - uiState.update { current -> - current.copy( - parent = state, - token = tokenConverter.convert(state.feeExtraInfo.feeCryptoCurrencyStatus), - fee = state.selectedFeeItem, - ) - } + private fun convertState(state: FeeSelectorUM.Content): FeeExtendedSelectorUM { + val tokenConverter = SelectedTokenItemConverter( + appCurrencyProvider = Provider { appCurrency }, + onTokenClick = { router.push(FeeSelectorRoute.ChooseToken) }, + isTokenSelectionAvailable = state.feeExtraInfo.availableFeeCurrencies.orEmpty().size > 1, + ) + + return FeeExtendedSelectorUM( + parent = state, + token = tokenConverter.convert(state.feeExtraInfo.feeCryptoCurrencyStatus), + fee = state.selectedFeeItem, + onFeeClick = { router.push(FeeSelectorRoute.ChooseSpeed) }, + ) } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/SelectedTokenItemConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/SelectedTokenItemConverter.kt index af61ddfe05..070c1895a1 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/SelectedTokenItemConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/SelectedTokenItemConverter.kt @@ -16,6 +16,7 @@ import com.tangem.utils.converter.Converter internal class SelectedTokenItemConverter( private val appCurrencyProvider: Provider, private val onTokenClick: () -> Unit, + private val isTokenSelectionAvailable: Boolean, ) : Converter { override fun convert(value: CryptoCurrencyStatus): TokenItemState { @@ -24,9 +25,14 @@ internal class SelectedTokenItemConverter( } val appCurrency = appCurrencyProvider() + val tokenItemConverter = TokenItemStateConverter( appCurrency = appCurrencyProvider(), - onItemClick = { _, _ -> onTokenClick() }, + onItemClick = if (isTokenSelectionAvailable) { + { _, _ -> onTokenClick() } + } else { + null + }, ) val state = tokenItemConverter.convert(value) @@ -38,10 +44,14 @@ internal class SelectedTokenItemConverter( } state.copy( - fiatAmountState = TokenItemState.FiatAmountState.Icon( - iconRes = R.drawable.ic_select_18_24, - tint = IconTint.Informative, - ), + fiatAmountState = if (isTokenSelectionAvailable) { + TokenItemState.FiatAmountState.Icon( + iconRes = R.drawable.ic_select_18_24, + tint = IconTint.Informative, + ) + } else { + TokenItemState.FiatAmountState.Empty + }, subtitleState = TokenItemState.SubtitleState.TextContent( value = resourceReference( R.string.common_balance, @@ -50,7 +60,6 @@ internal class SelectedTokenItemConverter( isAvailable = false, ), subtitle2State = null, - ) } else -> state diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt index a94b018eae..eef8cc085a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt @@ -22,6 +22,7 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.icons.IconTint import com.tangem.core.ui.components.token.TokenItem import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.conditional import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fee @@ -61,12 +62,15 @@ fun FeeExtendedSelectorContent(state: FeeExtendedSelectorUM, modifier: Modifier val feeFiatRateUM = state.parent.feeFiatRateUM val fee = state.fee.fee + val isChooseSpeedAvailable = state.parent.feeItems.size > 1 RegularFeeItemContent( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) - .clickable(onClick = state.onFeeClick) + .conditional(isChooseSpeedAvailable) { + clickable(onClick = state.onFeeClick) + } .background( TangemTheme.colors.background.action, RoundedCornerShape(TangemTheme.dimens.radius14), @@ -93,7 +97,8 @@ fun FeeExtendedSelectorContent(state: FeeExtendedSelectorUM, modifier: Modifier null }, ellipsizeOffset = fee.amount.currencySymbol.length, - showSelectorIcon = true, + showSelectorIcon = isChooseSpeedAvailable, + isLoading = state.fee.isLoading(), ) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt index 4f5034555d..8a6f57fb07 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt @@ -38,6 +38,7 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.ui.amountScreen.utils.getFiatReference import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.components.inputrow.InputRowEnter @@ -325,6 +326,7 @@ internal fun RegularFeeItemContent( postDot: TextReference? = null, ellipsizeOffset: Int? = null, showSelectorIcon: Boolean = false, + isLoading: Boolean = false, ) { Column(modifier = modifier) { Row( @@ -349,6 +351,7 @@ internal fun RegularFeeItemContent( preDot = preDot, postDot = postDot, ellipsizeOffset = ellipsizeOffset, + isLoading = isLoading, ) if (showSelectorIcon) { @@ -371,6 +374,7 @@ private fun FeeDescription( preDot: TextReference? = null, postDot: TextReference? = null, ellipsizeOffset: Int? = null, + isLoading: Boolean = false, ) { Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( @@ -379,8 +383,12 @@ private fun FeeDescription( style = TangemTheme.typography.subtitle2, modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE), ) - if (preDot != null) { - FeeValueContent(preDot = preDot, postDot = postDot, ellipsizeOffset = ellipsizeOffset) + when { + isLoading -> TextShimmer( + modifier = Modifier.width(122.dp), + style = TangemTheme.typography.caption1, + ) + preDot != null -> FeeValueContent(preDot = preDot, postDot = postDot, ellipsizeOffset = ellipsizeOffset) } } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt index fd5ac4c138..4afcd80323 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt @@ -22,6 +22,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -47,7 +48,8 @@ internal class FeeTokenSelectorModel @Inject constructor( initAppCurrency() params.state - .onEach { uiState.value = stateFromParent(it as FeeSelectorUM.Content) } + .filterIsInstance() + .onEach { state -> uiState.value = stateFromParent(state) } .launchIn(modelScope) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt index 6abebb0fb4..96a8476eef 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt @@ -92,6 +92,7 @@ internal class FeeSelectorAlertFactory @Inject constructor( is FeeItem.Slow -> newTransactionFee.minimum is FeeItem.Market -> newTransactionFee.normal is FeeItem.Fast -> newTransactionFee.priority + FeeItem.Loading -> newTransactionFee.normal is FeeItem.Custom -> return } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt index 81983520fd..0599751766 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt @@ -8,6 +8,7 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase 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.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.transaction.error.GetFeeError @@ -31,6 +32,7 @@ import com.tangem.utils.transformer.update import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn @@ -56,6 +58,7 @@ internal class FeeSelectorLogic @AssistedInject constructor( ) : FeeSelectorIntents { private var appCurrency: AppCurrency = AppCurrency.Default + private var selectedToken: CryptoCurrencyStatus? = null val uiState = MutableStateFlow(params.state) init { @@ -112,9 +115,19 @@ internal class FeeSelectorLogic @AssistedInject constructor( } override fun onTokenSelected(status: CryptoCurrencyStatus) { + if (status.currency !is CryptoCurrency.Token || + status.currency.id == params.feeCryptoCurrencyStatus.currency.id) { + selectedToken = null + loadFee() + return + } + uiState.update(FeeSelectorTokenSelectedTransformer(status)) + uiState.update { state -> if (state is FeeSelectorUM.Content) { state.copy( + selectedFeeItem = FeeItem.Loading, + feeItems = persistentListOf(FeeItem.Loading), feeExtraInfo = state.feeExtraInfo.copy( feeCryptoCurrencyStatus = status, ), @@ -123,6 +136,14 @@ internal class FeeSelectorLogic @AssistedInject constructor( state } } + + if (status.currency is CryptoCurrency.Token) { + selectedToken = status + } else { + selectedToken = null + } + + loadFee() } override fun onCustomFeeValueChange(index: Int, value: String) { @@ -219,7 +240,7 @@ internal class FeeSelectorLogic @AssistedInject constructor( private suspend fun callLoadFee(): Either { val extended = params.onLoadFeeExtended return if (extended != null && sendFeatureToggles.isGaslessTransactionsEnabled) { - extended().fold( + extended(selectedToken).fold( ifLeft = { error -> if (error is GetFeeError.GaslessError) { params.onLoadFee().map { LoadedFeeResult.Basic(it) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt index 75fb5135d8..0daa9f3a3c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt @@ -43,7 +43,8 @@ internal class FeeSelectorLoadedTransformer( ) val selectedFee = when (prevState) { - is FeeSelectorUM.Content -> feeItems.first { it.isSameClass(prevState.selectedFeeItem) } + is FeeSelectorUM.Content -> + feeItems.firstOrNull { it.isSameClass(prevState.selectedFeeItem) } ?: FeeItem.Loading is FeeSelectorUM.Error, FeeSelectorUM.Loading, -> feeItems.find { it is FeeItem.Suggested } ?: feeItems.first { it is FeeItem.Market } @@ -52,7 +53,7 @@ internal class FeeSelectorLoadedTransformer( val nonce = ((prevState as? FeeSelectorUM.Content)?.feeNonce as? FeeNonce.Nonce)?.nonce return FeeSelectorUM.Content( - isPrimaryButtonEnabled = true, + isPrimaryButtonEnabled = selectedFee !is FeeItem.Loading, fees = fees.transactionFee, feeItems = feeItems, selectedFeeItem = selectedFee, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt new file mode 100644 index 0000000000..bb7d3d5df7 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt @@ -0,0 +1,26 @@ +package com.tangem.features.send.v2.feeselector.model.transformers + +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.send.v2.api.entity.FeeItem +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf + +class FeeSelectorTokenSelectedTransformer( + private val selectedToken: CryptoCurrencyStatus, +) : Transformer { + + override fun transform(prevState: FeeSelectorUM): FeeSelectorUM { + return if (prevState is FeeSelectorUM.Content) { + prevState.copy( + selectedFeeItem = FeeItem.Loading, + feeItems = persistentListOf(FeeItem.Loading), + feeExtraInfo = prevState.feeExtraInfo.copy( + feeCryptoCurrencyStatus = selectedToken, + ), + ) + } else { + prevState + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt index d6e89cd52c..2230676a18 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt @@ -216,6 +216,7 @@ internal class DefaultSendComponent @AssistedInject constructor( callback = model, predefinedValues = model.predefinedValues, onLoadFee = model::loadFee, + onLoadFeeExtended = model::loadFeeExtended, onSendTransaction = { innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess) }, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt index fef494deef..afab0e41d7 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt @@ -15,6 +15,7 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.features.send.v2.api.FeeSelectorBlockComponent import com.tangem.features.send.v2.api.SendNotificationsComponent import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData @@ -86,6 +87,7 @@ internal class SendConfirmComponent( params = FeeSelectorParams.FeeSelectorBlockParams( state = model.uiState.value.feeSelectorUM, onLoadFee = params.onLoadFee, + onLoadFeeExtended = params.onLoadFeeExtended, feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, cryptoCurrencyStatus = params.cryptoCurrencyStatus, feeStateConfiguration = model.feeStateConfiguration, @@ -164,6 +166,7 @@ internal class SendConfirmComponent( val isBalanceHidingFlow: StateFlow, val predefinedValues: PredefinedValues, val onLoadFee: suspend () -> Either, + val onLoadFeeExtended: suspend (CryptoCurrencyStatus?) -> Either, val onSendTransaction: () -> Unit, ) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt index 5035e1ea7c..d6b190dd5d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import arrow.core.Either import arrow.core.getOrElse import arrow.core.left +import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.navigationButtons.NavigationUM @@ -37,8 +38,11 @@ import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.transaction.error.GetFeeError +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.gasless.GetFeeForGaslessUseCase +import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.models.GetUserWalletError import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -95,6 +99,8 @@ internal class SendModel @Inject constructor( private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val getFeeUseCase: GetFeeUseCase, + private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase, + private val getFeeForTokenUseCase: GetFeeForTokenUseCase, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val sendAmountUpdateTrigger: SendAmountUpdateTrigger, @@ -245,10 +251,10 @@ internal class SendModel @Inject constructor( showAlertError() } - suspend fun loadFee(): Either { + suspend fun prepareTransferTransaction(): Either { val predefinedValues = predefinedValues val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value - val transferTransaction = if (predefinedValues is PredefinedValues.Content.Deeplink) { + return if (predefinedValues is PredefinedValues.Content.Deeplink) { val predefinedAmount = predefinedValues.amount.parseBigDecimalOrNull() createTransferTransactionUseCase( amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus) ?: error("Invalid amount"), @@ -271,9 +277,12 @@ internal class SendModel @Inject constructor( userWalletId = userWallet.walletId, network = cryptoCurrency.network, ) - }.getOrElse { - return GetFeeError.DataError(it).left() } + } + + suspend fun loadFee(): Either { + val transferTransaction = prepareTransferTransaction() + .getOrElse { return GetFeeError.DataError(it).left() } return getFeeUseCase( transactionData = transferTransaction, @@ -282,6 +291,25 @@ internal class SendModel @Inject constructor( ) } + suspend fun loadFeeExtended(maybeToken: CryptoCurrencyStatus?): Either { + val transferTransaction = prepareTransferTransaction() + .getOrElse { return GetFeeError.DataError(it).left() } + + return if (maybeToken == null) { + getFeeForGaslessUseCase( + transactionData = transferTransaction, + userWallet = userWallet, + network = params.currency.network, + ) + } else { + getFeeForTokenUseCase( + transactionData = transferTransaction, + userWallet = userWallet, + token = maybeToken.currency, + ) + } + } + fun showAlertError() { sendConfirmAlertFactory.getGenericErrorState( onFailedTxEmailClick = ::onFailedTxEmailClick, From 98e7136b425f040bc8302aa7c0e577347abb93d5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 5 Jan 2026 20:58:57 +0100 Subject: [PATCH 067/172] Updated on 2026-08-14 --- .../tangem/datasource/api/common/config/News.kt | 5 +++++ .../com/tangem/datasource/di/ApiConfigsModule.kt | 10 +++++++++- .../datasource/api/common/config/ApiConfigTest.kt | 6 +++++- .../config/managers/ProdApiConfigsManagerTest.kt | 14 +++++++++++++- .../features/feed/ui/market/list/MarketsList.kt | 2 +- 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt index 23ff32ba22..fc57478ec5 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt @@ -4,12 +4,16 @@ import com.tangem.datasource.BuildConfig import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.utils.RequestHeader import com.tangem.utils.Provider +import com.tangem.utils.info.AppInfoProvider +import com.tangem.utils.version.AppVersionProvider /** * News [ApiConfig] [REDACTED_AUTHOR] */ internal class News( + private val appVersionProvider: AppVersionProvider, + private val appInfoProvider: AppInfoProvider, private val authProvider: AuthProvider, ) : ApiConfig() { @@ -52,6 +56,7 @@ internal class News( apiEnvironment = Provider { environment }, ).values, ) + putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) } private companion object { diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index 42d7ef09e6..2ae61bd722 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -60,7 +60,15 @@ internal object ApiConfigsModule { @Provides @IntoSet - fun provideNewsConfig(authProvider: AuthProvider): ApiConfig = News(authProvider = authProvider) + fun provideNewsConfig( + appVersionProvider: AppVersionProvider, + authProvider: AuthProvider, + appInfoProvider: AppInfoProvider, + ): ApiConfig = News( + appVersionProvider = appVersionProvider, + appInfoProvider = appInfoProvider, + authProvider = authProvider, + ) @Provides @IntoSet diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt index 1f664d53c8..715dbf1a0f 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt @@ -73,7 +73,11 @@ class ApiConfigTest { ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk()) ApiConfig.ID.MoonPay -> MoonPay() ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk()) - ApiConfig.ID.News -> News(authProvider = appAuthProvider) + ApiConfig.ID.News -> News( + appVersionProvider = mockk(), + authProvider = appAuthProvider, + appInfoProvider = mockk(), + ) ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = mockk()) ApiConfig.ID.GaslessTxService -> GaslessTxService( authProvider = appAuthProvider, diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index 22ff9ab8fb..4bb4cee123 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -119,7 +119,11 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.BlockAid -> BlockAid(configStorage = environmentConfigStorage) ApiConfig.ID.MoonPay -> MoonPay() ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider) - ApiConfig.ID.News -> News(authProvider = appAuthProvider) + ApiConfig.ID.News -> News( + appVersionProvider = appVersionProvider, + authProvider = appAuthProvider, + appInfoProvider = appInfoProvider, + ) ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = appVersionProvider) ApiConfig.ID.GaslessTxService -> GaslessTxService( authProvider = appAuthProvider, @@ -379,6 +383,14 @@ internal class ProdApiConfigsManagerTest { baseUrl = baseUrl, headers = mapOf( "api-key" to ProviderSuspend { TANGEM_API_KEY }, + "version" to ProviderSuspend { VERSION_NAME }, + "platform" to ProviderSuspend { "android" }, + "system_version" to ProviderSuspend { "Android 16" }, + "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "timezone" to ProviderSuspend { + TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() + }, + "device" to ProviderSuspend { "${Build.MANUFACTURER} ${Build.MODEL}".checkHeaderValueOrEmpty() }, ), ), ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt index f3b6f8abe0..05490b2364 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt @@ -146,7 +146,7 @@ private fun ColumnScope.Content(state: MarketsListUM, modifier: Modifier = Modif state.list !is ListUM.LoadingError && state.isInSearchMode.not() && state.selectedSortBy != SortByTypeUM.YieldSupply, ) { - val wrappedNotification = remember(this) { state.marketsNotificationUM } + val wrappedNotification = remember(state.list) { state.marketsNotificationUM } val showMore = stringResourceSafe(R.string.common_show_more) when (wrappedNotification) { From 61b848204f8d1d3f39e5d8b3717058a783f86671 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 8 Jan 2026 14:47:09 +0300 Subject: [PATCH 068/172] Updated on 2026-08-14 --- .../DefaultGaslessTransactionRepository.kt | 20 +- .../MockedGaslessTransactionRepository.kt | 10 +- .../GaslessTransactionRepository.kt | 4 +- .../CreateAndSendGaslessTransactionUseCase.kt | 377 ++++++++++++++---- gradle/tangem_dependencies.toml | 2 +- 5 files changed, 326 insertions(+), 87 deletions(-) diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt index 7a4a698b9e..40677f16b3 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -2,7 +2,6 @@ package com.tangem.data.transaction import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token -import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder @@ -27,11 +26,12 @@ class DefaultGaslessTransactionRepository( ) : GaslessTransactionRepository { private val supportedTokensState = MutableStateFlow>>(hashMapOf()) + private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder() private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter() override fun isNetworkSupported(network: Network): Boolean { - val blockchain = Blockchain.fromNetworkId(network.backendId) ?: return false + val blockchain = Blockchain.fromId(network.rawId) return SUPPORTED_BLOCKCHAINS.contains(blockchain) } @@ -44,8 +44,7 @@ class DefaultGaslessTransactionRepository( val supportedTokensData = gaslessTxServiceApi.getSupportedTokens().getOrThrow() if (supportedTokensData.isSuccess) { - val networkBlockchain = Blockchain.fromNetworkId(network.backendId) - ?: error("Cannot determine blockchain for network id: ${network.backendId}") + val networkBlockchain = Blockchain.fromId(network.rawId) val supportedTokens = supportedTokensData.result.tokens .filter { it.chainId == networkBlockchain.getChainId() @@ -79,16 +78,14 @@ class DefaultGaslessTransactionRepository( return TOKEN_RECEIVER_ADDRESS } - override suspend fun sendGaslessTransaction( + override suspend fun signGaslessTransaction( gaslessTransactionData: GaslessTransactionData, signature: String, userAddress: String, network: Network, eip7702Auth: Eip7702Authorization?, ): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) { - val blockchain = Blockchain.fromNetworkId(network.backendId) - ?: error("Cannot determine blockchain for network id: ${network.backendId}") - + val blockchain = Blockchain.fromId(network.rawId) val transactionRequest = gaslessTransactionRequestBuilder.build( gaslessTransaction = gaslessTransactionData, signature = signature, @@ -111,9 +108,14 @@ class DefaultGaslessTransactionRepository( return BASE_GAS_FOR_TRANSACTION } + override fun getChainIdForNetwork(network: Network): Int { + val networkBlockchain = Blockchain.fromId(network.rawId) + return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}") + } + private companion object { - const val TOKEN_RECEIVER_ADDRESS = "0x" + const val TOKEN_RECEIVER_ADDRESS = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000") val SUPPORTED_BLOCKCHAINS = arrayOf( diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt index 9ad092d702..7ac662e4ce 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt @@ -2,7 +2,6 @@ package com.tangem.data.transaction import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token -import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -17,7 +16,7 @@ class MockedGaslessTransactionRepository( ) : GaslessTransactionRepository { override fun isNetworkSupported(network: Network): Boolean { - val blockchain = Blockchain.fromNetworkId(network.backendId) ?: return false + val blockchain = Blockchain.fromId(network.rawId) return SUPPORTED_BLOCKCHAINS.contains(blockchain) } @@ -35,11 +34,16 @@ class MockedGaslessTransactionRepository( return setOf(usdcPolygon) } + override fun getChainIdForNetwork(network: Network): Int { + val networkBlockchain = Blockchain.fromId(network.rawId) + return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}") + } + override fun getTokenFeeReceiverAddress(): String { return TOKEN_RECEIVER_ADDRESS } - override suspend fun sendGaslessTransaction( + override suspend fun signGaslessTransaction( gaslessTransactionData: GaslessTransactionData, signature: String, userAddress: String, diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt index 84074cf002..1ebe8d1635 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt @@ -51,7 +51,7 @@ interface GaslessTransactionRepository { * @throws IllegalStateException if network is not supported or chainId cannot be determined * @throws Exception if service returns error or network request fails */ - suspend fun sendGaslessTransaction( + suspend fun signGaslessTransaction( gaslessTransactionData: GaslessTransactionData, signature: String, userAddress: String, @@ -63,4 +63,6 @@ interface GaslessTransactionRepository { * Hardcoded value as baseGas */ fun getBaseGasForTransaction(): BigInteger + + fun getChainIdForNetwork(network: Network): Int } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt index f80df07707..fde2117248 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -6,15 +6,30 @@ import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras import com.tangem.blockchain.blockchains.ethereum.EthereumUtils import com.tangem.blockchain.blockchains.ethereum.gasless.EthereumGaslessDataProvider -import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.blockchains.ethereum.models.EIP7702AuthorizationData import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionSigner +import com.tangem.blockchain.common.UnmarshalHelper +import com.tangem.blockchain.common.WalletManager import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.extensions.formatHex +import com.tangem.blockchain.extensions.removeLeadingZero import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData +import com.tangem.common.CompletionResult +import com.tangem.common.extensions.toDecompressedPublicKey +import com.tangem.common.extensions.toHexString +import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins +import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessSignedTransactionResult import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.walletmanager.WalletManagersFacade @@ -23,95 +38,219 @@ import java.math.BigInteger class CreateAndSendGaslessTransactionUseCase( private val walletManagersFacade: WalletManagersFacade, private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val gaslessTransactionRepository: GaslessTransactionRepository, + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner, ) { - @Suppress("UnusedPrivateProperty") suspend operator fun invoke( - userWalletId: UserWalletId, + userWallet: UserWallet, transactionData: TransactionData, fee: TransactionFeeExtended, - ): Either { - return either { - catch( - block = { - transactionData as? TransactionData.Uncompiled ?: error("Uncompiled transaction data required") + ): Either = either { + catch( + block = { + val uncompiledTxData = validateTransactionData(transactionData) + val context = prepareGaslessContext(userWallet, uncompiledTxData, fee) + val signedData = signGaslessTransactionByUser(userWallet, context, uncompiledTxData) + val remoteSignedData = signTransactionOnBackend(context, signedData, uncompiledTxData) + broadcastSignedTransaction(remoteSignedData) + }, + catch = { + raise(SendTransactionError.DataError(it.message)) + }, + ) + } - val tokenForFeeStatus = getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( - userWalletId, - fee.feeTokenId, - ).getOrNull() ?: error("Token for fee not found") + /** + * Validates and casts transaction data to Uncompiled type. + */ + private fun validateTransactionData(transactionData: TransactionData): TransactionData.Uncompiled { + return transactionData as? TransactionData.Uncompiled + ?: error("Uncompiled transaction data required") + } - val blockchain = Blockchain.fromId(tokenForFeeStatus.currency.network.rawId) + /** + * Prepares all necessary context for gasless transaction. + * Includes: wallet manager, gasless provider, token status, nonce, transaction data. + */ + private suspend fun prepareGaslessContext( + userWallet: UserWallet, + transactionData: TransactionData.Uncompiled, + fee: TransactionFeeExtended, + ): GaslessContext { + val tokenForFeeStatus = getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + fee.feeTokenId, + ).getOrNull() ?: error("Token for fee not found") - val gaslessTransactionData = createGaslessTransactionData( - userWalletId = userWalletId, - transactionData = transactionData, - txFee = fee, - tokenFeeStatus = tokenForFeeStatus, - ) + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWallet.walletId, + tokenForFeeStatus.currency.network, + ) ?: error("WalletManager not found for network ${tokenForFeeStatus.currency.network.id}") - val chainId = - blockchain.getChainId() ?: error("ChainId not found for blockchain ${blockchain.name}") + val gaslessDataProvider = walletManager as? EthereumGaslessDataProvider ?: error( + "WalletManager for network ${tokenForFeeStatus.currency.network.id} " + + "does not support gasless transactions", + ) - val eip712Data = Eip712TypedDataBuilder.build( - gaslessTransaction = gaslessTransactionData, - chainId = chainId, - verifyingContract = transactionData.sourceAddress, - ) + val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress) - val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data) - }, - catch = { - raise(SendTransactionError.DataError(it.message)) - }, - ) + val gaslessTransactionData = createGaslessTransactionData( + transactionData = transactionData, + txFee = fee, + tokenFeeStatus = tokenForFeeStatus, + nonce = gaslessContractNonce, + ) + + val chainId = gaslessTransactionRepository.getChainIdForNetwork(tokenForFeeStatus.currency.network) + + return GaslessContext( + walletManager = walletManager, + gaslessDataProvider = gaslessDataProvider, + tokenForFeeStatus = tokenForFeeStatus, + gaslessTransactionData = gaslessTransactionData, + chainId = chainId, + ) + } + + /** + * Gets contract nonce with fallback to zero on failure. + */ + private suspend fun getContractNonce( + gaslessDataProvider: EthereumGaslessDataProvider, + userAddress: String, + ): BigInteger { + return when (val nonceResult = gaslessDataProvider.getGaslessContractNonce(userAddress)) { + is Result.Failure -> BigInteger.ZERO + is Result.Success -> nonceResult.data } } - private suspend fun createGaslessTransactionData( - userWalletId: UserWalletId, + /** + * Signs gasless transaction and EIP-7702 authorization. + * Returns prepared signatures and authorization data. + */ + private suspend fun signGaslessTransactionByUser( + userWallet: UserWallet, + context: GaslessContext, + transactionData: TransactionData.Uncompiled, + ): SignedGaslessData { + val eip712Data = Eip712TypedDataBuilder.build( + gaslessTransaction = context.gaslessTransactionData, + chainId = context.chainId, + verifyingContract = transactionData.sourceAddress, + ) + + val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data) + val eip7702Data = getEIP7702DataForGasless(context.gaslessDataProvider) + + val signedHashes = signHashes( + userWallet = userWallet, + walletManager = context.walletManager, + hashesToOperate = HashesToOperate( + eip712Hash = eip712HashToSign, + eip7702Hash = eip7702Data.data, + ), + ) + + val decompressedPublicKey = context.walletManager + .wallet.publicKey.blockchainKey + .toDecompressedPublicKey() + + val preparedEip712Hash = UnmarshalHelper.unmarshalSignatureExtended( + signature = signedHashes.eip712Hash, + hash = eip712HashToSign, + publicKey = decompressedPublicKey, + ).asRSVLegacyEVM().toHexString().formatHex().lowercase() + + val extendedEip7702Data = UnmarshalHelper.unmarshalSignatureExtended( + signature = signedHashes.eip7702Hash, + hash = eip7702Data.data, + publicKey = decompressedPublicKey, + ) + + val eip7702Auth = Eip7702Authorization( + chainId = context.chainId, + address = transactionData.sourceAddress, + nonce = eip7702Data.nonce, + yParity = extendedEip7702Data.recId, + r = extendedEip7702Data.r.toFormattedHex(), + s = extendedEip7702Data.s.toFormattedHex(), + ) + + return SignedGaslessData( + eip712Signature = preparedEip712Hash, + eip7702Auth = eip7702Auth, + ) + } + + /** + * Sends gasless transaction to the service. + */ + private suspend fun signTransactionOnBackend( + context: GaslessContext, + signedData: SignedGaslessData, + transactionData: TransactionData.Uncompiled, + ): GaslessSignedTransactionResult { + return gaslessTransactionRepository.signGaslessTransaction( + network = context.tokenForFeeStatus.currency.network, + gaslessTransactionData = context.gaslessTransactionData, + signature = signedData.eip712Signature, + userAddress = transactionData.sourceAddress, + eip7702Auth = signedData.eip7702Auth, + ) + } + + @Suppress("UnusedParameter") + private fun broadcastSignedTransaction(remoteSignedData: GaslessSignedTransactionResult) { + TODO("Will be implemented later") + } + + private suspend fun signHashes( + userWallet: UserWallet, + walletManager: WalletManager, + hashesToOperate: HashesToOperate, + ): HashesToOperate { + val signer = getSigner(userWallet) + val hashesToSign = listOf(hashesToOperate.eip712Hash, hashesToOperate.eip7702Hash) + + return when (val signerResult = signer.sign(hashesToSign, walletManager.wallet.publicKey)) { + is CompletionResult.Failure -> error("Signing failed: ${signerResult.error.message ?: "sdk error"}") + is CompletionResult.Success -> { + val signatures = signerResult.data + require(signatures.size == 2) { "Expected 2 signatures, got ${signatures.size}" } + HashesToOperate( + eip712Hash = signatures[0], + eip7702Hash = signatures[1], + ) + } + } + } + + private fun getSigner(userWallet: UserWallet): TransactionSigner { + return when (userWallet) { + is UserWallet.Cold -> { + val card = userWallet.scanResponse.card + val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins + + cardSdkConfigRepository.getCommonSigner( + cardId = card.cardId.takeIf { isCardNotBackedUp }, + twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), + ) + } + is UserWallet.Hot -> getHotWalletSigner(userWallet) + } + } + + private fun createGaslessTransactionData( transactionData: TransactionData.Uncompiled, txFee: TransactionFeeExtended, tokenFeeStatus: CryptoCurrencyStatus, + nonce: BigInteger, ): GaslessTransactionData { - val bigIntegerAmount = - transactionData.amount.value?.movePointRight(transactionData.amount.decimals)?.toBigInteger() - ?: error("Amount value is null") - val txData = (transactionData.extras as? EthereumTransactionExtras)?.callData ?: error("Call data required") - - val transaction = GaslessTransactionData.Transaction( - to = getDestinationAddress(transactionData), - value = bigIntegerAmount, - data = txData.data, - ) - - val tokenForFee = tokenFeeStatus.currency as? CryptoCurrency.Token - ?: error("only CryptoCurrency.Token supported for fee") - - val txFeeInTokenCurrency = txFee.transactionFee.normal as? Fee.Ethereum.TokenCurrency ?: error( - "only Fee.Ethereum.TokenCurrency supported for gasless fee", - ) - val fee = GaslessTransactionData.Fee( - feeToken = tokenForFee.contractAddress, - maxTokenFee = txFeeInTokenCurrency.gasLimit, - coinPriceInToken = txFeeInTokenCurrency.coinPriceInToken, - feeTransferGasLimit = txFeeInTokenCurrency.feeTransferGasLimit, - baseGas = txFeeInTokenCurrency.baseGas, - ) - - val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, tokenForFee.network) - ?: error("WalletManager not found for network ${tokenForFee.network.id}") - val gaslessDataProvider = walletManager as? EthereumGaslessDataProvider ?: error( - "WalletManager for network ${tokenForFee.network.id} does not support gasless transactions", - ) - - val nonceResult = gaslessDataProvider.getGaslessContractNonce( - userAddress = transactionData.sourceAddress, - ) - val nonce = when (nonceResult) { - is com.tangem.blockchain.extensions.Result.Failure -> BigInteger.ZERO - is com.tangem.blockchain.extensions.Result.Success -> nonceResult.data - } + val transaction = buildTransaction(transactionData) + val fee = buildFee(txFee, tokenFeeStatus) return GaslessTransactionData( transaction = transaction, @@ -120,6 +259,50 @@ class CreateAndSendGaslessTransactionUseCase( ) } + private fun buildTransaction(transactionData: TransactionData.Uncompiled): GaslessTransactionData.Transaction { + val amount = transactionData.amount.value + ?.movePointRight(transactionData.amount.decimals) + ?.toBigInteger() + ?: error("Transaction amount value is null") + + val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData + ?: error("Ethereum call data is required") + + return GaslessTransactionData.Transaction( + to = getDestinationAddress(transactionData), + value = amount, + data = callData.data, + ) + } + + private fun buildFee( + txFee: TransactionFeeExtended, + tokenFeeStatus: CryptoCurrencyStatus, + ): GaslessTransactionData.Fee { + val tokenForFee = tokenFeeStatus.currency as? CryptoCurrency.Token + ?: error("Fee currency must be a token") + + val feeInTokenCurrency = txFee.transactionFee.normal as? Fee.Ethereum.TokenCurrency + ?: error("Fee must be in token currency") + + return GaslessTransactionData.Fee( + feeToken = tokenForFee.contractAddress, + maxTokenFee = feeInTokenCurrency.gasLimit, + coinPriceInToken = feeInTokenCurrency.coinPriceInToken, + feeTransferGasLimit = feeInTokenCurrency.feeTransferGasLimit, + baseGas = feeInTokenCurrency.baseGas, + ) + } + + private suspend fun getEIP7702DataForGasless( + gaslessDataProvider: EthereumGaslessDataProvider, + ): EIP7702AuthorizationData { + return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData()) { + is Result.Failure -> throw dataResult.error + is Result.Success -> dataResult.data + } + } + private fun getDestinationAddress(txData: TransactionData.Uncompiled): String { val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData return if (ethereumCallData is EthereumYieldSupplySendCallData) { @@ -128,4 +311,52 @@ class CreateAndSendGaslessTransactionUseCase( txData.destinationAddress } } + + /** + * Context containing all prepared data for gasless transaction. + */ + private data class GaslessContext( + val walletManager: WalletManager, + val gaslessDataProvider: EthereumGaslessDataProvider, + val tokenForFeeStatus: CryptoCurrencyStatus, + val gaslessTransactionData: GaslessTransactionData, + val chainId: Int, + ) + + /** + * Signed data ready for submission. + */ + private data class SignedGaslessData( + val eip712Signature: String, + val eip7702Auth: Eip7702Authorization, + ) + + private data class HashesToOperate( + val eip712Hash: ByteArray, + val eip7702Hash: ByteArray, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HashesToOperate + + if (!eip712Hash.contentEquals(other.eip712Hash)) return false + if (!eip7702Hash.contentEquals(other.eip7702Hash)) return false + + return true + } + + override fun hashCode(): Int { + var result = eip712Hash.contentHashCode() + result = 31 * result + eip7702Hash.contentHashCode() + return result + } + } + + private companion object { + fun BigInteger.toFormattedHex(): String { + return toByteArray().removeLeadingZero().toHexString().formatHex() + } + } } \ No newline at end of file diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 342d7d6a54..f9c9c8e39f 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-1346" +tangemBlockchainSdk = "develop-1352" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 930e6ffc7212a152dabed11db3711f9af1cb59a6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 8 Jan 2026 17:55:28 +0300 Subject: [PATCH 069/172] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- .../tap/network/auth/DefaultAuthProvider.kt | 9 ++------- .../api/common/config/GaslessTxService.kt | 2 +- .../config/environment/EnvironmentConfig.kt | 2 ++ .../converter/EnvironmentConfigConverter.kt | 2 ++ .../environment/models/EnvironmentConfigModel.kt | 2 ++ .../config/managers/ProdApiConfigsManagerTest.kt | 2 +- .../data/transaction/di/TransactionDataModule.kt | 16 +++++++++------- 8 files changed, 20 insertions(+), 17 deletions(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 78be297b0f..5ba0959d72 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 78be297b0f09bc2be1e9b0fbab9384f17bf94c37 +Subproject commit 5ba0959d72f41e49a22f792e9a3d53e5e24e7713 diff --git a/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt index abc3d9f69b..79a50c1403 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt @@ -63,13 +63,8 @@ internal class DefaultAuthProvider( return ProviderSuspend { when (apiEnvironment.invoke()) { ApiEnvironment.DEV, - ApiEnvironment.DEV_2, - ApiEnvironment.DEV_3, - -> environmentConfigStorage.getConfigSync().tangemApiKeyDev // TODO add gaslessTxApiKeyDev - ApiEnvironment.STAGE, - ApiEnvironment.STAGE_2, - -> environmentConfigStorage.getConfigSync().tangemApiKeyStage // TODO add gaslessTxApiKeyStage - ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().tangemApiKey // TODO add gaslessTxApiKey + -> environmentConfigStorage.getConfigSync().gaslessTxApiKeyDev + ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().gaslessTxApiKey else -> error("No gasless tx api config provided for ${apiEnvironment.invoke()}") } ?: error("No gasless tx api config provided") } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt index ce201f58bb..35935f0c9d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt @@ -60,7 +60,7 @@ internal class GaslessTxService( } private companion object { - private const val PROD_BASE_URL = "https://api.tangem.org/" + private const val PROD_BASE_URL = "https://gasless.tangem.org/" private const val DEV_BASE_URL = "[REDACTED_ENV_URL]" } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt index cce8979a7c..c77d5ede0c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt @@ -25,4 +25,6 @@ data class EnvironmentConfig( val yieldModuleApiKey: String? = null, val yieldModuleApiKeyDev: String? = null, val bffStaticToken: String? = null, + val gaslessTxApiKeyDev: String? = null, + val gaslessTxApiKey: String? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt index d8ec470eff..8e19d3682e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt @@ -33,6 +33,8 @@ internal object EnvironmentConfigConverter : Converter ApiEnvironment.PROD to "https://tangem.com/" + -> ApiEnvironment.PROD to "https://gasless.tangem.org/" else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") } return TestModel( diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt index d21fc04dce..731006b04d 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt @@ -2,10 +2,11 @@ package com.tangem.data.transaction.di import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.transaction.DefaultFeeRepository +import com.tangem.data.transaction.DefaultGaslessTransactionRepository import com.tangem.data.transaction.DefaultTransactionRepository import com.tangem.data.transaction.DefaultWalletAddressServiceRepository -import com.tangem.data.transaction.MockedGaslessTransactionRepository import com.tangem.data.transaction.error.DefaultFeeErrorResolver +import com.tangem.datasource.api.gasless.GaslessTxServiceApi import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.demo.models.DemoConfig @@ -73,12 +74,13 @@ internal object TransactionDataModule { @Singleton fun provideGaslessTransactionRepository( responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, + gaslessTxServiceApi: GaslessTxServiceApi, + coroutineDispatcherProvider: CoroutineDispatcherProvider, ): GaslessTransactionRepository { - return MockedGaslessTransactionRepository(responseCryptoCurrenciesFactory) - // return DefaultGaslessTransactionRepository( - // gaslessTxServiceApi, - // coroutineDispatcherProvider, - // responseCryptoCurrenciesFactory, - // ) + return DefaultGaslessTransactionRepository( + gaslessTxServiceApi, + coroutineDispatcherProvider, + responseCryptoCurrenciesFactory, + ) } } \ No newline at end of file From b75a9370ba5f6721ad66faff0c682f87aff88288 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 9 Jan 2026 12:26:31 +0300 Subject: [PATCH 070/172] Updated on 2026-08-14 --- .../tap/di/domain/TransactionDomainModule.kt | 24 +++++++++++--- .../send/v2/api/entity/FeeSelectorUM.kt | 2 ++ .../DefaultFeeSelectorBlockComponent.kt | 4 ++- .../v2/feeselector/model/FeeSelectorLogic.kt | 33 ++----------------- .../v2/feeselector/model/FeeSelectorModel.kt | 13 +++----- .../FeeSelectorLoadedTransformer.kt | 9 +++-- .../feeselector/ui/FeeSelectorBlockContent.kt | 5 ++- .../v2/send/confirm/model/SendConfirmModel.kt | 22 ++++++++++--- .../ethereum/EthereumCustomFeeConverter.kt | 13 +++++--- 9 files changed, 70 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 5eb7159463..9f0811eff8 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -6,6 +6,7 @@ import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -14,10 +15,7 @@ import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.WalletAddressServiceRepository import com.tangem.domain.transaction.usecase.* -import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCase -import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase -import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase -import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +import com.tangem.domain.transaction.usecase.gasless.* import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -330,4 +328,22 @@ internal object TransactionDomainModule { ): IsGaslessFeeSupportedForNetwork { return IsGaslessFeeSupportedForNetwork(gaslessTransactionRepository) } + + @Provides + @Singleton + fun provideCreateAndSendGaslessTransactionUseCase( + walletManagersFacade: WalletManagersFacade, + gaslessTransactionRepository: GaslessTransactionRepository, + getSingCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + cardSdkConfigRepository: CardSdkConfigRepository, + tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory, + ): CreateAndSendGaslessTransactionUseCase { + return CreateAndSendGaslessTransactionUseCase( + walletManagersFacade = walletManagersFacade, + getSingleCryptoCurrencyStatusUseCase = getSingCryptoCurrencyStatusUseCase, + gaslessTransactionRepository = gaslessTransactionRepository, + cardSdkConfigRepository = cardSdkConfigRepository, + getHotWalletSigner = tangemHotWalletSignerFactory::create, + ) + } } \ No newline at end of file diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt index ef578be698..3706fa2f92 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt @@ -11,6 +11,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.features.send.v2.api.R import com.tangem.features.send.v2.api.entity.FeeItem.* import kotlinx.collections.immutable.ImmutableList @@ -66,6 +67,7 @@ data class FeeExtraInfo( val isTronToken: Boolean, val feeCryptoCurrencyStatus: CryptoCurrencyStatus, val availableFeeCurrencies: ImmutableList? = null, + val transactionFeeExtended: TransactionFeeExtended? = null, ) sealed class FeeNonce { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt index 80a1151aa7..5da1137ef5 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt @@ -81,12 +81,14 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor( val isScreenSource = params.feeDisplaySource == FeeSelectorParams.FeeDisplaySource.Screen val isNotSingleFee = (state as? FeeSelectorUM.Content)?.feeItems?.isSingleItem() == false + val isGaslessAvailable = state is FeeSelectorUM.Content && + (state as FeeSelectorUM.Content).feeExtraInfo.transactionFeeExtended != null FeeSelectorBlockContent( state = state, onReadMoreClick = model::onReadMoreClicked, isGaslessFeatureEnabled = sendFeatureToggles.isGaslessTransactionsEnabled, modifier = modifier - .conditional(isScreenSource && isNotSingleFee) { + .conditional(isScreenSource && (isNotSingleFee || isGaslessAvailable)) { Modifier.clickable { model.showFeeSelector() } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt index 0599751766..42ffaa7515 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt @@ -32,12 +32,10 @@ import com.tangem.utils.transformer.update import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @Suppress("LongParameterList") @@ -58,7 +56,6 @@ internal class FeeSelectorLogic @AssistedInject constructor( ) : FeeSelectorIntents { private var appCurrency: AppCurrency = AppCurrency.Default - private var selectedToken: CryptoCurrencyStatus? = null val uiState = MutableStateFlow(params.state) init { @@ -115,34 +112,7 @@ internal class FeeSelectorLogic @AssistedInject constructor( } override fun onTokenSelected(status: CryptoCurrencyStatus) { - if (status.currency !is CryptoCurrency.Token || - status.currency.id == params.feeCryptoCurrencyStatus.currency.id) { - selectedToken = null - loadFee() - return - } uiState.update(FeeSelectorTokenSelectedTransformer(status)) - - uiState.update { state -> - if (state is FeeSelectorUM.Content) { - state.copy( - selectedFeeItem = FeeItem.Loading, - feeItems = persistentListOf(FeeItem.Loading), - feeExtraInfo = state.feeExtraInfo.copy( - feeCryptoCurrencyStatus = status, - ), - ) - } else { - state - } - } - - if (status.currency is CryptoCurrency.Token) { - selectedToken = status - } else { - selectedToken = null - } - loadFee() } @@ -240,6 +210,9 @@ internal class FeeSelectorLogic @AssistedInject constructor( private suspend fun callLoadFee(): Either { val extended = params.onLoadFeeExtended return if (extended != null && sendFeatureToggles.isGaslessTransactionsEnabled) { + val selectedToken = (uiState.value as? FeeSelectorUM.Content)?.feeExtraInfo?.feeCryptoCurrencyStatus + ?.takeIf { it.currency is CryptoCurrency.Token } + extended(selectedToken).fold( ifLeft = { error -> if (error is GetFeeError.GaslessError) { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt index c80fac2068..96ee2a20ad 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt @@ -89,14 +89,11 @@ internal class FeeSelectorModel @Inject constructor( } fun getInitialRoute(): FeeSelectorRoute { - if (sendFeatureToggles.isGaslessTransactionsEnabled.not()) { - return FeeSelectorRoute.ChooseSpeed - } - - return if (isGaslessFeeSupportedForNetwork(params.feeCryptoCurrencyStatus.currency.network)) { - FeeSelectorRoute.NetworkFee - } else { - FeeSelectorRoute.ChooseSpeed + return when { + sendFeatureToggles.isGaslessTransactionsEnabled && params.onLoadFeeExtended != null && + isGaslessFeeSupportedForNetwork(params.feeCryptoCurrencyStatus.currency.network) -> + FeeSelectorRoute.NetworkFee + else -> FeeSelectorRoute.ChooseSpeed } } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt index 0daa9f3a3c..21fe42a28e 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt @@ -38,13 +38,17 @@ internal class FeeSelectorLoadedTransformer( } else { null } + val feeItems: ImmutableList = feeItemsConverter.convert( FeeItemConverter.Input(fees.transactionFee, prevCustomFee), ) val selectedFee = when (prevState) { - is FeeSelectorUM.Content -> - feeItems.firstOrNull { it.isSameClass(prevState.selectedFeeItem) } ?: FeeItem.Loading + is FeeSelectorUM.Content -> if (prevState.selectedFeeItem is FeeItem.Loading) { + feeItems.firstOrNull { it is FeeItem.Market } + } else { + feeItems.firstOrNull { it.isSameClass(prevState.selectedFeeItem) } + } ?: FeeItem.Loading is FeeSelectorUM.Error, FeeSelectorUM.Loading, -> feeItems.find { it is FeeItem.Suggested } ?: feeItems.first { it is FeeItem.Market } @@ -64,6 +68,7 @@ internal class FeeSelectorLoadedTransformer( isTron(cryptoCurrencyStatus.currency.network.rawId), feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, availableFeeCurrencies = getAvailableFeeCurrencies(), + transactionFeeExtended = (fees as? LoadedFeeResult.Extended)?.fee, ), feeFiatRateUM = feeCryptoCurrencyStatus.value.fiatRate?.let { rate -> FeeFiatRateUM( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt index 19a1c86d96..2024fbf748 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt @@ -213,7 +213,10 @@ private fun FeeContent(state: FeeSelectorUM.Content, isGaslessFeatureEnabled: Bo .padding(start = TangemTheme.dimens.spacing4) .testTag(FeeSelectorBlockTestTags.FEE_AMOUNT), ) - if (!state.feeItems.isSingleItem()) { + + val isGaslessAvailable = isGaslessFeatureEnabled && state.feeExtraInfo.transactionFeeExtended != null + + if (!state.feeItems.isSingleItem() || isGaslessAvailable) { Icon( modifier = Modifier .size(width = 18.dp, height = 24.dp) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt index 6fcd04be67..15e6d54b17 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -36,6 +36,7 @@ import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.features.send.v2.api.SendNotificationsComponent @@ -111,6 +112,7 @@ internal class SendConfirmModel @Inject constructor( private val accountsFeatureToggles: AccountsFeatureToggles, private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val currenciesRepository: CurrenciesRepository, + private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, sendBalanceUpdaterFactory: SendBalanceUpdater.Factory, ) : Model(), SendConfirmClickIntents, FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback { @@ -388,11 +390,21 @@ internal class SendConfirmModel @Inject constructor( } private suspend fun sendTransaction(txData: TransactionData.Uncompiled) { - val result = sendTransactionUseCase( - txData = txData, - userWallet = userWallet, - network = cryptoCurrency.network, - ) + val feeExtended = feeUMV2?.feeExtraInfo?.transactionFeeExtended + + val result = if (feeExtended != null) { + createAndSendGaslessTransactionUseCase( + userWallet = userWallet, + transactionData = txData, + fee = feeExtended, + ) + } else { + sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = cryptoCurrency.network, + ) + } _uiState.update(SendConfirmSendingStateTransformer(isSending = false)) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt index be8621ba5a..7a7d53e4ec 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt @@ -14,6 +14,7 @@ import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkExceedBalance import com.tangem.features.send.v2.impl.R import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList internal class EthereumCustomFeeConverter( @@ -38,12 +39,16 @@ internal class EthereumCustomFeeConverter( ) override fun convert(value: Fee.Ethereum): ImmutableList { + if (value is Fee.Ethereum.TokenCurrency) { + return persistentListOf() + } + return buildList { add(convertFeeValue(value)) addAll( when (value) { - is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") + is Fee.Ethereum.TokenCurrency -> error("unreachable") is Fee.Ethereum.EIP1559 -> eipFeeConverter.convert(value) is Fee.Ethereum.Legacy -> legacyFeeConverter.convert(value) }, @@ -56,7 +61,7 @@ internal class EthereumCustomFeeConverter( override fun convertBack(normalFee: Fee.Ethereum, value: ImmutableList): Fee.Ethereum { return when (normalFee) { - is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") + is Fee.Ethereum.TokenCurrency -> error("unreachable") is Fee.Ethereum.EIP1559 -> eipFeeConverter.convertBack(normalFee = normalFee, value = value) is Fee.Ethereum.Legacy -> legacyFeeConverter.convertBack(normalFee = normalFee, value = value) } @@ -64,7 +69,7 @@ internal class EthereumCustomFeeConverter( override fun getGasLimitIndex(feeValue: Fee.Ethereum): Int { return when (feeValue) { - is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") + is Fee.Ethereum.TokenCurrency -> error("unreachable") is Fee.Ethereum.EIP1559 -> eipFeeConverter.getGasLimitIndex(feeValue) is Fee.Ethereum.Legacy -> legacyFeeConverter.getGasLimitIndex(feeValue) } @@ -77,7 +82,7 @@ internal class EthereumCustomFeeConverter( value: String, ): ImmutableList { return when (feeValue) { - is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") + is Fee.Ethereum.TokenCurrency -> error("unreachable") is Fee.Ethereum.EIP1559 -> eipFeeConverter.onValueChange( feeValue = feeValue, customValues = customValues, From 3a9bdb16ad68133d56cf8bbac1442cbdcc4deacc Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 9 Jan 2026 12:57:20 +0300 Subject: [PATCH 071/172] Updated on 2026-08-14 --- .../CreateAndSendGaslessTransactionUseCase.kt | 30 ++++++++++++------- gradle/tangem_dependencies.toml | 2 +- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt index fde2117248..79a3acff33 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -7,10 +7,7 @@ import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras import com.tangem.blockchain.blockchains.ethereum.EthereumUtils import com.tangem.blockchain.blockchains.ethereum.gasless.EthereumGaslessDataProvider import com.tangem.blockchain.blockchains.ethereum.models.EIP7702AuthorizationData -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.TransactionSigner -import com.tangem.blockchain.common.UnmarshalHelper -import com.tangem.blockchain.common.WalletManager +import com.tangem.blockchain.common.* import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.extensions.formatHex @@ -47,14 +44,14 @@ class CreateAndSendGaslessTransactionUseCase( userWallet: UserWallet, transactionData: TransactionData, fee: TransactionFeeExtended, - ): Either = either { + ): Either = either { catch( block = { val uncompiledTxData = validateTransactionData(transactionData) val context = prepareGaslessContext(userWallet, uncompiledTxData, fee) val signedData = signGaslessTransactionByUser(userWallet, context, uncompiledTxData) val remoteSignedData = signTransactionOnBackend(context, signedData, uncompiledTxData) - broadcastSignedTransaction(remoteSignedData) + broadcastSignedTransaction(context, remoteSignedData) }, catch = { raise(SendTransactionError.DataError(it.message)) @@ -202,9 +199,18 @@ class CreateAndSendGaslessTransactionUseCase( ) } - @Suppress("UnusedParameter") - private fun broadcastSignedTransaction(remoteSignedData: GaslessSignedTransactionResult) { - TODO("Will be implemented later") + private suspend fun broadcastSignedTransaction( + context: GaslessContext, + remoteSignedData: GaslessSignedTransactionResult, + ): String { + val transactionSender = context.walletManager as? TransactionSender ?: error( + "WalletManager for network ${context.tokenForFeeStatus.currency.network.id} " + + "does not support transaction sending", + ) + return when (val result = transactionSender.broadcastTransaction(remoteSignedData.signedTransaction)) { + is Result.Failure -> throw result.error + is Result.Success -> result.data.hash + } } private suspend fun signHashes( @@ -268,9 +274,13 @@ class CreateAndSendGaslessTransactionUseCase( val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData ?: error("Ethereum call data is required") + // Native amount is always zero in gasless transactions for now + // we don't support gasless transfers of native currency + val nativeAmount = BigInteger.ZERO + return GaslessTransactionData.Transaction( to = getDestinationAddress(transactionData), - value = amount, + value = nativeAmount, data = callData.data, ) } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index f9c9c8e39f..5df28a8a43 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-1352" +tangemBlockchainSdk = "develop-1353" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From aa621dd48fc2d34dd8a59fd07534cd7fe47439ce Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 9 Jan 2026 16:44:30 +0300 Subject: [PATCH 072/172] Updated on 2026-08-14 --- .../tap/di/domain/TokensDomainModule.kt | 3 +- .../IsAmountSubtractAvailableUseCase.kt | 27 +++++++------ .../send/v2/api/SendNotificationsComponent.kt | 2 +- .../v2/send/confirm/SendConfirmComponent.kt | 2 +- .../v2/send/confirm/model/SendConfirmModel.kt | 38 ++++++++++++++++--- .../confirm/NFTSendConfirmComponent.kt | 2 +- .../confirm/model/NFTSendConfirmModel.kt | 1 + .../notifications/model/NotificationsModel.kt | 36 +++++++++++++----- .../confirm/SendWithSwapConfirmComponent.kt | 2 +- .../confirm/model/SendWithSwapConfirmModel.kt | 1 + 10 files changed, 78 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt index 7528f0f18b..7e1c0e98de 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt @@ -294,9 +294,8 @@ internal object TokensDomainModule { @Singleton fun provideIsAmountSubtractAvailableUseCase( currenciesRepository: CurrenciesRepository, - dispatchers: CoroutineDispatcherProvider, ): IsAmountSubtractAvailableUseCase { - return IsAmountSubtractAvailableUseCase(currenciesRepository, dispatchers) + return IsAmountSubtractAvailableUseCase(currenciesRepository) } @Provides diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt index 2338f7f243..bdbc0507df 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt @@ -2,11 +2,9 @@ package com.tangem.domain.tokens import arrow.core.Either import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.withContext /** * Use case for checking if currency amount can be subtracted. @@ -14,17 +12,18 @@ import kotlinx.coroutines.withContext */ class IsAmountSubtractAvailableUseCase( private val currenciesRepository: CurrenciesRepository, - private val dispatchers: CoroutineDispatcherProvider, ) { - suspend operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency): Either = - Either.catch { - withContext(dispatchers.io) { - when (val feeCurrency = currenciesRepository.getFeePaidCurrency(userWalletId, currency.network)) { - is FeePaidCurrency.Coin -> currency is CryptoCurrency.Coin - is FeePaidCurrency.SameCurrency -> true - is FeePaidCurrency.Token -> currency.id == feeCurrency.tokenId - is FeePaidCurrency.FeeResource -> false - } - } + suspend operator fun invoke( + userWalletId: UserWalletId, + currency: CryptoCurrency, + isGaslessEthTx: Boolean = false, + ): Either = Either.catch { + if (isGaslessEthTx) return@catch true + when (val feeCurrency = currenciesRepository.getFeePaidCurrency(userWalletId, currency.network)) { + is FeePaidCurrency.Coin -> currency is CryptoCurrency.Coin + is FeePaidCurrency.SameCurrency -> true + is FeePaidCurrency.Token -> currency.id == feeCurrency.tokenId + is FeePaidCurrency.FeeResource -> false } + } } \ No newline at end of file diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendNotificationsComponent.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendNotificationsComponent.kt index 90823b9c00..5ec0bf75e3 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendNotificationsComponent.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendNotificationsComponent.kt @@ -28,7 +28,6 @@ interface SendNotificationsComponent { val analyticsCategoryName: String, val userWalletId: UserWalletId, val cryptoCurrencyStatus: CryptoCurrencyStatus, - val feeCryptoCurrencyStatus: CryptoCurrencyStatus, val appCurrency: AppCurrency, val notificationData: NotificationData, val callback: ModelCallback, @@ -41,6 +40,7 @@ interface SendNotificationsComponent { val isIgnoreReduce: Boolean, val fee: Fee?, val feeError: GetFeeError?, + val feeCryptoCurrencyStatus: CryptoCurrencyStatus, ) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt index afab0e41d7..423fc07b00 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt @@ -105,7 +105,6 @@ internal class SendConfirmComponent( analyticsCategoryName = params.analyticsCategoryName, userWalletId = params.userWallet.walletId, cryptoCurrencyStatus = params.cryptoCurrencyStatus, - feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, appCurrency = params.appCurrency, callback = model, notificationData = NotificationData( @@ -116,6 +115,7 @@ internal class SendConfirmComponent( isIgnoreReduce = model.confirmData.isIgnoreReduce, fee = model.confirmData.fee, feeError = model.confirmData.feeError, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, ), ), ) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt index 15e6d54b17..f99abc7f71 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Stable import arrow.core.getOrElse import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer import com.tangem.common.ui.amountScreen.models.AmountState @@ -29,6 +30,7 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.settings.NeverShowTapHelpUseCase import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase @@ -155,10 +157,7 @@ internal class SendConfirmModel @Inject constructor( internal var feeStateConfiguration: FeeStateConfiguration = FeeStateConfiguration.None init { - modelScope.launch { - isAmountSubtractAvailable = - isAmountSubtractAvailableUseCase(userWallet.walletId, cryptoCurrency).getOrElse { false } - } + updateAmountSubtractAvailability() configConfirmNavigation() subscribeOnNotificationsUpdateTrigger() subscribeOnCheckFeeResultUpdates() @@ -357,7 +356,7 @@ internal class SendConfirmModel @Inject constructor( val receivingAmount = checkAndCalculateSubtractedAmount( isAmountSubtractAvailable = isAmountSubtractAvailable, - cryptoCurrencyStatus = cryptoCurrencyStatus, + cryptoCurrencyStatus = getCurrencyStatusForFeePayment(), amountValue = amountValue, feeValue = feeValue, reduceAmountBy = confirmData.reduceAmountBy.orZero(), @@ -392,7 +391,9 @@ internal class SendConfirmModel @Inject constructor( private suspend fun sendTransaction(txData: TransactionData.Uncompiled) { val feeExtended = feeUMV2?.feeExtraInfo?.transactionFeeExtended - val result = if (feeExtended != null) { + val isFeeInTokenCurrency = feeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency + + val result = if (isFeeInTokenCurrency) { createAndSendGaslessTransactionUseCase( userWallet = userWallet, transactionData = txData, @@ -439,6 +440,16 @@ internal class SendConfirmModel @Inject constructor( ) } + private fun getCurrencyStatusForFeePayment(): CryptoCurrencyStatus { + val feeExtended = feeUMV2?.feeExtraInfo?.transactionFeeExtended + val isFeeInTokenCurrency = feeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency + return if (isFeeInTokenCurrency) { + feeUMV2?.feeExtraInfo?.feeCryptoCurrencyStatus ?: cryptoCurrencyStatus + } else { + params.feeCryptoCurrencyStatus + } + } + private fun addTokenToWalletIfNeeded() { if (cryptoCurrency !is CryptoCurrency.Token) return val wallets = destinationUM?.wallets ?: return @@ -509,6 +520,7 @@ internal class SendConfirmModel @Inject constructor( isIgnoreReduce = confirmData.isIgnoreReduce, fee = confirmData.fee, feeError = confirmData.feeError, + feeCryptoCurrencyStatus = getCurrencyStatusForFeePayment(), ), ) _uiState.update { state -> @@ -610,9 +622,23 @@ internal class SendConfirmModel @Inject constructor( override fun onFeeResult(feeSelectorUM: FeeSelectorUMRedesigned) { sendIdleTimer = SystemClock.elapsedRealtime() _uiState.update { it.copy(feeSelectorUM = feeSelectorUM) } + updateAmountSubtractAvailability() updateConfirmNotifications() } + private fun updateAmountSubtractAvailability() { + modelScope.launch { + val isGaslessEthTx = + feeUMV2?.feeExtraInfo?.transactionFeeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency + isAmountSubtractAvailable = + isAmountSubtractAvailableUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + isGaslessEthTx = isGaslessEthTx, + ).getOrElse { false } + } + } + private companion object { const val CHECK_FEE_UPDATE_DELAY = 10_000L } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt index fec1c74cb7..98e150c9bb 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt @@ -102,7 +102,6 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( analyticsCategoryName = params.analyticsCategoryName, userWalletId = params.userWallet.walletId, cryptoCurrencyStatus = params.cryptoCurrencyStatus, - feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, appCurrency = params.appCurrency, callback = model, notificationData = SendNotificationsComponent.Params.NotificationData( @@ -113,6 +112,7 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( isIgnoreReduce = false, fee = model.confirmData.fee, feeError = model.confirmData.feeError, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, ), ), ) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt index 8bab99a2a4..1b926b4ea2 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -350,6 +350,7 @@ internal class NFTSendConfirmModel @Inject constructor( isIgnoreReduce = false, fee = confirmData.fee, feeError = confirmData.feeError, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, // TODO change when gasless implement ), ) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt index 7efbe2186d..eb07ae00ec 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt @@ -79,7 +79,7 @@ internal class NotificationsModel @Inject constructor( private val analyticsCategoryName = params.analyticsCategoryName private val userWalletId = params.userWalletId private val cryptoCurrencyStatus = params.cryptoCurrencyStatus - private val feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus + private val feeCryptoCurrencyStatus = params.notificationData.feeCryptoCurrencyStatus private val currency = cryptoCurrencyStatus.currency private val appCurrency = params.appCurrency @@ -92,7 +92,10 @@ internal class NotificationsModel @Inject constructor( init { subscribeToNotificationUpdateTrigger() - checkIfSubtractAvailable() + modelScope.launch { + checkIfSubtractAvailable() + buildNotifications() + } incrementNotificationsShowCount() } @@ -102,11 +105,12 @@ internal class NotificationsModel @Inject constructor( .launchIn(modelScope) } - private fun checkIfSubtractAvailable() { - modelScope.launch { - isAmountSubtractAvailable = isAmountSubtractAvailableUseCase(userWalletId, currency).getOrElse { false } - buildNotifications() - } + private suspend fun checkIfSubtractAvailable() { + isAmountSubtractAvailable = isAmountSubtractAvailableUseCase( + userWalletId = userWalletId, + currency = currency, + isGaslessEthTx = notificationData.fee is Fee.Ethereum.TokenCurrency, + ).getOrElse { false } } private fun incrementNotificationsShowCount() { @@ -115,16 +119,19 @@ internal class NotificationsModel @Inject constructor( } } - private suspend fun updateState(data: NotificationData) { + private fun updateState(data: NotificationData) { notificationData = data - buildNotifications() + modelScope.launch { + checkIfSubtractAvailable() + buildNotifications() + } } private suspend fun buildNotifications() = with(notificationData) { val feeValue = fee?.amount?.value val sendingAmount = checkAndCalculateSubtractedAmount( isAmountSubtractAvailable = isAmountSubtractAvailable, - cryptoCurrencyStatus = cryptoCurrencyStatus, + cryptoCurrencyStatus = getCurrencyStatusForFeePayment(), amountValue = amountValue, feeValue = feeValue, reduceAmountBy = reduceAmountBy, @@ -369,6 +376,15 @@ internal class NotificationsModel @Inject constructor( } } + private fun getCurrencyStatusForFeePayment(): CryptoCurrencyStatus { + val isFeeInTokenCurrency = notificationData.fee is Fee.Ethereum.TokenCurrency + return if (isFeeInTokenCurrency) { + notificationData.feeCryptoCurrencyStatus + } else { + cryptoCurrencyStatus + } + } + companion object { const val TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT = 3 } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index dc79a0ee29..a81a280eda 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -107,7 +107,6 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( analyticsCategoryName = params.analyticsCategoryName, userWalletId = params.userWallet.walletId, cryptoCurrencyStatus = model.primaryCurrencyStatus, - feeCryptoCurrencyStatus = model.primaryFeePaidCurrencyStatus, appCurrency = params.appCurrency, callback = model, notificationData = SendNotificationsComponent.Params.NotificationData( @@ -121,6 +120,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( isIgnoreReduce = model.confirmData.isIgnoreReduce, fee = model.confirmData.fee, feeError = model.confirmData.feeError, + feeCryptoCurrencyStatus = model.primaryFeePaidCurrencyStatus, ), ), ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 3fcd6d870c..ead2a200fb 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -357,6 +357,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( isIgnoreReduce = confirmData.isIgnoreReduce, fee = confirmData.fee, feeError = confirmData.feeError, + feeCryptoCurrencyStatus = params.primaryFeePaidCurrencyStatusFlow.value, ), ) swapNotificationsUpdateTrigger.triggerUpdate( From c8b7934d931255bb20a4040b3007226059f078bd Mon Sep 17 00:00:00 2001 From: Tangem Date: Sat, 10 Jan 2026 14:08:10 +0300 Subject: [PATCH 073/172] Updated on 2026-08-14 --- .../tap/di/domain/TransactionDomainModule.kt | 19 ++- .../api/gasless/GaslessTxServiceApi.kt | 2 +- .../GaslessTxDataToGaslessRequestConverter.kt | 3 +- .../CreateAndSendGaslessTransactionUseCase.kt | 5 - .../gasless/EstimateFeeForTokenUseCase.kt | 131 ++++++++++++++++++ 5 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 9f0811eff8..b0b821fffb 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") @Module @InstallIn(SingletonComponent::class) internal object TransactionDomainModule { @@ -346,4 +346,21 @@ internal object TransactionDomainModule { getHotWalletSigner = tangemHotWalletSignerFactory::create, ) } + + @Provides + @Singleton + fun provideEstimateFeeForTokenUseCase( + walletManagersFacade: WalletManagersFacade, + gaslessTransactionRepository: GaslessTransactionRepository, + currenciesRepository: CurrenciesRepository, + getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + ): EstimateFeeForTokenUseCase { + return EstimateFeeForTokenUseCase( + gaslessTransactionRepository = gaslessTransactionRepository, + walletManagersFacade = walletManagersFacade, + demoConfig = DemoConfig, + currenciesRepository = currenciesRepository, + getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + ) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt index 193dc620e8..72b01f4139 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt @@ -14,7 +14,7 @@ interface GaslessTxServiceApi { @GET("api/v1/tokens") suspend fun getSupportedTokens(): ApiResponse> - @POST("api/v1/sign") + @POST("api/v1/transaction/sign") suspend fun signGaslessTransaction( @Body transaction: GaslessTransactionRequest, ): ApiResponse> diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt index 44005be4c1..1d20602465 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt @@ -1,5 +1,6 @@ package com.tangem.data.transaction.convertes +import com.tangem.blockchain.extensions.formatHex import com.tangem.datasource.api.gasless.models.FeeData import com.tangem.datasource.api.gasless.models.TransactionData import com.tangem.domain.transaction.models.GaslessTransactionData @@ -27,7 +28,7 @@ class GaslessTxDataToGaslessRequestConverter : Converter { + return either { + if (!gaslessTransactionRepository.isNetworkSupported(token.network)) { + raise(GetFeeError.GaslessError.NetworkIsNotSupported) + } + + val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + ).getOrNull()?.filter { + it.currency.network.id == token.network.id + } ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + + val tokenCurrencyStatus = userCurrenciesStatusesByNetwork.find { + it.currency.id == token.id + } ?: raiseIllegalStateError("token currency not found for network ${token.network.id}") + + val amountData = amount.convertToSdkAmount(tokenCurrencyStatus) + val result = if (userWallet is UserWallet.Cold && + demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) + ) { + demoTransactionSender(userWallet, token).estimateFee( + amount = amountData, + destination = "", + ) + } else { + walletManagersFacade.estimateFee( + amount = amountData, + userWalletId = userWallet.walletId, + network = token.network, + ) + } + + val initialTxFee = when (result) { + is Result.Success -> result.data + is Result.Failure -> raise(result.mapToFeeError()) + null -> raise(GetFeeError.UnknownError) + } + + val initialFeeEth = initialTxFee.normal as? Fee.Ethereum + ?: raiseIllegalStateError( + error = "only Fee.Ethereum supported, but was different", + ) + + val nativeCurrency = currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = token.network.id, + derivationPath = token.network.derivationPath, + ) + + val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find { + it.currency.id == nativeCurrency.id + } ?: raiseIllegalStateError("native currency not found for network ${token.network.id}") + + val walletManager = prepareWalletManager(userWallet, token.network) + + tokenFeeCalculator.calculateTokenFee( + walletManager = walletManager, + tokenForPayFeeStatus = tokenCurrencyStatus, + nativeCurrencyStatus = nativeCurrencyStatus, + initialFee = initialFeeEth, + ).bind() + } + } + + @Suppress("NullableToStringCall") + private suspend fun Raise.prepareWalletManager( + userWallet: UserWallet, + network: Network, + ): EthereumWalletManager { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = network, + ) + val ethereumWalletManager = walletManager as? EthereumWalletManager + ?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported") + return ethereumWalletManager + } + + private suspend fun demoTransactionSender( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + ): DemoTransactionSender { + return DemoTransactionSender( + walletManagersFacade + .getOrCreateWalletManager(userWallet.walletId, cryptoCurrency.network) + ?: error("WalletManager is null"), + ) + } +} \ No newline at end of file From bdf2433cb739c823bdf9b35ae5e1c044e01b829b Mon Sep 17 00:00:00 2001 From: Tangem Date: Sat, 10 Jan 2026 19:49:57 +0300 Subject: [PATCH 074/172] Updated on 2026-08-14 --- .../tap/di/domain/TransactionDomainModule.kt | 19 ++ .../gasless/EstimateFeeForGaslessTxUseCase.kt | 183 ++++++++++++++++++ .../gasless/EstimateFeeForTokenUseCase.kt | 20 +- .../usecase/gasless/TokenFeeCalculator.kt | 32 +++ .../confirm/SendWithSwapConfirmComponent.kt | 1 + .../confirm/model/SendWithSwapConfirmModel.kt | 53 ++++- .../confirm/model/SwapTransactionSender.kt | 29 ++- 7 files changed, 317 insertions(+), 20 deletions(-) create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index b0b821fffb..e664e2294a 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -363,4 +363,23 @@ internal object TransactionDomainModule { getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, ) } + + @Provides + @Singleton + fun provideEstimateFeeForGaslessTxUseCase( + walletManagersFacade: WalletManagersFacade, + gaslessTransactionRepository: GaslessTransactionRepository, + currenciesRepository: CurrenciesRepository, + getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + estimateFeeUseCase: EstimateFeeUseCase, + ): EstimateFeeForGaslessTxUseCase { + return EstimateFeeForGaslessTxUseCase( + gaslessTransactionRepository = gaslessTransactionRepository, + walletManagersFacade = walletManagersFacade, + demoConfig = DemoConfig, + currenciesRepository = currenciesRepository, + getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + estimateFeeUseCase = estimateFeeUseCase, + ) + } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt new file mode 100644 index 0000000000..b7d9ec271c --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt @@ -0,0 +1,183 @@ +package com.tangem.domain.transaction.usecase.gasless + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.GetFeeError.GaslessError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.domain.transaction.raiseIllegalStateError +import com.tangem.domain.transaction.usecase.EstimateFeeUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade +import java.math.BigDecimal + +class EstimateFeeForGaslessTxUseCase( + private val walletManagersFacade: WalletManagersFacade, + private val demoConfig: DemoConfig, + private val gaslessTransactionRepository: GaslessTransactionRepository, + private val currenciesRepository: CurrenciesRepository, + private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val estimateFeeUseCase: EstimateFeeUseCase, +) { + + private val tokenFeeCalculator = TokenFeeCalculator( + walletManagersFacade = walletManagersFacade, + gaslessTransactionRepository = gaslessTransactionRepository, + demoConfig = demoConfig, + ) + + suspend operator fun invoke( + userWallet: UserWallet, + amount: BigDecimal, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): Either { + return either { + catch( + block = { + val network = cryptoCurrencyStatus.currency.network + val nativeCurrency = currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = network.id, + derivationPath = network.derivationPath, + ) + + if (!gaslessTransactionRepository.isNetworkSupported(network)) { + estimateFeeUseCase.invoke( + userWallet = userWallet, + amount = amount, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).fold( + ifLeft = { raise(it) }, + ifRight = { fee -> + return@either TransactionFeeExtended( + transactionFee = fee, + feeTokenId = nativeCurrency.id, + ) + }, + ) + } + + val walletManager = prepareWalletManager(userWallet, network) + + val initialFee = tokenFeeCalculator.estimateInitialFee( + userWallet = userWallet, + amount = amount, + tokenCurrencyStatus = cryptoCurrencyStatus, + ).bind() + + selectFeePaymentStrategy( + userWallet = userWallet, + walletManager = walletManager, + nativeCurrency = nativeCurrency, + network = network, + initialFee = initialFee, + ) + }, + catch = { + raise(GetFeeError.DataError(it)) + }, + ) + } + } + + @Suppress("NullableToStringCall") + private suspend fun Raise.prepareWalletManager( + userWallet: UserWallet, + network: Network, + ): EthereumWalletManager { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = network, + ) + val ethereumWalletManager = walletManager as? EthereumWalletManager + ?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported") + return ethereumWalletManager + } + + private suspend fun Raise.selectFeePaymentStrategy( + userWallet: UserWallet, + walletManager: EthereumWalletManager, + nativeCurrency: CryptoCurrency, + network: Network, + initialFee: TransactionFee, + ): TransactionFeeExtended { + val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError) + + val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + ).getOrNull() ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + + val networkCurrenciesStatuses = userCurrenciesStatuses.filter { + it.currency.network.id == network.id + } + + val nativeCurrencyStatus = networkCurrenciesStatuses.find { + it.currency.id == nativeCurrency.id + } ?: raiseIllegalStateError("native currency not found for network ${network.id}") + + val nativeBalance = nativeCurrencyStatus.value.amount ?: BigDecimal.ZERO + return if (nativeBalance >= feeValue) { + TransactionFeeExtended(transactionFee = initialFee, feeTokenId = nativeCurrencyStatus.currency.id) + } else { + findTokensToPayFee( + walletManager = walletManager, + initialTxFee = initialFee, + nativeCurrencyStatus = nativeCurrencyStatus, + networkCurrenciesStatuses = networkCurrenciesStatuses, + ) + } + } + + @Suppress("NullableToStringCall") + private suspend fun Raise.findTokensToPayFee( + walletManager: EthereumWalletManager, + initialTxFee: TransactionFee, + nativeCurrencyStatus: CryptoCurrencyStatus, + networkCurrenciesStatuses: List, + ): TransactionFeeExtended { + val initialFee = initialTxFee.normal as? Fee.Ethereum + ?: raiseIllegalStateError( + error = "only Fee.Ethereum supported, but was ${initialTxFee.normal::class.qualifiedName}", + ) + + val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens( + network = nativeCurrencyStatus.currency.network, + ).mapNotNull { currency -> + (currency as? CryptoCurrency.Token)?.contractAddress + }.toSet() + + val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses + .filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token } + .sortedByDescending { it.value.amount } + .filter { status -> + val token = status.currency as? CryptoCurrency.Token ?: return@filter false + token.contractAddress.lowercase() in supportedGaslessTokens + } + + /** + * Selects token with highest balance to maximize chances of successful fee payment. + * Returns null if no suitable tokens found. + */ + val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull() + ?: raise(GaslessError.NoSupportedTokensFound) + + return tokenFeeCalculator.calculateTokenFee( + walletManager = walletManager, + tokenForPayFeeStatus = tokenForPayFeeStatus, + nativeCurrencyStatus = nativeCurrencyStatus, + initialFee = initialFee, + ).bind() + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt index de5f21becf..33e6864487 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt @@ -9,6 +9,7 @@ import com.tangem.blockchain.extensions.Result import com.tangem.domain.demo.DemoTransactionSender import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase @@ -38,24 +39,15 @@ class EstimateFeeForTokenUseCase( suspend operator fun invoke( userWallet: UserWallet, - token: CryptoCurrency, + tokenCurrencyStatus: CryptoCurrencyStatus, amount: BigDecimal, ): Either { return either { + val token = tokenCurrencyStatus.currency if (!gaslessTransactionRepository.isNetworkSupported(token.network)) { raise(GetFeeError.GaslessError.NetworkIsNotSupported) } - val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( - userWallet.walletId, - ).getOrNull()?.filter { - it.currency.network.id == token.network.id - } ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") - - val tokenCurrencyStatus = userCurrenciesStatusesByNetwork.find { - it.currency.id == token.id - } ?: raiseIllegalStateError("token currency not found for network ${token.network.id}") - val amountData = amount.convertToSdkAmount(tokenCurrencyStatus) val result = if (userWallet is UserWallet.Cold && demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) @@ -89,6 +81,12 @@ class EstimateFeeForTokenUseCase( derivationPath = token.network.derivationPath, ) + val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + ).getOrNull()?.filter { + it.currency.network.id == token.network.id + } ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find { it.currency.id == nativeCurrency.id } ?: raiseIllegalStateError("native currency not found for network ${token.network.id}") diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt index 612cd7cb24..8934f4fb11 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt @@ -23,6 +23,7 @@ import com.tangem.domain.transaction.error.GetFeeError.GaslessError import com.tangem.domain.transaction.error.mapToFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.raiseIllegalStateError +import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.walletmanager.WalletManagersFacade import java.math.BigDecimal import java.math.RoundingMode @@ -56,6 +57,37 @@ internal class TokenFeeCalculator( } } + suspend fun estimateInitialFee( + userWallet: UserWallet, + amount: BigDecimal, + tokenCurrencyStatus: CryptoCurrencyStatus, + ): Either { + return either { + val network = tokenCurrencyStatus.currency.network + val amountData = amount.convertToSdkAmount(tokenCurrencyStatus) + val result = if (userWallet is UserWallet.Cold && + demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) + ) { + demoTransactionSender(userWallet, network).estimateFee( + amount = amountData, + destination = "", + ) + } else { + walletManagersFacade.estimateFee( + amount = amountData, + userWalletId = userWallet.walletId, + network = network, + ) + } + + when (result) { + is Result.Success -> result.data + is Result.Failure -> raise(result.mapToFeeError()) + null -> raise(GetFeeError.UnknownError) + } + } + } + suspend fun calculateTokenFee( walletManager: EthereumWalletManager, tokenForPayFeeStatus: CryptoCurrencyStatus, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index a81a280eda..5d53b1d40e 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -90,6 +90,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( params = FeeSelectorBlockParams( state = model.uiState.value.feeSelectorUM, onLoadFee = model::loadFee, + onLoadFeeExtended = model::loadFeeExtended, feeCryptoCurrencyStatus = model.primaryFeePaidCurrencyStatus, cryptoCurrencyStatus = model.primaryCurrencyStatus, feeStateConfiguration = FeeStateConfiguration.ExcludeLow, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index ead2a200fb..72cfe665f9 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -3,6 +3,7 @@ package com.tangem.features.swap.v2.impl.sendviaswap.confirm.model import arrow.core.Either import arrow.core.getOrElse import arrow.core.left +import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer @@ -26,7 +27,10 @@ import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.swap.models.SwapDirection.Companion.withSwapDirection import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.EstimateFeeUseCase +import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase +import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.features.send.v2.api.SendNotificationsComponent import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData @@ -64,6 +68,7 @@ import jakarta.inject.Inject import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.math.BigDecimal +import com.tangem.features.send.v2.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned import com.tangem.utils.transformer.update as transformerUpdate @Suppress("LongParameterList", "LargeClass") @@ -73,6 +78,8 @@ internal class SendWithSwapConfirmModel @Inject constructor( private val router: Router, private val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase, private val estimateFeeUseCase: EstimateFeeUseCase, + private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase, + private val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val sendNotificationsUpdateTrigger: SendNotificationsUpdateTrigger, @@ -106,6 +113,8 @@ internal class SendWithSwapConfirmModel @Inject constructor( get() = uiState.value.destinationUM as? DestinationUM.Content private val feeSelectorUM get() = uiState.value.feeSelectorUM as? FeeSelectorUM.Content + private val feeUMV2 + get() = uiState.value.feeSelectorUM as? FeeSelectorUMRedesigned.Content val secondaryCurrencyStatus: CryptoCurrencyStatus? = amountUM?.secondaryCryptoCurrencyStatus val secondaryCurrency: CryptoCurrency = requireNotNull(amountUM?.secondaryCryptoCurrencyStatus?.currency) { @@ -143,7 +152,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( } init { - initAmountSubtractAvailability() + updateAmountSubtractAvailability() configConfirmNavigation() initialState() subscribeOnNotificationUpdates() @@ -152,6 +161,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( override fun onFeeResult(feeSelectorUM: FeeSelectorUM) { uiState.update { it.copy(feeSelectorUM = feeSelectorUM) } + updateAmountSubtractAvailability() updateConfirmNotifications() } @@ -243,11 +253,43 @@ internal class SendWithSwapConfirmModel @Inject constructor( } } + suspend fun loadFeeExtended(maybeToken: CryptoCurrencyStatus?): Either { + val defaultError = GetFeeError.UnknownError.left() + val provider = (confirmData.quote as? SwapQuoteUM.Content)?.provider ?: return defaultError + val amountValue = confirmData.enteredAmount ?: return defaultError + + return when (val providerType = provider.type) { + ExpressProviderType.CEX -> { + if (maybeToken != null) { + estimateFeeForTokenUseCase( + amount = amountValue, + userWallet = params.userWallet, + tokenCurrencyStatus = maybeToken, + ) + } else { + estimateFeeForGaslessTxUseCase( + amount = amountValue, + userWallet = params.userWallet, + cryptoCurrencyStatus = primaryCurrencyStatus, + ) + } + } + ExpressProviderType.DEX, + ExpressProviderType.DEX_BRIDGE, + ExpressProviderType.ONRAMP, + -> GetFeeError.DataError( + cause = IllegalStateException("Provider $providerType is not supported in Send With Swap"), + ).left() + } + } + private fun onSendClick() { val provider = confirmData.quote?.provider ?: return modelScope.launch { uiState.transformerUpdate(SendWithSwapConfirmSendingStateTransformer(true)) + val feeExtended = feeUMV2?.feeExtraInfo?.transactionFeeExtended swapTransactionSender.sendTransaction( + feeExtended = feeExtended, confirmData = confirmData, isAmountSubtractAvailable = isAmountSubtractAvailable, onExpressError = { expressError -> @@ -305,12 +347,15 @@ internal class SendWithSwapConfirmModel @Inject constructor( } } - private fun initAmountSubtractAvailability() { + private fun updateAmountSubtractAvailability() { modelScope.launch { + val isGaslessEthTx = + feeUMV2?.feeExtraInfo?.transactionFeeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency isAmountSubtractAvailable = isAmountSubtractAvailableUseCase( - params.userWallet.walletId, - primaryCurrencyStatus.currency, + userWalletId = params.userWallet.walletId, + currency = primaryCurrencyStatus.currency, + isGaslessEthTx = isGaslessEthTx, ).getOrElse { false } } } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt index 15b59a6ef4..50a94cdb36 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt @@ -17,8 +17,10 @@ import com.tangem.domain.swap.models.SwapTxType import com.tangem.domain.swap.usecase.GetSwapDataUseCase import com.tangem.domain.swap.usecase.SwapTransactionSentUseCase import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils import com.tangem.features.swap.v2.impl.common.ConfirmData @@ -35,6 +37,7 @@ internal class SwapTransactionSender @AssistedInject constructor( private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val swapTransactionSentUseCase: SwapTransactionSentUseCase, private val sendTransactionUseCase: SendTransactionUseCase, + private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, @Assisted private val userWallet: UserWallet, ) { @@ -45,6 +48,7 @@ internal class SwapTransactionSender @AssistedInject constructor( onExpressError: (ExpressError) -> Unit, onSendError: (SendTransactionError?) -> Unit, expressOperationType: ExpressOperationType, + feeExtended: TransactionFeeExtended?, ) { val provider = confirmData.quote?.provider ?: return @@ -56,6 +60,7 @@ internal class SwapTransactionSender @AssistedInject constructor( onSendSuccess = onSendSuccess, onSendError = onSendError, expressOperationType = expressOperationType, + feeExtended = feeExtended, ) ExpressProviderType.DEX, ExpressProviderType.DEX_BRIDGE, @@ -74,6 +79,7 @@ internal class SwapTransactionSender @AssistedInject constructor( onExpressError: (ExpressError) -> Unit, onSendError: (SendTransactionError?) -> Unit, expressOperationType: ExpressOperationType, + feeExtended: TransactionFeeExtended?, ) { val fromStatus = confirmData.fromCryptoCurrencyStatus ?: return val toStatus = confirmData.toCryptoCurrencyStatus ?: return @@ -114,6 +120,7 @@ internal class SwapTransactionSender @AssistedInject constructor( onSendSuccess = onSendSuccess, onExpressError = onExpressError, onSendError = onSendError, + feeExtended = feeExtended, ) } @@ -123,6 +130,7 @@ internal class SwapTransactionSender @AssistedInject constructor( toStatus: CryptoCurrencyStatus, fromAccount: Account.CryptoPortfolio?, fee: Fee, + feeExtended: TransactionFeeExtended?, provider: ExpressProvider, swapData: SwapDataModel, onSendSuccess: (String, Long, SwapDataModel) -> Unit, @@ -161,11 +169,22 @@ internal class SwapTransactionSender @AssistedInject constructor( txData.destinationAddress } - sendTransactionUseCase( - txData = txData, - userWallet = userWallet, - network = fromStatus.currency.network, - ).fold( + val isFeeInTokenCurrency = feeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency + + val result = if (feeExtended != null && isFeeInTokenCurrency) { + createAndSendGaslessTransactionUseCase( + userWallet = userWallet, + transactionData = txData, + fee = feeExtended, + ) + } else { + sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = fromStatus.currency.network, + ) + } + result.fold( ifLeft = { onSendError(it) }, ifRight = { txHash -> val timestamp = System.currentTimeMillis() From 4a5fcc48fab8e6b84525d62d0a6968f53ac28248 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 11 Jan 2026 16:23:21 +0300 Subject: [PATCH 075/172] Updated on 2026-08-14 --- domain/transaction/build.gradle.kts | 5 + .../usecase/gasless/TokenFeeCalculatorTest.kt | 474 ++++++++++++++++++ gradle/tangem_dependencies.toml | 2 +- 3 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt diff --git a/domain/transaction/build.gradle.kts b/domain/transaction/build.gradle.kts index 50f4bce71d..8488e0c17b 100644 --- a/domain/transaction/build.gradle.kts +++ b/domain/transaction/build.gradle.kts @@ -36,4 +36,9 @@ dependencies { implementation(projects.domain.demo) implementation(projects.domain.card) api(projects.domain.networks) + + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(projects.common.test) + testImplementation(projects.test.core) + testImplementation(projects.test.mock) } \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt new file mode 100644 index 0000000000..2cd02cdee6 --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt @@ -0,0 +1,474 @@ +package com.tangem.domain.transaction.usecase.gasless + +import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchain.extensions.Result +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.walletmanager.WalletManagersFacade +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.math.BigDecimal +import java.math.BigInteger + +/** + * Unit tests for [TokenFeeCalculator]. + * Tests cover fee calculation logic, error handling, and edge cases. + */ +class TokenFeeCalculatorTest { + + private lateinit var walletManagersFacade: WalletManagersFacade + private lateinit var gaslessTransactionRepository: GaslessTransactionRepository + private lateinit var demoConfig: DemoConfig + private lateinit var tokenFeeCalculator: TokenFeeCalculator + + private lateinit var mockWalletManager: EthereumWalletManager + private lateinit var mockNetwork: Network + private lateinit var mockUserWallet: UserWallet + private lateinit var mockUserWalletId: UserWalletId + private lateinit var mockTransactionData: TransactionData + + @Before + fun setup() { + walletManagersFacade = mockk() + gaslessTransactionRepository = mockk() + demoConfig = mockk() + + tokenFeeCalculator = TokenFeeCalculator( + walletManagersFacade = walletManagersFacade, + gaslessTransactionRepository = gaslessTransactionRepository, + demoConfig = demoConfig, + ) + + mockWalletManager = mockk() + mockNetwork = mockk() + mockUserWallet = mockk() + mockUserWalletId = mockk() + mockTransactionData = mockk() + + + // Default mock behavior + every { demoConfig.isDemoCardId(any()) } returns false + every { mockUserWallet.walletId } returns mockUserWalletId + } + + // ===== calculateInitialFee Tests ===== + + @Test + fun `calculateInitialFee should return success when getFee succeeds`() = runTest { + // Given + val expectedFee = createMockTransactionFee() + coEvery { mockWalletManager.getFee(mockTransactionData) } returns Result.Success(expectedFee) + + // When + val result = tokenFeeCalculator.calculateInitialFee( + userWallet = mockUserWallet, + network = mockNetwork, + walletManager = mockWalletManager, + transactionData = mockTransactionData, + ) + + // Then + assertTrue(result.isRight()) + result.onRight { fee -> + assertEquals(expectedFee, fee) + } + coVerify { mockWalletManager.getFee(mockTransactionData) } + } + + @Test + fun `calculateInitialFee should return error when getFee fails`() = runTest { + // Given + val failure = Result.Failure(BlockchainSdkError.Ethereum.Api(1, "Failed to get fee")) + coEvery { mockWalletManager.getFee(mockTransactionData) } returns failure + + // When + val result = tokenFeeCalculator.calculateInitialFee( + userWallet = mockUserWallet, + network = mockNetwork, + walletManager = mockWalletManager, + transactionData = mockTransactionData, + ) + + // Then + assertTrue(result.isLeft()) + result.onLeft { error -> + assertTrue(error is GetFeeError.DataError) + } + } + + // ===== estimateInitialFee Tests ===== + + @Test + fun `estimateInitialFee should return success when estimation succeeds`() = runTest { + // Given + val amount = BigDecimal("100") + val tokenStatus = createMockTokenStatus() + val expectedFee = createMockTransactionFee() + + coEvery { walletManagersFacade.estimateFee(any(), any(), any()) } returns Result.Success(expectedFee) + + // When + val result = tokenFeeCalculator.estimateInitialFee( + userWallet = mockUserWallet, + amount = amount, + tokenCurrencyStatus = tokenStatus, + ) + + // Then + assertTrue(result.isRight()) + result.onRight { fee -> + assertEquals(expectedFee, fee) + } + } + + @Test + fun `estimateInitialFee should return error when estimation fails`() = runTest { + // Given + val amount = BigDecimal("100") + val tokenStatus = createMockTokenStatus() + val failure = Result.Failure(BlockchainSdkError.NPError("Estimation failed")) + + coEvery { + walletManagersFacade.estimateFee(any(), any(), any()) + } returns failure + + // When + val result = tokenFeeCalculator.estimateInitialFee( + userWallet = mockUserWallet, + amount = amount, + tokenCurrencyStatus = tokenStatus, + ) + + // Then + assertTrue(result.isLeft()) + } + + @Test + fun `estimateInitialFee should return error when result is null`() = runTest { + // Given + val amount = BigDecimal("100") + val tokenStatus = createMockTokenStatus() + + coEvery { walletManagersFacade.estimateFee(any(), any(), any()) } returns null + + // When + val result = tokenFeeCalculator.estimateInitialFee( + userWallet = mockUserWallet, + amount = amount, + tokenCurrencyStatus = tokenStatus, + ) + + // Then + assertTrue(result.isLeft()) + result.onLeft { error -> + assertTrue(error is GetFeeError.UnknownError) + } + } + + // ===== calculateTokenFee Tests ===== + + @Test + fun `calculateTokenFee should calculate correct fee for token payment`() = runTest { + // Given + val tokenStatus = createMockTokenStatus( + balance = BigDecimal("1000"), + fiatRate = BigDecimal("1"), // 1 USDC = 1 USD + ) + val nativeStatus = createMockNativeCurrencyStatus( + fiatRate = BigDecimal("2000"), // 1 ETH = 2000 USD + ) + val initialFee = createMockEIP1559Fee() + + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + every { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isRight()) + result.onRight { feeExtended -> + assertNotNull(feeExtended) + assertEquals(tokenStatus.currency.id, feeExtended.feeTokenId) + assertTrue(feeExtended.transactionFee is TransactionFee.Single) + } + } + + @Test + fun `calculateTokenFee should return error when token balance is insufficient`() = runTest { + // Given + val tokenStatus = createMockTokenStatus( + balance = BigDecimal("0.001"), // Very small balance + fiatRate = BigDecimal("1"), + ) + val nativeStatus = createMockNativeCurrencyStatus( + fiatRate = BigDecimal("2000"), + ) + val initialFee = createMockEIP1559Fee() + + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + every { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isLeft()) + result.onLeft { error -> + assertTrue(error is GetFeeError.GaslessError.NotEnoughFunds) + } + } + + @Test + fun `calculateTokenFee should return error when fiatRate is null`() = runTest { + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + every { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + // Given + val tokenStatus = createMockTokenStatus( + balance = BigDecimal("1000"), + fiatRate = null, // No fiat rate + ) + val nativeStatus = createMockNativeCurrencyStatus( + fiatRate = BigDecimal("2000"), + ) + val initialFee = createMockEIP1559Fee() + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isLeft()) + } + + @Test + fun `calculateTokenFee should return error when getGasLimit fails`() = runTest { + // Given + val tokenStatus = createMockTokenStatus() + val nativeStatus = createMockNativeCurrencyStatus() + val initialFee = createMockEIP1559Fee() + + val failure = Result.Failure(BlockchainSdkError.NPError("Gas limit fetch failed")) + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns failure + every { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isLeft()) + result.onLeft { error -> + assertTrue(error is GetFeeError.DataError) + } + } + + @Test + fun `calculateTokenFee should handle Legacy fee type correctly`() = runTest { + // Given + val tokenStatus = createMockTokenStatus() + val nativeStatus = createMockNativeCurrencyStatus() + val initialFee = Fee.Ethereum.Legacy( + amount = mockk(relaxed = true), + gasLimit = BigInteger("100000"), + gasPrice = BigInteger("50000000000"), // 50 Gwei + ) + + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + every { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isRight()) + } + + @Test + fun `calculateTokenFee should reject TokenCurrency as initial fee`() = runTest { + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + every { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + + // Given + val tokenStatus = createMockTokenStatus() + val nativeStatus = createMockNativeCurrencyStatus() + val initialFee = Fee.Ethereum.TokenCurrency( + amount = mockk(relaxed = true), + gasLimit = BigInteger("100000"), + coinPriceInToken = BigInteger("1000"), + feeTransferGasLimit = BigInteger("60000"), + baseGas = BigInteger("21000"), + ) + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isLeft()) + } + + @Test + fun `calculateTokenFee should apply 1 percent increase to token price`() = runTest { + // Expected + + val expectedAmount = Amount( + value = BigDecimal("36.200000000000000000000000000000000000"), + token = Token( + name = "USDC", + symbol = "USDC", + contractAddress = "0xUSDC", + decimals = 6, + ) + ) + val expectedGasLimit = "181000".toBigInteger() + val expectedCoinPriceInToken = BigInteger("2020000000") // 2000 * 1.01 * 10^6 + val expectedFeeTransferLimit = "60000".toBigInteger() + val expectedBaseGas = "21000".toBigInteger() + + // Given + val tokenStatus = createMockTokenStatus( + balance = BigDecimal("1000"), + fiatRate = BigDecimal("1"), + decimals = 6, + ) + val nativeStatus = createMockNativeCurrencyStatus( + fiatRate = BigDecimal("2000"), + decimals = 18, + ) + val initialFee = createMockEIP1559Fee() + + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + every { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isRight()) + result.onRight { feeExtended -> + val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency + + assertEquals(expectedAmount, fee.amount) + assertEquals(expectedGasLimit, fee.gasLimit) + assertEquals(expectedCoinPriceInToken, fee.coinPriceInToken) + assertEquals(expectedFeeTransferLimit, fee.feeTransferGasLimit) + assertEquals(expectedBaseGas, fee.baseGas) + } + } + + // ===== Helper Methods ===== + + private fun createMockTransactionFee(): TransactionFee { + val fee = Fee.Ethereum.EIP1559( + amount = mockk(relaxed = true), + gasLimit = BigInteger("100000"), + maxFeePerGas = BigInteger("50000000000"), + priorityFee = BigInteger("1000000000"), + ) + return TransactionFee.Single(normal = fee) + } + + private fun createMockEIP1559Fee(): Fee.Ethereum.EIP1559 { + return Fee.Ethereum.EIP1559( + amount = mockk(relaxed = true), + gasLimit = BigInteger("100000"), + maxFeePerGas = BigInteger("50000000000"), // 50 Gwei + priorityFee = BigInteger("1000000000"), // 1 Gwei + ) + } + + private fun createMockTokenStatus( + balance: BigDecimal = BigDecimal("1000"), + fiatRate: BigDecimal? = BigDecimal("1"), + decimals: Int = 6, + ): CryptoCurrencyStatus { + val token = mockk(relaxed = true) + every { token.symbol } returns "USDC" + every { token.contractAddress } returns "0xUSDC" + every { token.decimals } returns decimals + every { token.network } returns mockNetwork + every { token.id } returns mockk(relaxed = true) + + val status = mockk() + every { status.currency } returns token + every { status.value.amount } returns balance + every { status.value.fiatRate } returns fiatRate + every { status.value.yieldSupplyStatus } returns null + + return status + } + + private fun createMockNativeCurrencyStatus( + fiatRate: BigDecimal? = BigDecimal("2000"), + decimals: Int = 18, + ): CryptoCurrencyStatus { + val coin = mockk(relaxed = true) + every { coin.symbol } returns "ETH" + every { coin.decimals } returns decimals + every { coin.network } returns mockNetwork + + val status = mockk() + every { status.currency } returns coin + every { status.value.fiatRate } returns fiatRate + every { status.value.yieldSupplyStatus } returns null + + return status + } +} diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 5df28a8a43..c8a467d9cb 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-1353" +tangemBlockchainSdk = "develop-1354" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From a46fc13fb0df8ec58b4c8a72a8a75862114a26fd Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 12 Jan 2026 08:06:26 +0100 Subject: [PATCH 076/172] Updated on 2026-08-14 --- .../features/feed/components/DefaultFeedEntryComponent.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 a66d53fe10..97883f8378 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 @@ -129,13 +129,17 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier, ) { - BackHandler(enabled = bottomSheetState.value == BottomSheetState.EXPANDED) { + val stackStack = stack.subscribeAsState() + BackHandler( + enabled = bottomSheetState.value == BottomSheetState.EXPANDED && + stackStack.value.active.configuration !is FeedEntryChildFactory.Child.Feed, + ) { onChildBack() } EntryContent( bottomSheetState = bottomSheetState, - stackState = stack.subscribeAsState(), + stackState = stackStack, onHeaderSizeChange = onHeaderSizeChange, isOpenedInBottomSheet = true, ) From 4f88a5ddf939b336b8a973b11fea42e73e4270df Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 12 Jan 2026 08:07:20 +0100 Subject: [PATCH 077/172] Updated on 2026-08-14 --- .../features/feed/model/market/list/state/MarketsListUM.kt | 3 +-- .../com/tangem/features/feed/ui/market/list/MarketsList.kt | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/MarketsListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/MarketsListUM.kt index 9529c3db53..8415759005 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/MarketsListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/MarketsListUM.kt @@ -23,8 +23,7 @@ internal data class MarketsListUM( val onSearchClicked: () -> Unit, ) { val isInSearchMode - get() = marketsSearchBar.searchBarUM.isActive && - marketsSearchBar.searchBarUM.query.isNotEmpty() + get() = marketsSearchBar.searchBarUM.query.isNotEmpty() enum class TrendInterval(val text: TextReference) { H24(resourceReference(R.string.markets_selector_interval_24h_title)), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt index 05490b2364..85ebbae800 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt @@ -59,7 +59,8 @@ internal fun TopBarWithSearch( val focusRequester: FocusRequester = remember { FocusRequester() } AnimatedContent( - targetState = !marketsSearchBar.shouldAlwaysShowSearchBar && !marketsSearchBar.searchBarUM.isActive, + targetState = !marketsSearchBar.shouldAlwaysShowSearchBar && + !marketsSearchBar.searchBarUM.isActive && marketsSearchBar.searchBarUM.query.isEmpty(), ) { showAppBarWithBackIcon -> if (showAppBarWithBackIcon) { AppBarWithBackButtonAndIcon( From ebb562b2a0bf58d827875441ccd3f365032d2ab4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 12 Jan 2026 10:20:23 +0300 Subject: [PATCH 078/172] Updated on 2026-08-14 --- .../tangem/tap/di/domain/TokensDomainModule.kt | 2 ++ .../tap/di/domain/TransactionDomainModule.kt | 15 +++++++++++++-- data/tokens/build.gradle.kts | 4 ++++ .../com/tangem/data/tokens/di/TokensDataModule.kt | 3 +++ .../repository/DefaultCurrencyChecksRepository.kt | 14 ++++++++++---- .../DefaultGaslessTransactionRepository.kt | 14 -------------- .../MockedGaslessTransactionRepository.kt | 5 ----- .../GetBalanceNotEnoughForFeeWarningUseCase.kt | 10 +++++++++- .../domain/tokens/GetCurrencyWarningsUseCase.kt | 8 ++++++-- .../tokens/repository/CurrencyChecksRepository.kt | 3 +++ .../transaction/GaslessTransactionRepository.kt | 2 -- .../gasless/EstimateFeeForGaslessTxUseCase.kt | 5 ++++- .../usecase/gasless/EstimateFeeForTokenUseCase.kt | 4 +++- .../gasless/GetAvailableFeeTokensUseCase.kt | 4 +++- .../usecase/gasless/GetFeeForGaslessUseCase.kt | 5 ++++- .../usecase/gasless/GetFeeForTokenUseCase.kt | 4 +++- .../gasless/IsGaslessFeeSupportedForNetwork.kt | 6 +++--- .../com/tangem/feature/swap/model/SwapModel.kt | 3 +++ .../swap/model/SwapNotificationsFactory.kt | 5 ++++- .../com/tangem/feature/swap/ui/StateBuilder.kt | 4 +++- gradle/tangem_dependencies.toml | 2 +- 21 files changed, 81 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt index 7e1c0e98de..5bb200ad04 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt @@ -282,11 +282,13 @@ internal object TokensDomainModule { currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, dispatchers: CoroutineDispatcherProvider, + currencyChecksRepository: CurrencyChecksRepository, ): GetBalanceNotEnoughForFeeWarningUseCase { return GetBalanceNotEnoughForFeeWarningUseCase( currenciesRepository = currenciesRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, dispatchers = dispatchers, + currencyChecksRepository = currencyChecksRepository, ) } diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index e664e2294a..ff1b6bb97a 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -10,6 +10,7 @@ import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.TransactionRepository @@ -277,11 +278,13 @@ internal object TransactionDomainModule { gaslessTransactionRepository: GaslessTransactionRepository, currenciesRepository: CurrenciesRepository, getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + currencyChecksRepository: CurrencyChecksRepository, ): GetAvailableFeeTokensUseCase { return GetAvailableFeeTokensUseCase( gaslessTransactionRepository = gaslessTransactionRepository, currenciesRepository = currenciesRepository, getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + currencyChecksRepository = currencyChecksRepository, ) } @@ -293,6 +296,7 @@ internal object TransactionDomainModule { currenciesRepository: CurrenciesRepository, getFeeUseCase: GetFeeUseCase, getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + currencyChecksRepository: CurrencyChecksRepository, ): GetFeeForGaslessUseCase { return GetFeeForGaslessUseCase( walletManagersFacade = walletManagersFacade, @@ -301,6 +305,7 @@ internal object TransactionDomainModule { currenciesRepository = currenciesRepository, getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, getFeeUseCase = getFeeUseCase, + currencyChecksRepository = currencyChecksRepository, ) } @@ -311,6 +316,7 @@ internal object TransactionDomainModule { gaslessTransactionRepository: GaslessTransactionRepository, currenciesRepository: CurrenciesRepository, getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + currencyChecksRepository: CurrencyChecksRepository, ): GetFeeForTokenUseCase { return GetFeeForTokenUseCase( gaslessTransactionRepository = gaslessTransactionRepository, @@ -318,15 +324,16 @@ internal object TransactionDomainModule { demoConfig = DemoConfig, currenciesRepository = currenciesRepository, getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + currencyChecksRepository = currencyChecksRepository, ) } @Provides @Singleton fun provideIsGaslessFeeSupportedForNetwork( - gaslessTransactionRepository: GaslessTransactionRepository, + currencyChecksRepository: CurrencyChecksRepository, ): IsGaslessFeeSupportedForNetwork { - return IsGaslessFeeSupportedForNetwork(gaslessTransactionRepository) + return IsGaslessFeeSupportedForNetwork(currencyChecksRepository) } @Provides @@ -354,6 +361,7 @@ internal object TransactionDomainModule { gaslessTransactionRepository: GaslessTransactionRepository, currenciesRepository: CurrenciesRepository, getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + currencyChecksRepository: CurrencyChecksRepository, ): EstimateFeeForTokenUseCase { return EstimateFeeForTokenUseCase( gaslessTransactionRepository = gaslessTransactionRepository, @@ -361,6 +369,7 @@ internal object TransactionDomainModule { demoConfig = DemoConfig, currenciesRepository = currenciesRepository, getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + currencyChecksRepository = currencyChecksRepository, ) } @@ -372,6 +381,7 @@ internal object TransactionDomainModule { currenciesRepository: CurrenciesRepository, getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, estimateFeeUseCase: EstimateFeeUseCase, + currencyChecksRepository: CurrencyChecksRepository, ): EstimateFeeForGaslessTxUseCase { return EstimateFeeForGaslessTxUseCase( gaslessTransactionRepository = gaslessTransactionRepository, @@ -380,6 +390,7 @@ internal object TransactionDomainModule { currenciesRepository = currenciesRepository, getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, estimateFeeUseCase = estimateFeeUseCase, + currencyChecksRepository = currencyChecksRepository, ) } } \ No newline at end of file diff --git a/data/tokens/build.gradle.kts b/data/tokens/build.gradle.kts index 39d4e0ed16..171fb3f750 100644 --- a/data/tokens/build.gradle.kts +++ b/data/tokens/build.gradle.kts @@ -46,6 +46,10 @@ dependencies { implementation(projects.libs.blockchainSdk) // endregion + // region Project - Features API + implementation(projects.features.sendV2.api) + // endregion + // region Tangem SDKs implementation(tangemDeps.blockchain) implementation(tangemDeps.card.core) diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt index 1941b6ee4b..8c0efab318 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt @@ -22,6 +22,7 @@ import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -72,10 +73,12 @@ internal object TokensDataModule { fun provideCurrencyChecksRepository( walletManagersFacade: WalletManagersFacade, coroutineDispatcherProvider: CoroutineDispatcherProvider, + sendFeatureToggles: SendFeatureToggles, ): CurrencyChecksRepository { return DefaultCurrencyChecksRepository( walletManagersFacade = walletManagersFacade, coroutineDispatchers = coroutineDispatcherProvider, + sendFeatureToggles = sendFeatureToggles, ) } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt index 336256a2de..a24dc8b95b 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt @@ -1,10 +1,8 @@ package com.tangem.data.tokens.repository +import com.tangem.blockchain.blockchains.ethereum.eip1559.isGaslessTxSupported import com.tangem.blockchain.blockchains.polkadot.ExistentialDepositProvider -import com.tangem.blockchain.common.FeeResourceAmountProvider -import com.tangem.blockchain.common.MinimumSendAmountProvider -import com.tangem.blockchain.common.ReserveAmountProvider -import com.tangem.blockchain.common.UtxoAmountLimitProvider +import com.tangem.blockchain.common.* import com.tangem.data.tokens.converters.UtxoConverter import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -17,6 +15,7 @@ import com.tangem.domain.tokens.model.blockchains.UtxoAmountLimit import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.isZero import com.tangem.utils.extensions.orZero @@ -26,6 +25,7 @@ import java.math.BigDecimal internal class DefaultCurrencyChecksRepository( private val walletManagersFacade: WalletManagersFacade, private val coroutineDispatchers: CoroutineDispatcherProvider, + private val sendFeatureToggles: SendFeatureToggles, ) : CurrencyChecksRepository { override suspend fun getExistentialDeposit(userWalletId: UserWalletId, network: Network): BigDecimal? { @@ -66,6 +66,12 @@ internal class DefaultCurrencyChecksRepository( } } + override fun isNetworkSupportedForGaslessTx(network: Network): Boolean { + if (!sendFeatureToggles.isGaslessTransactionsEnabled) return false + val blockchain = Blockchain.fromId(network.rawId) + return blockchain.isGaslessTxSupported + } + override suspend fun getFeeResourceAmount(userWalletId: UserWalletId, network: Network): CurrencyAmount? { val manager = walletManagersFacade.getOrCreateWalletManager( userWalletId = userWalletId, diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt index 40677f16b3..f821a49766 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -30,11 +30,6 @@ class DefaultGaslessTransactionRepository( private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder() private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter() - override fun isNetworkSupported(network: Network): Boolean { - val blockchain = Blockchain.fromId(network.rawId) - return SUPPORTED_BLOCKCHAINS.contains(blockchain) - } - override suspend fun getSupportedTokens(network: Network): Set { return withContext(coroutineDispatcherProvider.io) { val storedTokens = supportedTokensState.value[network.id] @@ -118,14 +113,5 @@ class DefaultGaslessTransactionRepository( const val TOKEN_RECEIVER_ADDRESS = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000") - val SUPPORTED_BLOCKCHAINS = arrayOf( - Blockchain.Ethereum, - Blockchain.BSC, - Blockchain.Base, - Blockchain.Polygon, - Blockchain.Arbitrum, - Blockchain.XDC, - Blockchain.Optimism, - ) } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt index 7ac662e4ce..ae642382e9 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt @@ -15,11 +15,6 @@ class MockedGaslessTransactionRepository( private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, ) : GaslessTransactionRepository { - override fun isNetworkSupported(network: Network): Boolean { - val blockchain = Blockchain.fromId(network.rawId) - return SUPPORTED_BLOCKCHAINS.contains(blockchain) - } - override suspend fun getSupportedTokens(network: Network): Set { val usdcPolygon = responseCryptoCurrenciesFactory.createToken( blockchain = Blockchain.Polygon, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt index 26ed8b9867..9f8fe6fc0c 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt @@ -7,6 +7,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext import java.math.BigDecimal @@ -27,6 +28,7 @@ class GetBalanceNotEnoughForFeeWarningUseCase( private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val dispatchers: CoroutineDispatcherProvider, + private val currencyChecksRepository: CurrencyChecksRepository, ) { suspend operator fun invoke( fee: BigDecimal, @@ -41,9 +43,15 @@ class GetBalanceNotEnoughForFeeWarningUseCase( val isFeePaidByCoin = tokenStatus.currency is CryptoCurrency.Token val isFeePaidByToken = feePaidCurrency is FeePaidCurrency.Token && tokenStatus.currency.id != feePaidCurrency.tokenId + val isNetworkSupportGasless = currencyChecksRepository.isNetworkSupportedForGaslessTx( + coinStatus.currency.network, + ) val warning = when { - feePaidCurrency is FeePaidCurrency.Coin && isFeePaidByCoin && fee > coinBalance -> { + feePaidCurrency is FeePaidCurrency.Coin && + isFeePaidByCoin && + fee > coinBalance && + !isNetworkSupportGasless -> { CryptoCurrencyWarning.BalanceNotEnoughForFee( tokenCurrency = tokenStatus.currency, coinCurrency = coinStatus.currency, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt index 6ca1aa64ce..15315030c7 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt @@ -29,6 +29,7 @@ class GetCurrencyWarningsUseCase( private val currencyChecksRepository: CurrencyChecksRepository, private val currencyStatusOperations: BaseCurrencyStatusOperations, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, + ) { suspend operator fun invoke( @@ -56,7 +57,7 @@ class GetCurrencyWarningsUseCase( maybeRentWarning, maybeEdWarning?.let { getExistentialDepositWarning(currency, it) }, maybeFeeResource?.let { getFeeResourceWarning(it) }, - * coinRelatedWarnings.toTypedArray(), + *coinRelatedWarnings.toTypedArray(), getNetworkUnavailableWarning(currencyStatus), getNetworkNoAccountWarning(currencyStatus), getBeaconChainShutdownWarning(rawId = currency.network.id.rawId), @@ -114,11 +115,14 @@ class GetCurrencyWarningsUseCase( ): CryptoCurrencyWarning? { val feePaidCurrency = currenciesRepository.getFeePaidCurrency(userWalletId, tokenStatus.currency.network) val isNetworkFeeZero = currenciesRepository.isNetworkFeeZero(userWalletId, tokenStatus.currency.network) + val isNetworkSupportGasless = + currencyChecksRepository.isNetworkSupportedForGaslessTx(coinStatus.currency.network) return when { feePaidCurrency is FeePaidCurrency.Coin && !tokenStatus.value.amount.isZero() && coinStatus.value.amount.isZero() && - !isNetworkFeeZero -> { + !isNetworkFeeZero && + !isNetworkSupportGasless -> { CryptoCurrencyWarning.BalanceNotEnoughForFee( tokenCurrency = tokenStatus.currency, coinCurrency = coinStatus.currency, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrencyChecksRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrencyChecksRepository.kt index 41266e0b39..86f9ee6d7e 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrencyChecksRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrencyChecksRepository.kt @@ -61,4 +61,7 @@ interface CurrencyChecksRepository { currencyStatus: CryptoCurrencyStatus?, balanceAfterTransaction: BigDecimal, ): CryptoCurrencyWarning.Rent? + + /** Checks if the network supports gasless transactions */ + fun isNetworkSupportedForGaslessTx(network: Network): Boolean } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt index 1ebe8d1635..bbef57a432 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt @@ -9,8 +9,6 @@ import java.math.BigInteger interface GaslessTransactionRepository { - fun isNetworkSupported(network: Network): Boolean - suspend fun getSupportedTokens(network: Network): Set fun getTokenFeeReceiverAddress(): String diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt index b7d9ec271c..53856d8603 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt @@ -14,6 +14,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError.GaslessError @@ -23,6 +24,7 @@ import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import java.math.BigDecimal +@Suppress("LongParameterList") class EstimateFeeForGaslessTxUseCase( private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, @@ -30,6 +32,7 @@ class EstimateFeeForGaslessTxUseCase( private val currenciesRepository: CurrenciesRepository, private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, private val estimateFeeUseCase: EstimateFeeUseCase, + private val currencyChecksRepository: CurrencyChecksRepository, ) { private val tokenFeeCalculator = TokenFeeCalculator( @@ -53,7 +56,7 @@ class EstimateFeeForGaslessTxUseCase( derivationPath = network.derivationPath, ) - if (!gaslessTransactionRepository.isNetworkSupported(network)) { + if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) { estimateFeeUseCase.invoke( userWallet = userWallet, amount = amount, diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt index 33e6864487..d41eb24d0c 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt @@ -14,6 +14,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.mapToFeeError @@ -29,6 +30,7 @@ class EstimateFeeForTokenUseCase( private val demoConfig: DemoConfig, private val currenciesRepository: CurrenciesRepository, private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val currencyChecksRepository: CurrencyChecksRepository, ) { private val tokenFeeCalculator = TokenFeeCalculator( @@ -44,7 +46,7 @@ class EstimateFeeForTokenUseCase( ): Either { return either { val token = tokenCurrencyStatus.currency - if (!gaslessTransactionRepository.isNetworkSupported(token.network)) { + if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) { raise(GetFeeError.GaslessError.NetworkIsNotSupported) } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt index f3881c6787..cf57b14f21 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -10,6 +10,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.raiseIllegalStateError @@ -19,6 +20,7 @@ class GetAvailableFeeTokensUseCase( private val gaslessTransactionRepository: GaslessTransactionRepository, private val currenciesRepository: CurrenciesRepository, private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val currencyChecksRepository: CurrencyChecksRepository, ) { /** @@ -40,7 +42,7 @@ class GetAvailableFeeTokensUseCase( val nativeCurrencyStatus = getNativeCurrencyStatus(userWallet, network, userCurrenciesStatuses) - if (!gaslessTransactionRepository.isNetworkSupported(network)) { + if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) { return@either listOf(nativeCurrencyStatus) } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt index a8ef3aa70b..9beebc0a2f 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt @@ -15,6 +15,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError.GaslessError @@ -24,6 +25,7 @@ import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import java.math.BigDecimal +@Suppress("LongParameterList") class GetFeeForGaslessUseCase( private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, @@ -31,6 +33,7 @@ class GetFeeForGaslessUseCase( private val currenciesRepository: CurrenciesRepository, private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, private val getFeeUseCase: GetFeeUseCase, + private val currencyChecksRepository: CurrencyChecksRepository, ) { private val tokenFeeCalculator = TokenFeeCalculator( @@ -53,7 +56,7 @@ class GetFeeForGaslessUseCase( derivationPath = network.derivationPath, ) - if (!gaslessTransactionRepository.isNetworkSupported(network)) { + if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) { getFeeUseCase.invoke(userWallet, network, transactionData).fold( ifLeft = { raise(it) }, ifRight = { fee -> diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt index c1d82c2b18..4e4efd2880 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt @@ -12,6 +12,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended @@ -24,6 +25,7 @@ class GetFeeForTokenUseCase( private val demoConfig: DemoConfig, private val currenciesRepository: CurrenciesRepository, private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val currencyChecksRepository: CurrencyChecksRepository, ) { private val tokenFeeCalculator = TokenFeeCalculator( @@ -38,7 +40,7 @@ class GetFeeForTokenUseCase( transactionData: TransactionData, ): Either { return either { - if (!gaslessTransactionRepository.isNetworkSupported(token.network)) { + if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) { raise(GetFeeError.GaslessError.NetworkIsNotSupported) } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/IsGaslessFeeSupportedForNetwork.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/IsGaslessFeeSupportedForNetwork.kt index 67c8c36205..18a61d0319 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/IsGaslessFeeSupportedForNetwork.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/IsGaslessFeeSupportedForNetwork.kt @@ -1,16 +1,16 @@ package com.tangem.domain.transaction.usecase.gasless import com.tangem.domain.models.network.Network -import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository /** * Use case to check if gasless fee is supported for a given network. */ class IsGaslessFeeSupportedForNetwork( - private val gaslessTransactionRepository: GaslessTransactionRepository, + private val currencyChecksRepository: CurrencyChecksRepository, ) { operator fun invoke(network: Network): Boolean { - return gaslessTransactionRepository.isNetworkSupported(network) + return currencyChecksRepository.isNetworkSupportedForGaslessTx(network) } } \ No newline at end of file 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 cbed111a44..1255f83a96 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 @@ -51,6 +51,7 @@ import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.analytics.StoriesEvents @@ -118,6 +119,7 @@ internal class SwapModel @Inject constructor( private val accountsFeatureToggles: AccountsFeatureToggles, private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase, private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, + private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) : Model() { private val params = paramsContainer.require() @@ -149,6 +151,7 @@ internal class SwapModel @Inject constructor( isBalanceHiddenProvider = Provider { isBalanceHidden }, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), isAccountsModeProvider = Provider { isAccountsMode }, + iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, ) private val inputNumberFormatter = 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 731e036f94..eafe9d8abb 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 @@ -12,6 +12,7 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount @@ -32,6 +33,7 @@ import java.math.BigDecimal @Suppress("LargeClass") internal class SwapNotificationsFactory( private val actions: UiActions, + private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) { fun getInitialErrorStateNotifications(code: Int, onRefreshClick: () -> Unit): ImmutableList { @@ -294,7 +296,8 @@ internal class SwapNotificationsFactory( val shouldShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough && quoteModel.permissionState !is PermissionDataState.PermissionLoading && feeEnoughState.feeCurrency != fromToken - if (shouldShowCoverWarning) { + val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromToken.network) + if (shouldShowCoverWarning && !isGaslessAvailable) { add( SwapNotificationUM.Error.UnableToCoverFeeWarning( fromToken = fromToken, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 6ab1d4af06..860cd0017c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -25,6 +25,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.promo.models.StoryContent +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter import com.tangem.feature.swap.converters.TokensDataConverter import com.tangem.feature.swap.converters.TokensDataConverterV2 @@ -64,6 +65,7 @@ internal class StateBuilder( private val isBalanceHiddenProvider: Provider, private val appCurrencyProvider: Provider, private val isAccountsModeProvider: Provider, + private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) { private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) @@ -76,7 +78,7 @@ internal class StateBuilder( ) private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) { - SwapNotificationsFactory(actions) + SwapNotificationsFactory(actions, iGaslessFeeSupportedForNetwork) } fun createInitialLoadingState( diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index c8a467d9cb..65b0b9e0d3 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-1354" +tangemBlockchainSdk = "develop-1355" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From ae759af1c7632b1cbb14ca8c83f0c73262793358 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 4 Dec 2025 10:15:05 +0400 Subject: [PATCH 079/172] Updated on 2026-08-14 --- .../tangem/tap/di/domain/NFTDomainModule.kt | 16 --- .../networks/utils/DefaultNetworksCleaner.kt | 42 ++++-- .../utils/DefaultNetworksCleanerTest.kt | 31 ++++- .../data/staking/di/StakingDataModule.kt | 3 + .../staking/utils/DefaultStakingCleaner.kt | 46 ++++++- .../utils/DefaultStakingCleanerTest.kt | 127 +++++++++++++++--- .../status/di/AccountStatusUseCaseModule.kt | 26 +++- .../usecase/ManageCryptoCurrenciesUseCase.kt | 27 +--- .../utils/CryptoCurrencyMetadataCleaner.kt | 69 ++++++++++ .../status/utils/CryptoCurrencyCleanerTest.kt | 74 ++++++++++ .../domain/networks/utils/NetworksCleaner.kt | 10 ++ .../ObserveAndClearNFTCacheIfNeedUseCase.kt | 51 ------- .../domain/staking/utils/StakingCleaner.kt | 29 ++++ .../wallet/child/wallet/model/WalletModel.kt | 11 -- 14 files changed, 422 insertions(+), 140 deletions(-) create mode 100644 domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyMetadataCleaner.kt create mode 100644 domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyCleanerTest.kt delete mode 100644 domain/nft/src/main/kotlin/com/tangem/domain/nft/ObserveAndClearNFTCacheIfNeedUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt index 2fbc11170b..cf1a7707c6 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt @@ -142,22 +142,6 @@ internal object NFTDomainModule { return GetWalletNFTEnabledUseCase(walletsRepository) } - @Provides - @Singleton - fun provideClearNFTCacheUseCase( - nftCleaner: NFTCleaner, - currenciesRepository: CurrenciesRepository, - accountsFeatureToggles: AccountsFeatureToggles, - singleAccountListSupplier: SingleAccountListSupplier, - ): ObserveAndClearNFTCacheIfNeedUseCase { - return ObserveAndClearNFTCacheIfNeedUseCase( - nftCleaner = nftCleaner, - currenciesRepository = currenciesRepository, - accountsFeatureToggles = accountsFeatureToggles, - singleAccountListSupplier = singleAccountListSupplier, - ) - } - @Provides @Singleton fun provideGetNftCurrencyUseCase(nftRepository: NFTRepository): GetNFTCurrencyUseCase { diff --git a/data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt b/data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt index c4ef782bc9..b395990e65 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt @@ -7,9 +7,11 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.utils.NetworksCleaner import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch +import com.tangem.utils.coroutines.runSuspendCatching +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext +import timber.log.Timber /** * Default implementation of [NetworksCleaner]. @@ -27,33 +29,51 @@ internal class DefaultNetworksCleaner( ) : NetworksCleaner { override suspend fun invoke(userWalletId: UserWalletId, currencies: List) { + if (currencies.isEmpty()) { + Timber.d("No currencies to clear for wallet: $userWalletId") + return + } + withContext(dispatchers.default) { val (networks, tokens) = currencies.partitionByType() - coroutineScope { - launch { cleanStore(userWalletId = userWalletId, networks = networks) } - launch { cleanWalletManager(userWalletId = userWalletId, networks = networks, tokens = tokens) } - } + awaitAll( + async { clearStatusesStore(userWalletId = userWalletId, networks = networks) }, + async { clearBlockchainSDK(userWalletId = userWalletId, networks = networks, tokens = tokens) }, + ) } } - private suspend fun cleanStore(userWalletId: UserWalletId, networks: Set) { + private suspend fun clearStatusesStore(userWalletId: UserWalletId, networks: Set) { if (networks.isNotEmpty()) { - networksStatusesStore.clear(userWalletId = userWalletId, networks = networks) + runSuspendCatching { + networksStatusesStore.clear(userWalletId = userWalletId, networks = networks) + } + .onFailure { Timber.e(it, "Failed to clear network statuses for wallet: $userWalletId") } } } - private suspend fun cleanWalletManager( + private suspend fun clearBlockchainSDK( userWalletId: UserWalletId, networks: Set, tokens: Set, ) { if (networks.isNotEmpty()) { - walletManagersFacade.remove(userWalletId = userWalletId, networks = networks) + runSuspendCatching { + walletManagersFacade.remove(userWalletId = userWalletId, networks = networks) + } + .onFailure { + Timber.e(it, "Failed to remove networks from Blockchain SDK for wallet: $userWalletId") + } } if (tokens.isNotEmpty()) { - walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = tokens) + runSuspendCatching { + walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = tokens) + } + .onFailure { + Timber.e(it, "Failed to remove tokens from Blockchain SDK for wallet: $userWalletId") + } } } diff --git a/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt b/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt index c2913c5f5a..5e7d1d6083 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt @@ -7,6 +7,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks +import io.mockk.coEvery import io.mockk.coVerifyOrder import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -45,7 +46,7 @@ class DefaultNetworksCleanerTest { // Assert coVerifyOrder { - networksStatusesStore.clear(userWalletId, setOf(network)) + networksStatusesStore.clear(userWalletId = userWalletId, networks = setOf(network)) walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network)) walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token)) } @@ -66,12 +67,15 @@ class DefaultNetworksCleanerTest { @Test fun `should clear only networks when there are no tokens`() = runTest { + // Arrange val currencies = listOf(coin) + // Act cleaner(userWalletId = userWalletId, currencies = currencies) + // Assert coVerifyOrder { - networksStatusesStore.clear(userWalletId, setOf(network)) + networksStatusesStore.clear(userWalletId = userWalletId, networks = setOf(network)) walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network)) } @@ -98,4 +102,27 @@ class DefaultNetworksCleanerTest { walletManagersFacade.remove(userWalletId = any(), networks = any()) } } + + @Test + fun `should handle exception during cleaning`() = runTest { + // Arrange + val currencies = listOf(coin, token) + + val exception = Exception("Test exception") + coEvery { networksStatusesStore.clear(userWalletId, setOf(network)) } throws exception + coEvery { walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network)) } throws exception + coEvery { + walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token)) + } throws exception + + // Act + cleaner(userWalletId = userWalletId, currencies = currencies) + + // Assert + coVerifyOrder { + networksStatusesStore.clear(userWalletId = userWalletId, networks = setOf(network)) + walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network)) + walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token)) + } + } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt index 2f4f5b754a..220bdc1198 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -16,6 +16,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.P2PEthPoolVaultsStore import com.tangem.datasource.local.token.StakingActionsStore import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.repositories.* import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.staking.utils.StakingCleaner @@ -136,10 +137,12 @@ internal object StakingDataModule { @Provides @Singleton fun provideStakingCleaner( + stakingIdFactory: StakingIdFactory, stakingBalancesStore: StakingBalancesStore, dispatchers: CoroutineDispatcherProvider, ): StakingCleaner { return DefaultStakingCleaner( + stakingIdFactory = stakingIdFactory, stakingBalancesStore = stakingBalancesStore, dispatchers = dispatchers, ) diff --git a/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt b/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt index c55bf2154e..396099e6ff 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt @@ -1,10 +1,17 @@ package com.tangem.data.staking.utils +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.data.staking.store.StakingBalancesStore import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.utils.StakingCleaner import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import timber.log.Timber /** * Default implementation of [StakingCleaner]. @@ -15,15 +22,46 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider [REDACTED_AUTHOR] */ internal class DefaultStakingCleaner( + private val stakingIdFactory: StakingIdFactory, private val stakingBalancesStore: StakingBalancesStore, private val dispatchers: CoroutineDispatcherProvider, ) : StakingCleaner { - override suspend fun invoke(userWalletId: UserWalletId, stakingIds: Set) { - if (stakingIds.isEmpty()) return + override suspend fun invoke(userWalletId: UserWalletId, currencies: List) { + if (currencies.isEmpty()) { + Timber.d("No currencies to clear for wallet: $userWalletId") + return + } - with(dispatchers.default) { - stakingBalancesStore.clear(userWalletId, stakingIds) + val stakingIds = currencies.mapNotNullTo(hashSetOf()) { + stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() + } + + if (stakingIds.isEmpty()) { + Timber.d("All currencies have no stakingIds to clear for wallet: $userWalletId") + return + } + + invoke(userWalletId = userWalletId, stakingIds = stakingIds) + } + + override suspend fun invoke(userWalletId: UserWalletId, stakingIds: Set) { + if (stakingIds.isEmpty()) { + Timber.d("No stakingIds to clear for wallet: $userWalletId") + return + } + + withContext(dispatchers.default) { + awaitAll( + async { clearStatusesStore(userWalletId = userWalletId, stakingIds = stakingIds) }, + ) } } + + private suspend fun clearStatusesStore(userWalletId: UserWalletId, stakingIds: Set) { + runSuspendCatching { + stakingBalancesStore.clear(userWalletId, stakingIds) + } + .onFailure { Timber.e(it, "Failed to clear yield balance statuses for wallet: $userWalletId") } + } } \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt index 630865f7ba..823197d89f 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt @@ -1,55 +1,146 @@ package com.tangem.data.staking.utils +import arrow.core.right +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.data.staking.store.StakingBalancesStore import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks +import io.mockk.coEvery import io.mockk.coVerifyOrder import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DefaultStakingCleanerTest { + private val stakingIdFactory = mockk(relaxed = true) private val stakingBalancesStore = mockk(relaxed = true) private val cleaner = DefaultStakingCleaner( + stakingIdFactory = stakingIdFactory, stakingBalancesStore = stakingBalancesStore, dispatchers = TestingCoroutineDispatcherProvider(), ) + private val userWalletId = UserWalletId("011") - private val stakingIds = setOf( - StakingID(integrationId = StakingIntegrationID.StakeKit.Coin.Cardano.value, address = "0x1"), - ) @BeforeEach fun setUp() { - clearMocks(stakingBalancesStore) + clearMocks(stakingIdFactory, stakingBalancesStore) } - @Test - fun `should clear yields balances when called`() = runTest { - // Act - cleaner(userWalletId = userWalletId, stakingIds = stakingIds) + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ClearByStakingIds { - // Assert - coVerifyOrder { - stakingBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + private val stakingIds = setOf( + StakingID(integrationId = StakingIntegrationID.StakeKit.Coin.Cardano.value, address = "0x1"), + ) + + @Test + fun `should clear yields balances when called`() = runTest { + // Act + cleaner(userWalletId = userWalletId, stakingIds = stakingIds) + + // Assert + coVerifyOrder { + stakingBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + } + } + + @Test + fun `should handle empty stakingIds`() = runTest { + // Act + cleaner(userWalletId = userWalletId, stakingIds = emptySet()) + + // Assert + coVerifyOrder(inverse = true) { + stakingBalancesStore.clear(userWalletId = any(), stakingIds = any()) + } + } + + @Test + fun `should catch exception from stakingBalancesStore and not throw`() = runTest { + // Arrange + coEvery { stakingBalancesStore.clear(userWalletId, stakingIds) } throws Exception() + + // Act + cleaner(userWalletId = userWalletId, stakingIds = stakingIds) + + // Assert + coVerifyOrder { + stakingBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + } } } - @Test - fun `should handle empty stakingIds`() = runTest { - // Act - cleaner(userWalletId = userWalletId, stakingIds = emptySet()) + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ClearByCurrencies { - // Assert - coVerifyOrder(inverse = true) { - stakingBalancesStore.clear(userWalletId = any(), stakingIds = any()) + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + private val coin = cryptoCurrencyFactory.ethereum + + @Test + fun `should clear yields balances when called with single currency`() = runTest { + // Arrange + val stakingId = StakingID( + integrationId = "stake_kit_coin_eth", + address = "0xabc", + ) + + coEvery { stakingIdFactory.create(userWalletId, coin) } returns stakingId.right() + + // Act + cleaner(userWalletId = userWalletId, currency = coin) + + // Assert + coVerifyOrder { + stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = coin) + stakingBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + } + } + + @Test + fun `should handle empty list of currencies`() = runTest { + // Act + cleaner(userWalletId = userWalletId, currencies = emptyList()) + + // Assert + coVerifyOrder(inverse = true) { + stakingIdFactory.create(userWalletId = any(), cryptoCurrency = any()) + stakingBalancesStore.clear(userWalletId = any(), stakingIds = any()) + } + } + + @Test + fun `should catch exception from stakingBalancesStore and not throw`() = runTest { + // Arrange + val stakingId = StakingID( + integrationId = "stake_kit_coin_eth", + address = "0xabc", + ) + + coEvery { stakingIdFactory.create(userWalletId, coin) } returns stakingId.right() + coEvery { + stakingBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + } throws Exception() + + // Act + cleaner(userWalletId = userWalletId, currency = coin) + + // Assert + coVerifyOrder { + stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = coin) + stakingBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + } } } } \ No newline at end of file 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 c2d429c650..fced65957e 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 @@ -5,11 +5,13 @@ import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.* import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher +import com.tangem.domain.account.status.utils.CryptoCurrencyMetadataCleaner import com.tangem.domain.account.supplier.SingleAccountListSupplier 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.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher @@ -98,9 +100,7 @@ internal object AccountStatusUseCaseModule { derivationsRepository: DerivationsRepository, walletManagersFacade: WalletManagersFacade, cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, - stakingIdFactory: StakingIdFactory, - networksCleaner: NetworksCleaner, - stakingCleaner: StakingCleaner, + cryptoCurrencyMetadataCleaner: CryptoCurrencyMetadataCleaner, expressServiceFetcher: ExpressServiceFetcher, dispatchers: CoroutineDispatcherProvider, ): ManageCryptoCurrenciesUseCase { @@ -111,9 +111,7 @@ internal object AccountStatusUseCaseModule { derivationsRepository = derivationsRepository, walletManagersFacade = walletManagersFacade, cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, - stakingIdFactory = stakingIdFactory, - networksCleaner = networksCleaner, - stakingCleaner = stakingCleaner, + cryptoCurrencyMetadataCleaner = cryptoCurrencyMetadataCleaner, expressServiceFetcher = expressServiceFetcher, parallelUpdatingScope = CoroutineScope(SupervisorJob() + dispatchers.default), dispatchers = dispatchers, @@ -157,4 +155,20 @@ internal object AccountStatusUseCaseModule { dispatchers = dispatchers, ) } + + @Provides + @Singleton + fun provideCryptoCurrencyMetadataCleaner( + networksCleaner: NetworksCleaner, + stakingCleaner: StakingCleaner, + nftCleaner: NFTCleaner, + dispatchers: CoroutineDispatcherProvider, + ): CryptoCurrencyMetadataCleaner { + return CryptoCurrencyMetadataCleaner( + networksCleaner = networksCleaner, + stakingCleaner = stakingCleaner, + nftCleaner = nftCleaner, + dispatchers = dispatchers, + ) + } } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt index 51ffcfa1e8..dd64e3d0cc 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt @@ -7,6 +7,7 @@ import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher +import com.tangem.domain.account.status.utils.CryptoCurrencyMetadataCleaner import com.tangem.domain.core.utils.eitherOn import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.express.models.ExpressAsset @@ -17,9 +18,6 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.networks.utils.NetworksCleaner -import com.tangem.domain.staking.StakingIdFactory -import com.tangem.domain.staking.utils.StakingCleaner import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.derivations.DerivationsRepository @@ -36,9 +34,7 @@ import timber.log.Timber * @property currenciesRepository Repository for managing currencies. * @property derivationsRepository Repository for deriving public keys. * @property cryptoCurrencyBalanceFetcher Fetcher for updating crypto currency balances. - * @property stakingIdFactory Factory for creating staking IDs. - * @property networksCleaner Cleaner for removing obsolete network data. - * @property stakingCleaner Cleaner for removing obsolete staking data. + * @property cryptoCurrencyMetadataCleaner Cleaner for removing metadata of deleted currencies. * @property expressServiceFetcher Fetcher for updating express service data. * @property parallelUpdatingScope Coroutine scope for parallel updates. * @property dispatchers Coroutine dispatchers for managing threading. @@ -53,9 +49,7 @@ class ManageCryptoCurrenciesUseCase( private val derivationsRepository: DerivationsRepository, private val walletManagersFacade: WalletManagersFacade, private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, - private val stakingIdFactory: StakingIdFactory, - private val networksCleaner: NetworksCleaner, - private val stakingCleaner: StakingCleaner, + private val cryptoCurrencyMetadataCleaner: CryptoCurrencyMetadataCleaner, private val expressServiceFetcher: ExpressServiceFetcher, private val parallelUpdatingScope: CoroutineScope, private val dispatchers: CoroutineDispatcherProvider, @@ -306,21 +300,12 @@ class ManageCryptoCurrenciesUseCase( if (currencies.isEmpty()) return coroutineScope { - listOf( - launch { networksCleaner(userWalletId = userWalletId, currencies = currencies) }, - launch { clearStaking(userWalletId = userWalletId, currencies = currencies) }, - ) + launch { + cryptoCurrencyMetadataCleaner(userWalletId = userWalletId, currencies = currencies) + } } } - private suspend fun clearStaking(userWalletId: UserWalletId, currencies: List) { - val stakingIds = currencies.mapNotNullTo(hashSetOf()) { - stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() - } - - stakingCleaner(userWalletId = userWalletId, stakingIds = stakingIds) - } - private data class TempID( val networkId: String, val derivationPath: Network.DerivationPath, diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyMetadataCleaner.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyMetadataCleaner.kt new file mode 100644 index 0000000000..15fdc5ff07 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyMetadataCleaner.kt @@ -0,0 +1,69 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.utils.NetworksCleaner +import com.tangem.domain.nft.utils.NFTCleaner +import com.tangem.domain.staking.utils.StakingCleaner +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext + +/** + * Class to clean up all data related to a specific cryptocurrency in a user's wallet. + * + * @property networksCleaner Utility to clean network-related data. + * @property stakingCleaner Utility to clean staking-related data. + * @property nftCleaner Utility to clean NFT-related data. + * @property dispatchers Coroutine dispatchers for managing threading. + * + * @see Notion + * +[REDACTED_AUTHOR] + */ +class CryptoCurrencyMetadataCleaner( + private val networksCleaner: NetworksCleaner, + private val stakingCleaner: StakingCleaner, + private val nftCleaner: NFTCleaner, + private val dispatchers: CoroutineDispatcherProvider, +) { + + /** + * Cleans up data for a single cryptocurrency in the specified user wallet. + * + * @param userWalletId The ID of the user's wallet. + * @param currency The cryptocurrency to be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency) { + invoke(userWalletId = userWalletId, currencies = listOf(currency)) + } + + /** + * Cleans up data for multiple cryptocurrencies in the specified user wallet. + * + * @param userWalletId The ID of the user's wallet. + * @param currencies The list of cryptocurrencies to be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, currencies: List) { + if (currencies.isEmpty()) return + + return withContext(dispatchers.default) { + awaitAll( + async { networksCleaner(userWalletId = userWalletId, currencies = currencies) }, + async { clearStaking(userWalletId = userWalletId, currencies = currencies) }, + async { clearNFTs(userWalletId = userWalletId, currencies = currencies) }, + ) + } + } + + private suspend fun clearStaking(userWalletId: UserWalletId, currencies: List) { + stakingCleaner(userWalletId = userWalletId, currencies = currencies) + } + + private suspend fun clearNFTs(userWalletId: UserWalletId, currencies: List) { + val networks = currencies.mapTo(destination = hashSetOf(), transform = CryptoCurrency::network) + + nftCleaner(userWalletId = userWalletId, networks = networks) + } +} \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyCleanerTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyCleanerTest.kt new file mode 100644 index 0000000000..e1ce153ad3 --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyCleanerTest.kt @@ -0,0 +1,74 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.utils.NetworksCleaner +import com.tangem.domain.nft.utils.NFTCleaner +import com.tangem.domain.staking.utils.StakingCleaner +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CryptoCurrencyCleanerTest { + + private val networksCleaner: NetworksCleaner = mockk(relaxUnitFun = true) + private val stakingCleaner: StakingCleaner = mockk(relaxUnitFun = true) + private val nftCleaner: NFTCleaner = mockk(relaxUnitFun = true) + + private val cleaner = CryptoCurrencyMetadataCleaner( + networksCleaner = networksCleaner, + stakingCleaner = stakingCleaner, + nftCleaner = nftCleaner, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("011") + + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + private val coin = cryptoCurrencyFactory.ethereum + private val token = cryptoCurrencyFactory.createToken(Blockchain.Ethereum) + + @AfterEach + fun tearDown() { + clearMocks(networksCleaner, stakingCleaner, nftCleaner) + } + + @Test + fun `should not call cleaners when currencies list is empty`() = runTest { + // Act + cleaner(userWalletId = userWalletId, currencies = emptyList()) + + // Assert + coVerify(inverse = true) { + networksCleaner(userWalletId = any(), currencies = any()) + stakingCleaner(userWalletId = any(), currencies = any()) + nftCleaner(userWalletId = any(), networks = any()) + } + } + + @Test + fun `should call both cleaners when currencies list is not empty`() = runTest { + // Arrange + val currencies = listOf(coin, token) + + // Act + cleaner(userWalletId = userWalletId, currencies = currencies) + + // Assert + coVerify { + networksCleaner(userWalletId = userWalletId, currencies = currencies) + stakingCleaner(userWalletId = userWalletId, currencies = currencies) + nftCleaner(userWalletId = userWalletId, networks = setOf(coin.network, token.network)) + } + } +} \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt b/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt index 5db343c0c4..4a5ac9acd8 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt @@ -10,6 +10,16 @@ import com.tangem.domain.models.wallet.UserWalletId */ interface NetworksCleaner { + /** + * Cleans up network-related data for the given [userWalletId] and [currency]. + * + * @param userWalletId The ID of the user wallet for which to clean up data. + * @param currency The cryptocurrency whose associated network data should be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency) { + invoke(userWalletId = userWalletId, currencies = listOf(currency)) + } + /** * Cleans up network-related data for the given [userWalletId] and list of [currencies]. * diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/ObserveAndClearNFTCacheIfNeedUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/ObserveAndClearNFTCacheIfNeedUseCase.kt deleted file mode 100644 index 86e2e2ac34..0000000000 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/ObserveAndClearNFTCacheIfNeedUseCase.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.tangem.domain.nft - -import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles -import com.tangem.domain.account.models.AccountList -import com.tangem.domain.account.supplier.SingleAccountListSupplier -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.nft.utils.NFTCleaner -import com.tangem.domain.tokens.repository.CurrenciesRepository -import kotlinx.coroutines.flow.* - -class ObserveAndClearNFTCacheIfNeedUseCase( - private val nftCleaner: NFTCleaner, - private val currenciesRepository: CurrenciesRepository, - private val accountsFeatureToggles: AccountsFeatureToggles, - private val singleAccountListSupplier: SingleAccountListSupplier, -) { - - operator fun invoke(userWalletId: UserWalletId): Flow> = getCryptoCurrencies(userWalletId) - .map { it.map(CryptoCurrency::network) } - .mapDiff { old, new -> - // calculate networks sets difference to determine which networks were removed - old.toSet() - new.toSet() - } - .distinctUntilChanged() - .onEach { removedNetworks -> - if (removedNetworks.isNotEmpty()) { - nftCleaner(userWalletId, removedNetworks) - } - } - - private fun getCryptoCurrencies(userWalletId: UserWalletId): Flow> { - return if (accountsFeatureToggles.isFeatureEnabled) { - singleAccountListSupplier(userWalletId).map(AccountList::flattenCurrencies) - } else { - currenciesRepository.getWalletCurrenciesUpdates(userWalletId) - } - } - - private fun Flow.mapDiff(diff: (old: T, new: T) -> R): Flow = flow { - var previous: T? = null - collect { current -> - val prev = previous - if (prev != null) { - emit(diff(prev, current)) - } - previous = current - } - } -} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt b/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt index 84925b509b..9e1ce60e74 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt @@ -1,5 +1,6 @@ package com.tangem.domain.staking.utils +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId @@ -10,6 +11,34 @@ import com.tangem.domain.models.wallet.UserWalletId */ interface StakingCleaner { + /** + * Cleans up staking-related data for the given [userWalletId] and single [currency]. + * + * @param userWalletId The ID of the user wallet for which to clean up data. + * @param currency The cryptocurrency whose associated staking data should be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency) { + invoke(userWalletId = userWalletId, currencies = listOf(currency)) + } + + /** + * Cleans up staking-related data for the given [userWalletId] and list of [currencies]. + * + * @param userWalletId The ID of the user wallet for which to clean up data. + * @param currencies The list of cryptocurrencies whose associated staking data should be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, currencies: List) + + /** + * Cleans up staking-related data for the given [userWalletId] and single [stakingId]. + * + * @param userWalletId The ID of the user wallet for which to clean up data. + * @param stakingId The staking ID whose associated data should be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, stakingId: StakingID) { + invoke(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + } + /** * Cleans up staking-related data for the given [userWalletId] and set of [stakingIds]. * diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index ba456f80f5..6759eefd27 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -18,7 +18,6 @@ import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.* -import com.tangem.domain.nft.ObserveAndClearNFTCacheIfNeedUseCase import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.pay.repository.OnboardingRepository @@ -84,7 +83,6 @@ internal class WalletModel @Inject constructor( private val onrampStatusFactory: OnrampStatusFactory, private val analyticsEventsHandler: AnalyticsEventHandler, private val walletContentFetcher: WalletContentFetcher, - private val observeAndClearNFTCacheIfNeedUseCase: ObserveAndClearNFTCacheIfNeedUseCase, private val walletDeepLinkActionListener: WalletDeepLinkActionListener, private val notificationsRepository: NotificationsRepository, private val getWalletsListForEnablingUseCase: GetWalletsForAutomaticallyPushEnablingUseCase, @@ -114,7 +112,6 @@ internal class WalletModel @Inject constructor( private val walletsUpdateJobHolder = JobHolder() private val refreshWalletJobHolder = JobHolder() - private val clearNFTCacheJobHolder = JobHolder() private val updateTangemPayJobHolder = JobHolder() private var needToRefreshWallet = false @@ -334,7 +331,6 @@ internal class WalletModel @Inject constructor( } subscribeOnExpressTransactionsUpdates(selectedWallet) - observeAndClearNFTCacheIfNeedUseCase(selectedWallet) } .flowOn(dispatchers.main) .launchIn(modelScope) @@ -390,13 +386,6 @@ internal class WalletModel @Inject constructor( } } - private fun observeAndClearNFTCacheIfNeedUseCase(selectedWallet: UserWallet) { - observeAndClearNFTCacheIfNeedUseCase - .invoke(selectedWallet.walletId) - .launchIn(modelScope) - .saveIn(clearNFTCacheJobHolder) - } - private fun subscribeTangemPayOnWalletState() { /** * Update state each time a user opens/returns to wallet screen From d5d9c7bd2d455578e3e56f391509cc4c033a007d Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 12 Jan 2026 15:54:15 +0100 Subject: [PATCH 080/172] Updated on 2026-08-14 --- .../components/DefaultFeedEntryComponent.kt | 9 +++++ .../tangem/features/feed/di/ModelModule.kt | 6 ++++ .../features/feed/model/FeedEntryModel.kt | 35 +++++++++++++++++++ .../model/market/list/MarketsListModel.kt | 32 +++++++---------- 4 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/FeedEntryModel.kt 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 97883f8378..a4f6f6d824 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 @@ -12,6 +12,7 @@ import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.value.Value import com.tangem.core.decompose.context.AppComponentContext 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.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent @@ -25,6 +26,7 @@ import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListCo import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent import com.tangem.features.feed.entry.components.FeedEntryComponent import com.tangem.features.feed.entry.components.FeedEntryRoute +import com.tangem.features.feed.model.FeedEntryModel import com.tangem.features.feed.model.feed.FeedModelClickIntents import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.EntryContent @@ -39,6 +41,8 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( private val feedEntryChildFactory: FeedEntryChildFactory, ) : FeedEntryComponent, AppComponentContext by context { + private val model: FeedEntryModel = getOrCreateModel() + private val stackNavigation = StackNavigation() private val innerRouter = InnerRouter( @@ -129,6 +133,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier, ) { + val bsState by bottomSheetState val stackStack = stack.subscribeAsState() BackHandler( enabled = bottomSheetState.value == BottomSheetState.EXPANDED && @@ -137,6 +142,10 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( onChildBack() } + LaunchedEffect(bsState) { + model.containerBottomSheetState.value = bsState + } + EntryContent( bottomSheetState = bottomSheetState, stackState = stackStack, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt index 209d30fe44..6feb4b7bfc 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt @@ -2,6 +2,7 @@ package com.tangem.features.feed.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model +import com.tangem.features.feed.model.FeedEntryModel import com.tangem.features.feed.model.feed.FeedComponentModel import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel import com.tangem.features.feed.model.market.list.MarketsListModel @@ -41,4 +42,9 @@ internal interface ModelModule { @IntoMap @ClassKey(NewsListModel::class) fun provideNewsListModel(model: NewsListModel): Model + + @Binds + @IntoMap + @ClassKey(FeedEntryModel::class) + fun provideFeedEntryModel(model: FeedEntryModel): Model } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/FeedEntryModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/FeedEntryModel.kt new file mode 100644 index 0000000000..62db5f2cf8 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/FeedEntryModel.kt @@ -0,0 +1,35 @@ +package com.tangem.features.feed.model + +import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.features.feed.model.market.list.analytics.MarketsListAnalyticsEvent +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +@Stable +@ModelScoped +internal class FeedEntryModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, +) : Model() { + + val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED) + + init { + observeContainerBottomSheet() + } + + private fun observeContainerBottomSheet() { + containerBottomSheetState.onEach { bottomSheetState -> + if (bottomSheetState == BottomSheetState.EXPANDED) { + analyticsEventHandler.send(MarketsListAnalyticsEvent.BottomSheetOpened()) + } + }.launchIn(modelScope) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt index 6780d5a73f..063129a71e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt @@ -18,12 +18,12 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.promo.PromoRepository import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent import com.tangem.features.feed.model.market.list.analytics.MarketsListAnalyticsEvent -import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager -import com.tangem.features.feed.model.market.list.statemanager.MarketsListUMStateManager import com.tangem.features.feed.model.market.list.state.ListUM import com.tangem.features.feed.model.market.list.state.MarketsListUM import com.tangem.features.feed.model.market.list.state.MarketsNotificationUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager +import com.tangem.features.feed.model.market.list.statemanager.MarketsListUMStateManager import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder @@ -263,18 +263,6 @@ internal class MarketsListModel @Inject constructor( } } - searchMarketsListManager - .isSearchNotFoundState - .onEach { isTokenFound -> - if (isTokenFound) { - analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(isTokenFound = false)) - } - }.launchIn(modelScope) - - searchMarketsListManager.onFirstBatchLoadedSuccess.onEach { - analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(isTokenFound = true)) - }.launchIn(modelScope) - // analytics initAnalytics() @@ -291,13 +279,19 @@ internal class MarketsListModel @Inject constructor( } private fun initAnalytics() { - containerBottomSheetState.onEach { bottomSheetState -> - if (bottomSheetState == BottomSheetState.EXPANDED) { - analyticsEventHandler.send(MarketsListAnalyticsEvent.BottomSheetOpened()) - } + searchMarketsListManager + .isSearchNotFoundState + .onEach { isTokenFound -> + if (isTokenFound) { + analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(isTokenFound = false)) + } + }.launchIn(modelScope) + + searchMarketsListManager.onFirstBatchLoadedSuccess.onEach { + analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(isTokenFound = true)) }.launchIn(modelScope) - state.filter { it.isInSearchMode.not() } + state .map { MarketsListAnalyticsEvent.SortBy(it.selectedSortBy, it.selectedInterval) }.distinctUntilChanged() .onEach { analyticsEventHandler.send(it) From f69235c0d7d564c44cb8627a63f075c7e364e1ea Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 13 Jan 2026 06:31:59 +0200 Subject: [PATCH 081/172] Updated on 2026-08-14 --- .../tap/di/domain/MarketsDomainModule.kt | 2 +- common/ui/detekt-baseline-debug.xml | 9 -- core/datasource/detekt-baseline-debug.xml | 1 - core/ui/detekt-baseline-debug.xml | 7 - data/markets/detekt-baseline-debug.xml | 16 -- .../markets/DefaultMarketsTokenRepository.kt | 10 +- .../data/markets/MarketsBatchUpdateFetcher.kt | 18 +-- .../analytics/MarketsDataAnalyticsEvent.kt | 4 +- .../converters/TokenMarketInfoConverter.kt | 12 +- .../converters/TokenMarketListConverter.kt | 2 +- data/wallet-connect/detekt-baseline-debug.xml | 1 - detekt_baseline_report.txt | 42 +++-- domain/markets/detekt-baseline-debug.xml | 11 -- .../markets/models/detekt-baseline-main.xml | 7 - .../tangem/domain/markets/TokenMarketInfo.kt | 2 +- ...FilterAvailableNetworksForWalletUseCase.kt | 8 +- domain/staking/detekt-baseline-debug.xml | 1 - domain/tokens/detekt-baseline-debug.xml | 2 - domain/wallets/detekt-baseline-debug.xml | 1 - .../hot-wallet/impl/detekt-baseline-debug.xml | 1 - .../markets/impl/detekt-baseline-debug.xml | 153 ------------------ .../DefaultMarketsTokenDetailsComponent.kt | 8 +- .../analytics/MarketDetailsAnalyticsEvent.kt | 2 +- .../impl/model/MarketsTokenDetailsModel.kt | 126 +++++++-------- .../model/converters/DescriptionConverter.kt | 41 +++-- .../converters/PricePerformanceConverter.kt | 8 +- .../converters/SecurityScoreConverter.kt | 13 +- .../converters/TokenMarketInfoConverter.kt | 4 +- .../impl/model/formatter/Formatters.kt | 2 +- .../impl/model/state/QuotesStateUpdater.kt | 2 +- .../impl/ui/MarketsTokenDetailsContent.kt | 14 +- .../impl/ui/components/InsightsBlock.kt | 14 +- .../details/impl/ui/components/LinksBlock.kt | 8 +- .../ui/components/MarketTokenDetailsChart.kt | 4 +- .../impl/ui/components/MetricsBlock.kt | 6 +- .../ui/components/PricePerformanceBlock.kt | 10 +- .../ui/components/SecurityScoreBottomSheet.kt | 8 +- .../ui/preview/MarketsTokenDetailsPreview.kt | 4 +- .../impl/ui/state/MarketsTokenDetailsUM.kt | 2 +- .../entry/impl/ui/EntryBottomSheetContent.kt | 13 +- .../portfolio/add/api/AvailableToAddData.kt | 6 +- .../impl/DefaultAddToPortfolioComponent.kt | 4 +- .../converter/AvailableToAddDataConverter.kt | 15 +- .../add/impl/model/AddToPortfolioModel.kt | 23 +-- .../portfolio/add/impl/model/AddTokenModel.kt | 4 +- .../add/impl/model/AddTokenUiBuilder.kt | 4 +- .../add/impl/model/ChooseNetworkModel.kt | 6 +- .../impl/ui/DefaultAddToPortfolioManager.kt | 2 +- .../add/impl/ui/TokenActionsContent.kt | 18 +-- .../impl/DefaultMarketsPortfolioComponent.kt | 4 +- .../impl/analytics/PortfolioAnalyticsEvent.kt | 4 +- .../impl/loader/PortfolioDataLoader.kt | 12 +- .../model/AddToPortfolioBSContentUMFactory.kt | 14 +- .../impl/model/AddToPortfolioManager.kt | 4 +- .../impl/model/MarketsPortfolioModel.kt | 52 +++--- .../impl/model/MyPortfolioUMFactory.kt | 4 +- .../impl/model/NewMarketsPortfolioDelegate.kt | 19 ++- .../impl/model/PortfolioBSVisibilityModel.kt | 8 +- .../impl/model/PortfolioTokenUMConverter.kt | 8 +- .../portfolio/impl/model/PortfolioUIData.kt | 4 +- .../impl/model/TokenActionsHandler.kt | 8 +- .../impl/model/TokensPortfolioUMConverter.kt | 8 +- .../impl/ui/AddToPortfolioBottomSheet.kt | 12 +- .../portfolio/impl/ui/PortfolioItem.kt | 16 +- .../impl/ui/PortfolioQuickActions.kt | 11 +- .../PreviewAddToPortfolioBSContentProvider.kt | 4 +- .../preview/PreviewMyPortfolioUMProvider.kt | 140 ++++++++-------- .../ui/state/AddToPortfolioBSContentUM.kt | 2 +- .../portfolio/impl/ui/state/QuickActionUM.kt | 6 +- .../block/impl/model/TokenMarketBlockModel.kt | 4 +- .../impl/DefaultMarketsTokenListComponent.kt | 4 +- .../analytics/MarketsListAnalyticsEvent.kt | 6 +- .../tokenlist/impl/model/MarketsListModel.kt | 42 +++-- .../MarketsListBatchFlowManager.kt | 53 +++--- .../statemanager/MarketsListUMStateManager.kt | 136 ++++++++-------- .../markets/tokenlist/impl/ui/MarketsList.kt | 20 +-- .../impl/ui/components/MarketsListItem.kt | 6 +- .../ui/components/MarketsListLazyColumn.kt | 8 +- .../impl/ui/state/MarketsListItemUM.kt | 2 +- .../tokenlist/impl/ui/state/MarketsListUM.kt | 4 +- features/nft/impl/detekt-baseline-debug.xml | 1 - .../staking/impl/detekt-baseline-debug.xml | 11 -- .../tester/impl/detekt-baseline-debug.xml | 1 - .../wallet/impl/detekt-baseline-debug.xml | 6 - .../welcome/impl/detekt-baseline-debug.xml | 1 - 85 files changed, 564 insertions(+), 762 deletions(-) delete mode 100644 data/markets/detekt-baseline-debug.xml delete mode 100644 domain/markets/detekt-baseline-debug.xml delete mode 100644 domain/markets/models/detekt-baseline-main.xml delete mode 100644 features/markets/impl/detekt-baseline-debug.xml diff --git a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt index 6e154f3fc1..e56953ffdc 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt @@ -115,7 +115,7 @@ object MarketsDomainModule { return FilterAvailableNetworksForWalletUseCase( userWalletsListManager = userWalletsListManager, userWalletsListRepository = userWalletsListRepository, - useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled, + shouldUseNewRepository = hotWalletFeatureToggles.isHotWalletEnabled, excludedBlockchains = excludedBlockchains, ) } diff --git a/common/ui/detekt-baseline-debug.xml b/common/ui/detekt-baseline-debug.xml index 13ac4c3486..312ce47852 100644 --- a/common/ui/detekt-baseline-debug.xml +++ b/common/ui/detekt-baseline-debug.xml @@ -9,20 +9,11 @@ BooleanPropertyNaming:NotificationUM.kt$NotificationUM.Error.ExceedsBalance$val mergeFeeNetworkName: Boolean = false BooleanPropertyNaming:NotificationsFactory.kt$NotificationsFactory$val showNotification = sendingAmount + feeAmount > balance - minimumRequirement.orZero() BooleanPropertyNaming:TokenReceiveBottomSheetConfig.kt$TokenReceiveBottomSheetConfig$val showMemoDisclaimer: Boolean - CanBeNonNullable:AmountBlockV2.kt$onClick: (() -> Unit)? = null - CanBeNonNullable:NavigationButtonsBlock.kt$footerText: TextReference? - CanBeNonNullable:NavigationButtonsBlock.kt$pairButtons: Pair<NavigationButton, NavigationButton>? - CanBeNonNullable:NavigationButtonsBlock.kt$prevButton: NavigationButton? - CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$dustValue: BigDecimal? - CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$minAdaValue: BigDecimal? - CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$minimumSendAmount: BigDecimal? - CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$rentWarning: CryptoCurrencyWarning.Rent? MultilineLambdaItParameter:ExpressStatusItems.kt${ val itemInfo = expressTxs[it].info val (iconRes, tint) = when (itemInfo.iconState) { ExpressTransactionStateIconUM.Warning -> { R.drawable.ic_alert_triangle_20 to TangemTheme.colors.icon.attention } ExpressTransactionStateIconUM.Error -> { R.drawable.ic_alert_circle_24 to TangemTheme.colors.icon.warning } ExpressTransactionStateIconUM.None -> null to null } ExpressStatusItem( title = itemInfo.title, fromTokenIconState = itemInfo.fromCurrencyIcon, toTokenIconState = itemInfo.toCurrencyIcon, fromAmount = itemInfo.fromAmount, fromSymbol = itemInfo.fromAmountSymbol, toAmount = itemInfo.toAmount, toSymbol = itemInfo.toAmountSymbol, onClick = itemInfo.onClick, infoIconRes = iconRes, infoIconTint = tint, modifier = modifier.animateItem(), ) } MultilineLambdaItParameter:TokenItemStateConverter.kt$TokenItemStateConverter.Companion${ it.key.equals( other = token.yieldSupplyKey(), ignoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId), ) } NoNameShadowing:NavigationButtonsBlock.kt$navigationUM NoNameShadowing:UserWalletItem.kt$balance NullableBooleanCheck:TokenItemStateConverter.kt$TokenItemStateConverter.Companion$cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive ?: false - NullableToStringCall:TokenItemStateConverter.kt$TokenItemStateConverter.Companion$${id.rawCurrencyId} ReusedModifierInstance:AddTokenContent.kt$AddButton( modifier = modifier.fillMaxWidth(), state = state.button, ) UnnecessaryEventHandlerParameter:SendDoneButtons.kt$onShareClick: (String) -> Unit UnnecessaryLet:TokenItemStateConverter.kt$TokenItemStateConverter.Companion$let(::add) diff --git a/core/datasource/detekt-baseline-debug.xml b/core/datasource/detekt-baseline-debug.xml index 7d14c576a1..08b13f32ec 100644 --- a/core/datasource/detekt-baseline-debug.xml +++ b/core/datasource/detekt-baseline-debug.xml @@ -12,6 +12,5 @@ NestedScopeFunctions:RetrofitApiBuilder.kt$RetrofitApiBuilder$let { withWriteTimeout(timeout = it.duration, unit = it.unit) } UnreachableCode:MockApiConfigsManager.kt$MockApiConfigsManager$apiConfigs + (apiConfig to environment) UnreachableCode:MockApiConfigsManager.kt$MockApiConfigsManager$val apiConfig = apiConfigs.keys.firstOrNull { it.id.name == id } ?: error("Api config with id [$id] not found. Check that ApiConfig with id [$id] was provided into DI") - UnusedImports:NetworkModule.kt$import com.tangem.datasource.api.common.config.MoonPay diff --git a/core/ui/detekt-baseline-debug.xml b/core/ui/detekt-baseline-debug.xml index 662bc3318f..9cd3507ca1 100644 --- a/core/ui/detekt-baseline-debug.xml +++ b/core/ui/detekt-baseline-debug.xml @@ -5,12 +5,6 @@ BooleanPropertyNaming:BigDecimalPercentFormat.kt$BigDecimalPercentFormat$val withPercentSign: Boolean = true BooleanPropertyNaming:FullScreen.kt$FullScreenLayout$private val focusable: Boolean BooleanPropertyNaming:TokenItemState.kt$TokenItemState.TitleState.Content$val earnApyIsActive: Boolean = false - CanBeNonNullable:FooterContainer.kt$footer: TextReference? = null - CanBeNonNullable:InputRowImageBase.kt$iconRes: Int? - CanBeNonNullable:InputRowImageInfo.kt$subtitleEndIconRes: Int? - CanBeNonNullable:SearchBar.kt$keyboardController: SoftwareKeyboardController? - CanBeNonNullable:TextFields.kt$caption: String? = null - CanBeNonNullable:TransactionList.kt$txHistoryItems: LazyPagingItems<TxHistoryState.TxHistoryItemState>? CastNullableToNonNullableType:DialogFullScreen.kt$as CastNullableToNonNullableType:FullScreen.kt$FullScreenLayout$as MultilineLambdaItParameter:Actions.kt${ ActionButtonContent( config = config, text = { textColor -> Text(text = config.text, textColor = textColor) }, modifier = it.padding( start = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing24, ), ) } @@ -27,6 +21,5 @@ ReusedModifierInstance:TangemRadioButton.kt$AnimatedVisibility( visible = isSelected, label = "Radio button animation", modifier = modifier .size(TangemTheme.dimens.size24), ) { Icon( painter = painterResource(id = R.drawable.ic_check_circle_24), contentDescription = null, tint = TangemTheme.colors.control.checked, ) } ReusedModifierInstance:TokenPrice.kt$Icon( modifier = modifier, painter = painterResource( id = when (animatedType) { PriceChangeType.UP -> R.drawable.ic_arrow_up_8 PriceChangeType.DOWN -> R.drawable.ic_arrow_down_8 PriceChangeType.NEUTRAL -> R.drawable.ic_elipse_8 }, ), tint = when (animatedType) { PriceChangeType.UP -> TangemTheme.colors.icon.accent PriceChangeType.DOWN -> TangemTheme.colors.icon.warning PriceChangeType.NEUTRAL -> TangemTheme.colors.icon.inactive }, contentDescription = null, ) UnnecessaryEventHandlerParameter:PinTextField.kt$onValueChange: (String) -> Unit - UnnecessaryEventHandlerParameter:ResizableText.kt$onFontSizeChange: (Float) -> Unit diff --git a/data/markets/detekt-baseline-debug.xml b/data/markets/detekt-baseline-debug.xml deleted file mode 100644 index fd96850854..0000000000 --- a/data/markets/detekt-baseline-debug.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - BooleanPropertyNaming:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository.<no name provided>$val last = res.tokens.size < request.limit - MultilineLambdaItParameter:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository${ val error = it as QuotesFetcher.Error.ApiOperationError val errorEvent = createDetailsErrorEvent( error = error.apiError, request = MarketsDataAnalyticsEvent.Details.Error.Request.Info, tokenSymbol = tokenSymbol, ) analyticsEventHandler.send(errorEvent.toEvent()) throw error.apiError } - MultilineLambdaItParameter:MarketsBatchUpdateFetcher.kt$MarketsBatchUpdateFetcher${ it.copy( tokenCharts = TokenMarketChartsConverter.convert( chartsToCopy = it.tokenCharts, tokenId = it.id, interval = updateRequest.interval, value = update, ), ) } - MultilineLambdaItParameter:MarketsBatchUpdateFetcher.kt$MarketsBatchUpdateFetcher${ val exception = if (it is QuotesFetcher.Error.ApiOperationError) { onApiResponseError(it.apiError) it.apiError } else { error("Cause: $it") } throw exception } - MultilineLambdaItParameter:TokenMarketInfoConverter.kt$TokenMarketInfoConverter${ TokenMarketInfo.Link( title = it.title, id = it.id, link = it.link, ) } - NullableBooleanCheck:TokenMarketListConverter.kt$TokenMarketListConverter$token.isUnderMarketCapLimit ?: false - SuspendFunWithFlowReturnType:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository$suspend - UnsafeCallOnNullableType:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository$network.contractAddress!! - UseEmptyCounterpart:MarketsDataAnalyticsEvent.kt$MarketsDataAnalyticsEvent.Details$mapOf() - UseEmptyCounterpart:MarketsDataAnalyticsEvent.kt$MarketsDataAnalyticsEvent.List$mapOf() - - diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index 2f974d4f31..d6df4d9333 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt @@ -90,13 +90,13 @@ internal class DefaultMarketsTokenRepository( requestTimeStamp.set(res.timestamp ?: 0) } - val last = res.tokens.size < request.limit + val isLast = res.tokens.size < request.limit val tokenMarketListWithMaxApy = TokenMarketListConverter.convert(res) return BatchFetchResult.Success( data = tokenMarketListWithMaxApy.tokens, - last = last, + last = isLast, empty = res.tokens.isEmpty(), ) } @@ -227,8 +227,8 @@ internal class DefaultMarketsTokenRepository( currencyId = tokenId.value, field = QuotesFetcher.Field.ALL_PRICES, ) - .getOrElse { - val error = it as QuotesFetcher.Error.ApiOperationError + .getOrElse { fetchError -> + val error = fetchError as QuotesFetcher.Error.ApiOperationError val errorEvent = createDetailsErrorEvent( error = error.apiError, @@ -274,7 +274,7 @@ internal class DefaultMarketsTokenRepository( name = token.name, symbol = token.symbol, decimals = network.decimalCount ?: error("Unknown decimal"), - contractAddress = network.contractAddress!!, + contractAddress = requireNotNull(network.contractAddress) { "Contract address is required for token" }, ) } } diff --git a/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt b/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt index 82ab261728..11a6852e47 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt @@ -79,13 +79,13 @@ internal class MarketsBatchUpdateFetcher( currenciesIds = currenciesIds, fields = setOf(QuotesFetcher.Field.ALL_PRICES), ) - .getOrElse { - val exception = if (it is QuotesFetcher.Error.ApiOperationError) { - onApiResponseError(it.apiError) + .getOrElse { error -> + val exception = if (error is QuotesFetcher.Error.ApiOperationError) { + onApiResponseError(error.apiError) - it.apiError + error.apiError } else { - error("Cause: $it") + error("Cause: $error") } throw exception @@ -116,11 +116,11 @@ internal class MarketsBatchUpdateFetcher( Batch( key = batchToUpdate.key, - data = batchToUpdate.data.map { - it.copy( + data = batchToUpdate.data.map { tokenMarket -> + tokenMarket.copy( tokenCharts = TokenMarketChartsConverter.convert( - chartsToCopy = it.tokenCharts, - tokenId = it.id, + chartsToCopy = tokenMarket.tokenCharts, + tokenId = tokenMarket.id, interval = updateRequest.interval, value = update, ), diff --git a/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt b/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt index 5d04e7511f..dc94cd9ceb 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt @@ -7,7 +7,7 @@ sealed interface MarketsDataAnalyticsEvent { sealed class List( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent(category = "Markets", event = event, params = params), MarketsDataAnalyticsEvent { data class Error( @@ -26,7 +26,7 @@ sealed interface MarketsDataAnalyticsEvent { sealed class Details( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params), MarketsDataAnalyticsEvent { data class Error( diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt index a00841547c..a1044051b9 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt @@ -62,7 +62,7 @@ internal class TokenMarketInfoConverter( network.contractAddress.isNullOrEmpty() -> { TokenMarketInfo.Network( networkId = network.networkId, - exchangeable = network.exchangeable, + isExchangeable = network.exchangeable, contractAddress = network.contractAddress, decimalCount = network.decimalCount, ) @@ -71,7 +71,7 @@ internal class TokenMarketInfoConverter( blockchain.canHandleTokens() -> { TokenMarketInfo.Network( networkId = network.networkId, - exchangeable = network.exchangeable, + isExchangeable = network.exchangeable, contractAddress = blockchain.reformatContractAddress(network.contractAddress), decimalCount = network.decimalCount, ) @@ -147,11 +147,11 @@ internal class TokenMarketInfoConverter( @JvmName("convertLink") private fun List.convert(): List { - return map { + return map { link -> TokenMarketInfo.Link( - title = it.title, - id = it.id, - link = it.link, + title = link.title, + id = link.id, + link = link.link, ) } } diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt index ed6cb59dc4..f43e7ddae1 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt @@ -31,7 +31,7 @@ internal object TokenMarketListConverter : ConverterMultilineLambdaItParameter:WcEthMessageSignUseCase.kt$LegacySdkHelper${ val char = it.toInt().toChar() if (char.isAscii()) char else return null } MultilineLambdaItParameter:WcEthNetwork.kt$WcEthNetwork${ if (this == WcEthMethodName.AddEthereumChain) { WcEthMethod.AddEthereumChain(rawChain = it).right() } else { WcEthMethod.SwitchEthereumChain(rawChain = it).right() } } MultilineLambdaItParameter:WcEthNetwork.kt$WcEthNetwork${ if (this == WcEthMethodName.SignTransaction) { WcEthMethod.SignTransaction(transaction = it).right() } else { WcEthMethod.SendTransaction(transaction = it).right() } } - MultilineLambdaItParameter:WcNetworksConverter.kt$WcNetworksConverter${ val walletAddress = walletManagersFacade.getDefaultAddress(wallet.walletId, it) walletAddress?.lowercase() == caip10.accountAddress.lowercase() } MultilineLambdaItParameter:WcPairSdkDelegate.kt$WcPairSdkDelegate${ proposalCallback.cancel() return@coroutineScope it.left() } MultilineLambdaItParameter:WcPairSdkDelegate.kt$WcPairSdkDelegate${ store.removePendingApproval(forSave) it.left() } MultilineLambdaItParameter:WcSdkSessionConverter.kt$WcSdkSessionConverter${ WcAppMetaDataConverter.convert( value = WcAppMetaDataConverter.Input( originUrl = value.originUrl, peerMetaData = it, ), ) } diff --git a/detekt_baseline_report.txt b/detekt_baseline_report.txt index d36313f946..6637a2e509 100644 --- a/detekt_baseline_report.txt +++ b/detekt_baseline_report.txt @@ -1,7 +1,7 @@ ========================================== Detekt Baseline Updater & Issue Counter ========================================== -Date: 2025-12-13 00:34:09 +Date: 2026-01-04 19:04:51 Step 1: Running detekt to check for new issues... @@ -17,13 +17,13 @@ Counting issues in baseline files... ========================================== Summary: - Total Issues: 1271 - Modules with Issues: 69 - Average Issues per Module: 18 + Total Issues: 1065 + Modules with Issues: 65 + Average Issues per Module: 16 Progress: - Fixed: 662 out of 1933 (34%) - Remaining: 1271 + Fixed: 868 out of 1933 (44%) + Remaining: 1065 ========================================== All Modules with Issues (sorted by count) @@ -31,54 +31,51 @@ All Modules with Issues (sorted by count) Module Issues ──────────────────────────────────────────────────────────────── -features/markets/impl 147 -features/wallet/impl 144 +features/wallet/impl 138 features/onboarding-v2/impl 128 -data/wallet-connect 54 -features/hot-wallet/impl 51 +data/wallet-connect 53 +features/hot-wallet/impl 50 features/walletconnect/impl 47 -features/staking/impl 46 features/tokendetails/impl 45 features/manage-tokens/impl 44 -domain/wallets 37 -features/nft/impl 34 -features/tester/impl 28 -core/ui 26 -domain/tokens 25 -common/ui 24 +domain/wallets 36 +features/staking/impl 35 +features/nft/impl 33 +features/tester/impl 27 +domain/tokens 23 features/yield-supply/impl 21 features/tangempay/details/impl 21 domain/models 21 data/visa 21 data/nft 19 +core/ui 19 core/pagination 16 +common/ui 15 domain/staking/models 13 data/wallets 13 features/token-recieve/impl 11 features/qr-scanning/impl 11 data/onramp 11 -core/datasource 11 core/analytics/models 11 -data/markets 10 +core/datasource 10 features/details/impl 9 domain/account/status 9 data/yield-supply 9 data/networks 9 domain/visa/models 8 domain/transaction 8 -domain/staking 8 core/utils 8 libs/tangem-sdk-api 7 features/referral/impl 7 domain/tokens/models 7 +domain/staking 7 data/txhistory 7 -features/welcome/impl 6 domain/wallet-connect/models 6 domain/onramp 6 domain/account 6 libs/visa 5 +features/welcome/impl 5 features/home/impl 5 -domain/markets 5 domain/core 5 data/account 5 domain/onramp/models 4 @@ -97,7 +94,6 @@ domain/transaction/models 1 domain/quotes 1 domain/promo 1 domain/onboarding 1 -domain/markets/models 1 domain/feedback/models 1 domain/express/models 1 ──────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/domain/markets/detekt-baseline-debug.xml b/domain/markets/detekt-baseline-debug.xml deleted file mode 100644 index 64d62630c1..0000000000 --- a/domain/markets/detekt-baseline-debug.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - BooleanPropertyNaming:FilterAvailableNetworksForWalletUseCase.kt$FilterAvailableNetworksForWalletUseCase$private val useNewRepository: Boolean - BooleanPropertyNaming:GetStakingNotificationMaxApyUseCase.kt$GetStakingNotificationMaxApyUseCase$val showStakingNotification = if (!hideClicked && walletFirstUsageDate != 0L) { currentDate - walletFirstUsageDate > TWO_WEEKS_IN_MILLIS } else { false } - MultilineLambdaItParameter:FilterAvailableNetworksForWalletUseCase.kt$FilterAvailableNetworksForWalletUseCase${ val blockchain = Blockchain.fromNetworkId(it.networkId) supportedBlockchains.contains(blockchain) } - SuspendFunWithFlowReturnType:GetStakingNotificationMaxApyUseCase.kt$GetStakingNotificationMaxApyUseCase$suspend - SuspendFunWithFlowReturnType:MarketsTokenRepository.kt$MarketsTokenRepository$suspend - - diff --git a/domain/markets/models/detekt-baseline-main.xml b/domain/markets/models/detekt-baseline-main.xml deleted file mode 100644 index 17228539ba..0000000000 --- a/domain/markets/models/detekt-baseline-main.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - BooleanPropertyNaming:TokenMarketInfo.kt$TokenMarketInfo.Network$val exchangeable: Boolean - - diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt index 5354432e4c..bbd7a840ce 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt @@ -23,7 +23,7 @@ data class TokenMarketInfo( @Serializable data class Network( val networkId: String, - val exchangeable: Boolean, + val isExchangeable: Boolean, val contractAddress: String?, val decimalCount: Int?, ) diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/FilterAvailableNetworksForWalletUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/FilterAvailableNetworksForWalletUseCase.kt index 467ab832d1..aaf389d312 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/FilterAvailableNetworksForWalletUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/FilterAvailableNetworksForWalletUseCase.kt @@ -12,7 +12,7 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager class FilterAvailableNetworksForWalletUseCase( private val userWalletsListManager: UserWalletsListManager, private val userWalletsListRepository: UserWalletsListRepository, - private val useNewRepository: Boolean, + private val shouldUseNewRepository: Boolean, private val excludedBlockchains: ExcludedBlockchains, ) { @@ -32,13 +32,13 @@ class FilterAvailableNetworksForWalletUseCase( excludedBlockchains = excludedBlockchains, ) - return networks.filter { - val blockchain = Blockchain.fromNetworkId(it.networkId) + return networks.filter { network -> + val blockchain = Blockchain.fromNetworkId(network.networkId) supportedBlockchains.contains(blockchain) }.toSet() } - private fun getWallets() = if (useNewRepository) { + private fun getWallets() = if (shouldUseNewRepository) { userWalletsListRepository.requireUserWalletsSync() } else { userWalletsListManager.userWalletsSync diff --git a/domain/staking/detekt-baseline-debug.xml b/domain/staking/detekt-baseline-debug.xml index ced29d17bf..f52428665c 100644 --- a/domain/staking/detekt-baseline-debug.xml +++ b/domain/staking/detekt-baseline-debug.xml @@ -5,7 +5,6 @@ MultilineLambdaItParameter:FetchStakingYieldBalanceUseCase.kt$FetchStakingYieldBalanceUseCase${ when (it) { is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$it")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } return@either } MultilineLambdaItParameter:InvalidatePendingTransactionsUseCase.kt$InvalidatePendingTransactionsUseCase${ !it.isPending && action.amount < it.amount && it.type == BalanceType.STAKED && it.validatorAddress == action.validatorAddress } NamedArguments:GetConstructedStakingTransactionUseCase.kt$GetConstructedStakingTransactionUseCase$constructTransaction(networkId, fee, amount, transactionId) - NullableToStringCall:StakingApyFlowUseCase.kt$StakingApyFlowUseCase$${yield.token.coinGeckoId} UnnecessaryAbstractClass:MultiStakingBalanceSupplier.kt$MultiStakingBalanceSupplier$MultiStakingBalanceSupplier UnnecessaryAbstractClass:SingleStakingBalanceSupplier.kt$SingleStakingBalanceSupplier$SingleStakingBalanceSupplier UseEmptyCounterpart:StakingAnalyticsEvent.kt$StakingAnalyticsEvent$mapOf() diff --git a/domain/tokens/detekt-baseline-debug.xml b/domain/tokens/detekt-baseline-debug.xml index 34d0f3eeb3..352d25c614 100644 --- a/domain/tokens/detekt-baseline-debug.xml +++ b/domain/tokens/detekt-baseline-debug.xml @@ -11,14 +11,12 @@ MultilineLambdaItParameter:FetchCurrencyStatusUseCase.kt$FetchCurrencyStatusUseCase${ when (it) { is StakingIdFactory.Error.UnableToGetAddress -> raise(IllegalStateException("$it")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } return@either } MultilineLambdaItParameter:GetBalanceNotEnoughForFeeWarningUseCase.kt$GetBalanceNotEnoughForFeeWarningUseCase${ it is CryptoCurrency.Token && it.contractAddress.equals(feePaidToken.contractAddress, ignoreCase = true) && it.network.derivationPath == tokenStatus.currency.network.derivationPath } MultilineLambdaItParameter:GetCryptoCurrencyActionsUseCase.kt$GetCryptoCurrencyActionsUseCase${ TokenActionsState( walletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus, states = it.toList(), ) } - MultilineLambdaItParameter:GetCryptoCurrencyActionsUseCase.kt$GetCryptoCurrencyActionsUseCase${ outdatedDataActionsFactory.create( userWallet = userWallet, cryptoCurrencyStatus = cryptoCurrencyStatus, stakingAvailability = it, ) } MultilineLambdaItParameter:GetCurrencyWarningsUseCase.kt$GetCurrencyWarningsUseCase${ if (isNeedToCreateAccountWithoutReserve(networkId = currencyStatus.currency.network.rawId)) { CryptoCurrencyWarning.TopUpWithoutReserve } else { CryptoCurrencyWarning.SomeNetworksNoAccount( amountToCreateAccount = it.amountToCreateAccount, amountCurrency = currencyStatus.currency, ) } } MultilineLambdaItParameter:GetCurrencyWarningsUseCase.kt$GetCurrencyWarningsUseCase${ it is CryptoCurrency.Token && it.contractAddress.equals(feePaidToken.contractAddress, ignoreCase = true) && it.network.derivationPath == tokenStatus.currency.network.derivationPath } MultilineLambdaItParameter:GetWalletTotalBalanceUseCase.kt$GetWalletTotalBalanceUseCase${ Timber.e("failed to load balances with error: $it") TotalFiatBalance.Failed } MultilineLambdaItParameter:PriceChangeCalculator.kt$PriceChangeCalculator${ val weight = it.value.fiatAmount.orZero().divide(balance, 2, RoundingMode.HALF_UP) val priceChange = it.value.priceChange.orZero() weight * priceChange } MultilineLambdaItParameter:WalletBalanceFetcher.kt$WalletBalanceFetcher${ val stakingId = stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it) if (stakingId.isLeft { it is StakingIdFactory.Error.UnableToGetAddress }) { Timber.e("Unable to get staking ID for user wallet $userWalletId and currency ${it.id}") } stakingId } NamedArguments:ApplyTokenListSortingUseCase.kt$ApplyTokenListSortingUseCase$saveTokens(userWalletId, currencies, isGrouped, isSortedByBalance) - NamedArguments:GetCurrencyWarningsUseCase.kt$GetCurrencyWarningsUseCase$combine( getCoinRelatedWarnings( userWalletId = userWalletId, networkId = currency.network.id, currencyId = currency.id, derivationPath = derivationPath, isSingleWalletWithTokens = isSingleWalletWithTokens, ), flowOf(currencyChecksRepository.getRentInfoWarning(userWalletId, currencyStatus)), flowOf(currencyChecksRepository.getExistentialDeposit(userWalletId, currency.network)), flowOf(currencyChecksRepository.getFeeResourceAmount(userWalletId, currency.network)), ) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeFeeResource -> setOfNotNull( maybeRentWarning, maybeEdWarning?.let { getExistentialDepositWarning(currency, it) }, maybeFeeResource?.let { getFeeResourceWarning(it) }, * coinRelatedWarnings.toTypedArray(), getNetworkUnavailableWarning(currencyStatus), getNetworkNoAccountWarning(currencyStatus), getBeaconChainShutdownWarning(rawId = currency.network.id.rawId), getAssetRequirementsWarning(userWalletId = userWalletId, currency = currency), getMigrationFromMaticToPolWarning(currency), ) } NoNameShadowing:WalletBalanceFetcher.kt$WalletBalanceFetcher${ it is StakingIdFactory.Error.UnableToGetAddress } NullableBooleanCheck:GetCurrencyCheckUseCase.kt$GetCurrencyCheckUseCase$recipientAddress?.let { currencyChecksRepository.checkIfAccountFunded( userWalletId, network, recipientAddress, ) } ?: false SuspendFunWithFlowReturnType:BaseCurrencyStatusOperations.kt$BaseCurrencyStatusOperations$suspend diff --git a/domain/wallets/detekt-baseline-debug.xml b/domain/wallets/detekt-baseline-debug.xml index b55688a97f..cbda6c3779 100644 --- a/domain/wallets/detekt-baseline-debug.xml +++ b/domain/wallets/detekt-baseline-debug.xml @@ -28,7 +28,6 @@ MultilineLambdaItParameter:UpdateWalletUseCase.kt$UpdateWalletUseCase${ when (it) { is SaveWalletError.DataError -> DataError( IllegalStateException("Failed to update wallet: ${it.messageId}"), ) is SaveWalletError.WalletAlreadySaved -> UpdateWalletError.NameAlreadyExists } } NestedScopeFunctions:ColdUserWalletBuilder.kt$ColdUserWalletBuilder$let { UserWallet.Cold( walletId = it, name = generateWalletNameUseCase( card = card, productType = productType, isStartToCoin = cardTypesResolver.isStart2Coin(), ), cardsInWallet = backupCardsIds.plus(card.cardId), scanResponse = this, isMultiCurrency = cardTypesResolver.isMultiwalletAllowed(), hasBackupError = hasBackupError, ) } NoNameShadowing:SaveWalletUseCase.kt$SaveWalletUseCase$userWallet - NullableToStringCall:GenerateBuyTangemCardLinkUseCase.kt$GenerateBuyTangemCardLinkUseCase$$id NullableToStringCall:UpdateWalletUseCase.kt$UpdateWalletUseCase$${it.messageId} ObjectExtendsThrowable:UserWalletsListError.kt$UserWalletsListError$AllKeysInvalidated : UserWalletsListError ObjectExtendsThrowable:UserWalletsListError.kt$UserWalletsListError$BiometricsAuthenticationDisabled : UserWalletsListError diff --git a/features/hot-wallet/impl/detekt-baseline-debug.xml b/features/hot-wallet/impl/detekt-baseline-debug.xml index 2e3bf90a57..0695c0d515 100644 --- a/features/hot-wallet/impl/detekt-baseline-debug.xml +++ b/features/hot-wallet/impl/detekt-baseline-debug.xml @@ -20,7 +20,6 @@ BooleanPropertyNaming:WalletHardwareBackupUM.kt$WalletHardwareBackupUM$val showPurchaseBlock: Boolean = false MultilineLambdaItParameter:AddExistingWalletImportModel.kt$AddExistingWalletImportModel${ Timber.e(it) setImportProgress(false) } MultilineLambdaItParameter:AddExistingWalletImportModel.kt$AddExistingWalletImportModel${ setImportProgress(false) when (it) { is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet") is SaveWalletError.WalletAlreadySaved -> { uiMessageSender.send( SnackbarMessage(resourceReference(R.string.hw_import_seed_phrase_already_imported)), ) } } } - MultilineLambdaItParameter:CreateHardwareWalletModel.kt$CreateHardwareWalletModel${ delay(HIDE_PROGRESS_DELAY) setLoading(false) when (it) { is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet") is SaveWalletError.WalletAlreadySaved -> { userWalletsListRepository.unlock( userWalletId = userWallet.walletId, unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), ).onRight { router.replaceAll(AppRoute.Wallet) } } } } MultilineLambdaItParameter:ForgetWalletModel.kt$ForgetWalletModel${ Timber.e("Unable to delete wallet: $it") uiMessageSender.send( message = SnackbarMessage(resourceReference(R.string.common_unknown_error)), ) return@launch } MultilineLambdaItParameter:ForgetWalletModel.kt$ForgetWalletModel${ val newValue = !it.firstCheckboxChecked it.copy( firstCheckboxChecked = newValue, isForgetButtonEnabled = newValue && it.secondCheckboxChecked, ) } MultilineLambdaItParameter:ForgetWalletModel.kt$ForgetWalletModel${ val newValue = !it.secondCheckboxChecked it.copy( secondCheckboxChecked = newValue, isForgetButtonEnabled = it.firstCheckboxChecked && newValue, ) } diff --git a/features/markets/impl/detekt-baseline-debug.xml b/features/markets/impl/detekt-baseline-debug.xml deleted file mode 100644 index d9fe99460d..0000000000 --- a/features/markets/impl/detekt-baseline-debug.xml +++ /dev/null @@ -1,153 +0,0 @@ - - - - - BooleanPropertyNaming:AddToPortfolioBSContentUM.kt$AddToPortfolioBSContentUM$val continueButtonEnabled: Boolean - BooleanPropertyNaming:AvailableToAddData.kt$AvailableToAddData$val availableToAdd: Boolean get() = availableToAddWallets.isNotEmpty() - BooleanPropertyNaming:AvailableToAddData.kt$SelectedNetwork$val availableMoreNetwork: Boolean - BooleanPropertyNaming:AvailableToAddData.kt$SelectedPortfolio$val availableMorePortfolio: Boolean - BooleanPropertyNaming:DefaultAddToPortfolioComponent.kt$DefaultAddToPortfolioComponent$val scrollableContent = when (stack.active.configuration) { AddToPortfolioRoutes.PortfolioSelector -> false AddToPortfolioRoutes.AddToken, AddToPortfolioRoutes.Empty, is AddToPortfolioRoutes.NetworkSelector, AddToPortfolioRoutes.TokenActions, -> true } - BooleanPropertyNaming:MarketsList.kt$val mainScrolled by remember { derivedStateOf { mainLazyListState.firstVisibleItemScrollOffset > 0 } } - BooleanPropertyNaming:MarketsList.kt$val searchScrolledState by remember { derivedStateOf { searchLazyListState.firstVisibleItemScrollOffset > 0 } } - BooleanPropertyNaming:MarketsListAnalyticsEvent.kt$MarketsListAnalyticsEvent.TokenSearched$val tokenFound: Boolean - BooleanPropertyNaming:MarketsListUM.kt$ListUM.Content$val showUnder100kTokensNotification: Boolean - BooleanPropertyNaming:MarketsListUM.kt$ListUM.Content$val showUnder100kTokensNotificationWasHidden: Boolean - BooleanPropertyNaming:MarketsListUMStateManager.kt$MarketsListUMStateManager$private var sortByBottomSheetIsShown get() = state.value.sortByBottomSheet.isShown set(value) = state.update { it.copy(sortByBottomSheet = it.sortByBottomSheet.copy(isShown = value)) } - BooleanPropertyNaming:MarketsTokenDetailsContent.kt$val showPriceSubtitle by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 1 } } - BooleanPropertyNaming:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel$val allWalletsIsHot = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } - BooleanPropertyNaming:MarketsTokenDetailsUM.kt$MarketsTokenDetailsUM$val markerSet: Boolean - BooleanPropertyNaming:MetricsBlock.kt$var expanded by remember { mutableStateOf(false) } - BooleanPropertyNaming:PortfolioBSVisibilityModel.kt$PortfolioBSVisibilityModel$val addToPortfolioBSVisibility: Boolean = false - BooleanPropertyNaming:PortfolioBSVisibilityModel.kt$PortfolioBSVisibilityModel$val walletSelectorBSVisibility: Boolean = false - BooleanPropertyNaming:PortfolioItem.kt$var quickActionsShown by remember { mutableStateOf(value = false) } - BooleanPropertyNaming:PortfolioUIData.kt$PortfolioUIData$val needColdWalletInteraction: Boolean - BooleanPropertyNaming:QuickActionUM.kt$QuickActionUM$val longClickAvailable: Boolean = false - BooleanPropertyNaming:QuickActionUM.kt$QuickActionUM.Exchange$val showBadge: Boolean - BooleanPropertyNaming:TokenActionsHandler.kt$TokenActionsHandler$val demoCard = isDemoCardUseCase.invoke(userWallet.cardId) - BooleanPropertyNaming:TokenActionsHandler.kt$TokenActionsHandler$val needShowDemoWarning = demoCard && disabledActionsInDemoMode.contains(action) - CanBeNonNullable:TokenMarketDetailsBody.kt$portfolioBlock: @Composable ((Modifier) -> Unit)? - CastNullableToNonNullableType:PortfolioItem.kt$PortfolioTokenUMProvider$as - CastNullableToNonNullableType:PortfolioItem.kt$PortfolioTokenUMProvider.Companion$as - MaxChainedCallsOnSameLine:AddToPortfolioModel.kt$AddToPortfolioModel$selectedPortfolio.first().account.account.account.accountId - MaxChainedCallsOnSameLine:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager$batchFlow.state.value.data.map { it.data }.flatten().find { it.id == id } - MaxChainedCallsOnSameLine:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager$batchFlow.state.value.data.map { it.key }.toSet() - MaxChainedCallsOnSameLine:MarketsListModel.kt$MarketsListModel$marketsListUMStateManager.state.map { it.selectedSortBy }.distinctUntilChanged().drop(1).collectLatest { mainMarketsListManager.reload() } - MaxChainedCallsOnSameLine:MarketsPortfolioModel.kt$MarketsPortfolioModel$handledAction.cryptoCurrencyData.status.currency.network.name - MaxChainedCallsOnSameLine:TokenActionsModel.kt$TokenActionsModel$handledAction.cryptoCurrencyData.status.currency.network.name - MultilineLambdaItParameter:AddToPortfolioBSContentUMFactory.kt$AddToPortfolioBSContentUMFactory${ if (it != selectedWalletId) { onAnotherWalletSelect(it) onWalletSelectorVisibilityChange(false) } } - MultilineLambdaItParameter:AddToPortfolioBottomSheet.kt${ Content( modifier = Modifier.fillMaxWidth(), state = it, ) WalletSelectorBottomSheet(it.walletSelectorConfig) } - MultilineLambdaItParameter:AddToPortfolioManager.kt$AddToPortfolioManager${ it.toMutableMap().apply { this[userWalletId] = if (isAddAction) { this[userWalletId].orEmpty() + network } else { this[userWalletId].orEmpty() - network } } } - MultilineLambdaItParameter:AddToPortfolioModel.kt$AddToPortfolioModel${ Timber.e(it) params.callback.onDismiss() } - MultilineLambdaItParameter:AddToPortfolioModel.kt$AddToPortfolioModel${ tokenActionsData.emit(it) navigation.replaceAll(AddToPortfolioRoutes.TokenActions) } - MultilineLambdaItParameter:AddTokenModel.kt$AddTokenModel${ processError(error = it) uiState.value = um.toggleProgress(false) return@launch } - MultilineLambdaItParameter:AvailableToAddDataConverter.kt$AvailableToAddDataConverter${ val (walletId, _) = it val availableToAddWallet = getAvailableToAddWallet(it) walletId to availableToAddWallet } - MultilineLambdaItParameter:ChooseNetworkModel.kt$ChooseNetworkModel${ Timber.e( it, """ Failed to check currency unsupported state |- User wallet ID: $userWalletId |- Network ID: $rawNetworkId |- Is main network: $isMainNetwork """.trimIndent(), ) val message = SnackbarMessage( message = it.localizedMessage?.let(::stringReference) ?: resourceReference(R.string.common_error), ) messageSender.send(message) null } - MultilineLambdaItParameter:DefaultMarketsTokenDetailsComponent.kt$DefaultMarketsTokenDetailsComponent${ when (it) { is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(it.networks) TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable() else -> {} } } - MultilineLambdaItParameter:DefaultMarketsTokenListComponent.kt$DefaultMarketsTokenListComponent${ MarketsListWithBack( modifier = Modifier .statusBarsPadding() .imePadding() .padding(it), state = state, bottomSheetState = BottomSheetState.EXPANDED, onBackClick = router::pop, ) } - MultilineLambdaItParameter:EntryBottomSheetContent.kt${ when (it.configuration) { is MarketsEntryChildFactory.Child.TokenDetails -> { (it.instance as MarketsTokenDetailsComponent).BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, modifier = modifier, ) } is MarketsEntryChildFactory.Child.TokenList -> { (it.instance as MarketsTokenListComponent).BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, modifier = modifier, ) } } } - MultilineLambdaItParameter:InsightsBlock.kt${ InfoPoint( modifier = Modifier.align(Alignment.CenterStart), infoPointUM = it, ) } - MultilineLambdaItParameter:InsightsBlock.kt${ currentInterval = it state.onIntervalChanged(it) } - MultilineLambdaItParameter:LinksBlock.kt${ Chip( text = stringReference(it.title), iconResId = it.iconRes, onClick = { onLinkClick(it) }, ) } - MultilineLambdaItParameter:MarketTokenDetailsChart.kt${ when (it) { MarketChartLook.Type.Growing -> growingColor MarketChartLook.Type.Falling -> fallingColor MarketChartLook.Type.Neutral -> neutralColor } } - MultilineLambdaItParameter:MarketsList.kt${ if (it.size.height > 0) { with(density) { onHeaderSizeChange(it.size.height.toDp()) } } } - MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ Batch( key = it.key, data = converter.convertList(it.data), ) } - MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ currentSearchText().isNullOrEmpty().not() && it.status is PaginationStatus.EndOfPagination && it.data.isEmpty() } - MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ val first = it.data.firstOrNull() ?: return@filter false val chartByInterval = when (interval) { TrendInterval.H24 -> first.tokenCharts.h24 TrendInterval.D7 -> first.tokenCharts.week TrendInterval.M1 -> first.tokenCharts.month } chartByInterval != null } - MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ when (val status = it.status) { is PaginationStatus.Paginating -> { if (status.lastResult is BatchFetchResult.Success) { it.data.lastOrNull()?.key } else { null } } is PaginationStatus.EndOfPagination -> { it.data.lastOrNull()?.key } else -> null } } - MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ when (val status = it.status) { is PaginationStatus.Paginating -> { if (status.lastResult is BatchFetchResult.Success) { it.data.size == 1 } else { null } } is PaginationStatus.EndOfPagination -> { it.data.size == 1 } else -> null } } - MultilineLambdaItParameter:MarketsListItem.kt${ if (Random.nextBoolean()) { it.first.inc() to PriceChangeType.UP } else { it.first.dec() to PriceChangeType.DOWN } } - MultilineLambdaItParameter:MarketsListLazyColumn.kt${ (it.key as? String)?.split(TOKEN_LAZY_LIST_ID_SEPARATOR)?.first() ?.let { rawId -> CryptoCurrency.RawID(rawId) } } - MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ if (it) { analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(tokenFound = false)) } } - MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ if (it.isNotEmpty()) { activeListManager.getBatchKeysByItemIds(visibleItemIds.value) } else { null } } - MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ if (it.list !is ListUM.Content) { visibleItemIds.value = emptyList() } } - MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ mainMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) } - MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ searchMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) } - MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( list = if (it.list is ListUM.Content) { it.list.copy( triggerScrollReset = consumedEvent(), ) } else { it.list }, ) } - MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( price = it.price.copy( changeType = if (visibleItemIds.contains(it.id)) { it.price.changeType } else { null }, ), ) } - MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( searchBar = it.searchBar.copy( query = value, isActive = value.isNotEmpty(), ), ) } - MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( selectedInterval = value, list = if (it.list is ListUM.Content && it.selectedSortBy != SortByTypeUM.Rating && it.selectedInterval != value ) { it.list.copy(triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }) } else { it.list }, ) } - MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( selectedSortBy = sortByTypeUM, sortByBottomSheet = it.sortByBottomSheet.copy( isShown = false, content = (it.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( selectedOption = sortByTypeUM, ), ), ) } - MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( selectedSortBy = value, sortByBottomSheet = it.sortByBottomSheet.copy( content = (it.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( selectedOption = value, ), ), list = if (it.list is ListUM.Content && it.selectedSortBy != value) { it.list.copy(triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }) } else { it.list }, ) } - MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ when { isInErrorState -> { it.copy( list = ListUM.LoadingError(onRetryClicked = onRetryButtonClicked), ) } isSearchNotFound -> { it.copy(list = ListUM.SearchNothingFound) } uiItems.isEmpty() -> { it.copy(list = ListUM.Loading) } else -> { it.updateItems( newItems = uiItems, stakingNotificationMaxApy = stakingNotificationMaxApy, ) } } } - MultilineLambdaItParameter:MarketsPortfolioModel.kt$MarketsPortfolioModel${ Timber.e( it, """ Failed to check currency unsupported state |- User wallet ID: $userWalletId |- Network ID: $rawNetworkId |- Is main network: $isMainNetwork """.trimIndent(), ) val message = SnackbarMessage( message = it.localizedMessage ?.let(::stringReference) ?: resourceReference(R.string.common_error), ) messageSender.send(message) null } - MultilineLambdaItParameter:MarketsPortfolioModel.kt$MarketsPortfolioModel${ onWalletSelect(it) // === Analytics === analyticsEventHandler.send( analyticsEventBuilder.addToPortfolioWalletChanged(), ) } - MultilineLambdaItParameter:MarketsPortfolioModel.kt$MarketsPortfolioModel${ when (it) { is NewAddToPortfolioManager.State.AvailableToAdd -> AddButtonState.Available NewAddToPortfolioManager.State.Init -> AddButtonState.Loading NewAddToPortfolioManager.State.NothingToAdd -> AddButtonState.Unavailable } } - MultilineLambdaItParameter:MarketsTokenDetailsContent.kt${ if (it.size.height > 0) { with(density) { onHeaderSizeChange(it.size.height.toDp()) } } } - MultilineLambdaItParameter:MarketsTokenDetailsContent.kt${ val nextColor = when (it) { PriceChangeType.UP, -> growColor PriceChangeType.DOWN -> fallColor PriceChangeType.NEUTRAL -> return@EventEffect } color.animateTo(nextColor, snap()) color.animateTo(generalColor, tween(durationMillis = 500)) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ BlockchainUtils.isSupportedNetworkId( blockchainId = it.networkId, excludedBlockchains = excludedBlockchains, hotExcludedBlockchains = hotWalletExcludedBlockchains, hasOnlyHotWallets = allWalletsIsHot, ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ ExchangesBottomSheetContent.Content( exchangeItems = ExchangeItemStateConverter.convertList(it).toImmutableList(), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ MarketsDateTimeFormatters.formatDateByIntervalWithMarker( interval = currentState.selectedInterval, markerTimestamp = it, ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ analyticsEventHandler.send( analyticsEventBuilder.intervalChanged( intervalType = MarketDetailsAnalyticsEvent.IntervalType.Insights, interval = it, ), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ analyticsEventHandler.send( analyticsEventBuilder.intervalChanged( intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance, interval = it, ), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ getChangePercentBetween( previousPrice = it, currentPrice = currentQuotes.value.currentPrice, ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ if (it.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) { it.copy( body = MarketsTokenDetailsUM.Body.Error( onLoadRetryClick = ::onLoadRetryClicked, ), ) } else { it.copy( body = MarketsTokenDetailsUM.Body.Nothing, ) } } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( body = MarketsTokenDetailsUM.Body.Loading, ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.DATA, ), body = if (it.body is MarketsTokenDetailsUM.Body.Nothing) { MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked) } else { it.body }, ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.ERROR, ), body = if (it.body is MarketsTokenDetailsUM.Body.Error) { MarketsTokenDetailsUM.Body.Nothing } else { it.body }, ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.LOADING, ), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( priceChangePercentText = priceChangePercent, selectedInterval = interval, priceChangeType = quotes.getPercentByInterval(interval)?.percentChangeType() ?: PriceChangeType.NEUTRAL, dateTimeText = getDefaultDateTimeString(interval), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( priceText = newInfo.quotes.currentPrice.format { fiat( fiatCurrencySymbol = currentAppCurrency.value.symbol, fiatCurrencyCode = currentAppCurrency.value.code, ).price() }, priceChangePercentText = newInfo.quotes.getFormattedPercentByInterval( interval = it.selectedInterval, ), priceChangeType = percent.percentChangeType(), body = MarketsTokenDetailsUM.Body.Content( description = descriptionConverter.convert(newInfo), infoBlocks = infoConverter.convert(newInfo), ), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( selectedInterval = interval, chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.LOADING, ), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( type = percent.percentChangeType().toChartType(), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( xAxisFormatter = xAxisFormatter, type = state.value.priceChangeType.toChartType(), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.urlData?.fullUrl?.let { url -> urlOpener.openUrl(url) } // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.securityScoreProviderClicked(it.name)) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ showBottomSheet(it) // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.readMoreClicked()) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ showBottomSheet(it) // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.securityScoreOpened()) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ val percentChangeType = params.token.tokenQuotes.h24Percent.percentChangeType() it.copy( type = percentChangeType.toChartType(), xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(PriceChangeInterval.H24), yAxisFormatter = { value -> value.format { fiat( fiatCurrencyCode = currentAppCurrency.value.code, fiatCurrencySymbol = currentAppCurrency.value.symbol, ).price() } }, ) } - MultilineLambdaItParameter:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate${ PortfoliosWithThisCurrency( currencyRawId = currencyRawId, portfolios = it.toList(), ) } - MultilineLambdaItParameter:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate${ when (it) { AddButtonState.Loading -> MyPortfolioUM.Loading AddButtonState.Available -> MyPortfolioUM.AddFirstToken( onAddClick = onAddClick, addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, ) AddButtonState.Unavailable -> MyPortfolioUM.Unavailable } } - MultilineLambdaItParameter:PortfolioDataLoader.kt$PortfolioDataLoader${ PortfolioData.CryptoCurrencyData( userWallet = wallet, status = it, actions = emptyList(), ) } - MultilineLambdaItParameter:PortfolioDataLoader.kt$PortfolioDataLoader${ PortfolioData.CryptoCurrencyData( userWallet = wallet, status = status, actions = it.states, ) } - MultilineLambdaItParameter:PortfolioItem.kt${ val onClick = state.tokenItemState.onItemClick if (onClick != null) { hapticManager.perform(TangemHapticEffect.View.ContextClick) onClick.invoke(it) } } - MultilineLambdaItParameter:PortfolioTokenUMConverter.kt$PortfolioTokenUMConverter.Companion${ if (it == QuickActionUM.Receive) { tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.CopyAddress, cryptoCurrencyData = cryptoData, ) } } - MultilineLambdaItParameter:PortfolioTokenUMConverter.kt$PortfolioTokenUMConverter.Companion${ when (it) { QuickActionUM.Buy -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Buy, cryptoCurrencyData = cryptoData, ) is QuickActionUM.Exchange -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Exchange, cryptoCurrencyData = cryptoData, ) QuickActionUM.Receive -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Receive, cryptoCurrencyData = cryptoData, ) QuickActionUM.Stake -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Stake, cryptoCurrencyData = cryptoData, ) } } - MultilineLambdaItParameter:PricePerformanceBlock.kt${ currentInterval = it state.onIntervalChanged(it) } - MultilineLambdaItParameter:SecurityScoreConverter.kt$SecurityScoreConverter${ SecurityScoreBottomSheetContent.SecurityScoreProviderUM( name = it.providerName, lastAuditDate = it.lastAuditDate?.let { date -> MarketsDateTimeFormatters.formatAsDate(date.millis) }, score = it.securityScore, urlData = it.urlData?.let { urlData -> SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData( fullUrl = urlData.fullUrl, rootHost = urlData.rootHost, ) }, iconUrl = it.iconUrl, ) } - MultilineLambdaItParameter:TokenActionsContent.kt${ key(it.title) { ActionRow( state = it, onClick = { state.quickActions.onQuickActionClick(it) }, onLongClick = { state.quickActions.onQuickActionLongClick(it) }, ) } } - MultilineLambdaItParameter:TokenMarketBlockModel.kt$TokenMarketBlockModel${ it.onSome { res -> quotesState = QuotesState( currentPrice = res.fiatRate, h24Percent = res.priceChange, ) state.value = state.value.copy( currentPrice = res.fiatRate.format { fiat( // TODO get currency from quotes use case AND-8022 fiatCurrencyCode = currentAppCurrency.value.code, // TODO get currency from quotes use case AND-8022 fiatCurrencySymbol = currentAppCurrency.value.symbol, ).price() }, h24Percent = res.priceChange.format { percent() }, priceChangeType = PriceChangeType.fromBigDecimal(res.priceChange), ) } } - MultilineLambdaItParameter:TokenMarketInfoConverter.kt$TokenMarketInfoConverter${ pricePerformanceConverter.convert( value = it, currentPrice = value.quotes.currentPrice, ) } - MultilineLambdaItParameter:TokensPortfolioUMConverter.kt$TokensPortfolioUMConverter${ it.copy( isQuickActionsShown = if (it.matchWith(cryptoData)) { !it.isQuickActionsShown } else { false }, ) } - NamedArguments:AvailableToAddDataConverter.kt$AvailableToAddDataConverter$createCryptoCurrency(wallet, it, marketParams, this.account) - NamedArguments:MarketsListModel.kt$MarketsListModel$combine( mainMarketsListManager.uiItems, mainMarketsListManager.isInInitialLoadingErrorState, getStakingNotificationMaxApyUseCase(), getUserCountryUseCase.invoke(), ) { uiItems, isInInitialLoadingErrorState, stakingNotificationMaxApy, userCountry -> MarketsItemsData( items = uiItems, isInErrorState = isInInitialLoadingErrorState, isSearchNotFound = false, stakingNotificationMaxApy = stakingNotificationMaxApy, userCountry = userCountry, ) } - NamedArguments:MarketsListModel.kt$MarketsListModel$combine( searchMarketsListManager.uiItems, searchMarketsListManager.isInInitialLoadingErrorState, searchMarketsListManager.isSearchNotFoundState, getStakingNotificationMaxApyUseCase(), getUserCountryUseCase.invoke(), ) { uiItems, isInInitialLoadingErrorState, isSearchNotFoundState, stakingMaxApy, userCountry -> MarketsItemsData( items = uiItems, isInErrorState = isInInitialLoadingErrorState, isSearchNotFound = isSearchNotFoundState, stakingNotificationMaxApy = stakingMaxApy, userCountry = userCountry, ) } - NestedScopeFunctions:DescriptionConverter.kt$DescriptionConverter$let { fullDescription -> stringReference(fullDescription) } - NoNameShadowing:MarketsPortfolioModel.kt$MarketsPortfolioModel${ it.isMultiCurrency } - NoNameShadowing:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ if (it.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) { it.copy( body = MarketsTokenDetailsUM.Body.Error( onLoadRetryClick = ::onLoadRetryClicked, ), ) } else { it.copy( body = MarketsTokenDetailsUM.Body.Nothing, ) } } - NoNameShadowing:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.ERROR, ), body = if (it.body is MarketsTokenDetailsUM.Body.Error) { MarketsTokenDetailsUM.Body.Nothing } else { it.body }, ) } - NoNameShadowing:MyPortfolioUMFactory.kt$MyPortfolioUMFactory${ networkIds.contains(it.status.currency.network.backendId) } - NoNameShadowing:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$portfolio - NullableToStringCall:MarketsListItemUM.kt$MarketsListItemUM$marketCap.toString() - PropertyUsedBeforeDeclaration:MarketsListModel.kt$MarketsListModel$activeListManager - PropertyUsedBeforeDeclaration:MarketsListUMStateManager.kt$MarketsListUMStateManager$state - PropertyUsedBeforeDeclaration:MarketsPortfolioModel.kt$MarketsPortfolioModel$_state - PropertyUsedBeforeDeclaration:MarketsPortfolioModel.kt$MarketsPortfolioModel$currentAppCurrency - PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$accountHeader - PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$accountToken - PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$sampleToken - PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$walletHeader - PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$walletPortfolioHeader - ReusedModifierInstance:EntryBottomSheetContent.kt$BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, modifier = modifier, ) - ReusedModifierInstance:PortfolioQuickActions.kt$Column(modifier = modifier) { actions.fastForEach { action -> LineSeparator() QuickActionItem( state = action, onClick = { onActionClick(action) }, onLongClick = { onActionLongClick(action) }.takeIf { action.longClickAvailable }, ) } } - ReusedModifierInstance:TokenActionsContent.kt$SecondaryButton( modifier = modifier.fillMaxWidth(), text = stringResourceSafe(R.string.common_later), onClick = state.onLaterClick, ) - SuspendFunWithFlowReturnType:AddToPortfolioModel.kt$AddToPortfolioModel$suspend - UnnecessaryEventHandlerParameter:SecurityScoreBottomSheet.kt$onLinkClick: (SecurityScoreBottomSheetContent.SecurityScoreProviderUM) -> Unit - UnnecessaryLet:PortfolioAnalyticsEvent.kt$PortfolioAnalyticsEvent.EventBuilder$let { put("Source", source) } - UnnecessaryLet:SecurityScoreBottomSheet.kt$let { Text( text = providerUM.lastAuditDate, style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, ) } - UnsafeCallOnNullableType:DefaultMarketsPortfolioComponent.kt$DefaultMarketsPortfolioComponent$model.newAddToPortfolioManager!! - UnsafeCallOnNullableType:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager$previousList!! - UnsafeCallOnNullableType:PricePerformanceConverter.kt$PricePerformanceConverter$high!! - UnsafeCallOnNullableType:PricePerformanceConverter.kt$PricePerformanceConverter$low!! - UseAnyOrNoneInsteadOfFind:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$find { account -> account.addedCurrency.isNotEmpty() } - UseEmptyCounterpart:MarketDetailsAnalyticsEvent.kt$MarketDetailsAnalyticsEvent$mapOf() - UseEmptyCounterpart:MarketsListAnalyticsEvent.kt$MarketsListAnalyticsEvent$mapOf() - UseEmptyCounterpart:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$setOf() - UseEmptyCounterpart:PortfolioAnalyticsEvent.kt$PortfolioAnalyticsEvent$mapOf() - UseOrEmpty:DescriptionConverter.kt$DescriptionConverter$value.fullDescription ?: "" - UseOrEmpty:Formatters.kt$percent?.format { percent() } ?: "" - UseOrEmpty:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel$percent?.format { percent() } ?: "" - UseOrEmpty:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$allActions[currencyStatus.currency]?.states ?: emptyList() - UseOrEmpty:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$portfolio.portfolios .find { it.accountsWithAdded.find { account -> account.addedCurrency.isNotEmpty() } != null } ?.userWallet ?.let { setOf(it.walletId to currency.currency.id) } ?: setOf() - VarCouldBeVal:MarketsListModel.kt$MarketsListModel$private var updateQuotesJob = JobHolder() - VarCouldBeVal:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel$private var quotesJob = JobHolder() - - diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt index 54aa223690..e6f564100f 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt @@ -62,9 +62,11 @@ internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor( init { componentScope.launch { - model.networksState.collectLatest { - when (it) { - is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(it.networks) + model.networksState.collectLatest { networksState -> + when (networksState) { + is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks( + networksState.networks, + ) TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable() else -> {} } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/analytics/MarketDetailsAnalyticsEvent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/analytics/MarketDetailsAnalyticsEvent.kt index 85d934fd13..c3674eb4cc 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/analytics/MarketDetailsAnalyticsEvent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/analytics/MarketDetailsAnalyticsEvent.kt @@ -6,7 +6,7 @@ import com.tangem.domain.markets.TokenMarketParams internal class MarketDetailsAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) { data class EventBuilder( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt index 6107b6d7a2..f2a44120b9 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt @@ -78,7 +78,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( private val getUserWalletsUseCase: GetWalletsUseCase, ) : Model() { - private var quotesJob = JobHolder() + private val quotesJob = JobHolder() private var userCountry: UserCountry? = null private val params = paramsContainer.require() private val analyticsEventBuilder = MarketDetailsAnalyticsEvent.EventBuilder(token = params.token) @@ -101,34 +101,34 @@ internal class MarketsTokenDetailsModel @Inject constructor( // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.linkClicked(linkTitle = link.title)) }, - onSecurityScoreInfoClick = { - showBottomSheet(it) + onSecurityScoreInfoClick = { content -> + showBottomSheet(content) // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.securityScoreOpened()) }, - onSecurityScoreProviderLinkClick = { - it.urlData?.fullUrl?.let { url -> + onSecurityScoreProviderLinkClick = { provider -> + provider.urlData?.fullUrl?.let { url -> urlOpener.openUrl(url) } // === Analytics === - analyticsEventHandler.send(analyticsEventBuilder.securityScoreProviderClicked(it.name)) + analyticsEventHandler.send(analyticsEventBuilder.securityScoreProviderClicked(provider.name)) }, // === Analytics === - onPricePerformanceIntervalChanged = { + onPricePerformanceIntervalChanged = { interval -> analyticsEventHandler.send( analyticsEventBuilder.intervalChanged( intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance, - interval = it, + interval = interval, ), ) }, - onInsightsIntervalChanged = { + onInsightsIntervalChanged = { interval -> analyticsEventHandler.send( analyticsEventBuilder.intervalChanged( intervalType = MarketDetailsAnalyticsEvent.IntervalType.Insights, - interval = it, + interval = interval, ), ) }, @@ -139,8 +139,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( ) private val descriptionConverter = DescriptionConverter( - onReadModeClicked = { - showBottomSheet(it) + onReadModeClicked = { content -> + showBottomSheet(content) // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.readMoreClicked()) }, @@ -162,10 +162,10 @@ internal class MarketsTokenDetailsModel @Inject constructor( private val chartDataProducer = MarketChartDataProducer.build(dispatcher = dispatchers.default) { chartData = MarketChartData.NoData.Loading - updateLook { + updateLook { currentLook -> val percentChangeType = params.token.tokenQuotes.h24Percent.percentChangeType() - it.copy( + currentLook.copy( type = percentChangeType.toChartType(), xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(PriceChangeInterval.H24), yAxisFormatter = { value -> @@ -220,7 +220,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( ), selectedInterval = PriceChangeInterval.H24, onSelectedIntervalChange = ::onSelectedIntervalChange, - markerSet = false, + isMarkerSet = false, body = MarketsTokenDetailsUM.Body.Loading, triggerPriceChange = consumedEvent(), bottomSheetConfig = TangemBottomSheetConfig( @@ -237,11 +237,11 @@ internal class MarketsTokenDetailsModel @Inject constructor( currentQuotes = currentQuotes, lastUpdatedTimestamp = lastUpdatedTimestamp, currentTokenInfo = currentTokenInfo, - onPricePerformanceIntervalChanged = { + onPricePerformanceIntervalChanged = { interval -> analyticsEventHandler.send( analyticsEventBuilder.intervalChanged( intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance, - interval = it, + interval = interval, ), ) }, @@ -256,7 +256,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( modelScope.launch { currentAppCurrency .filter { it != params.appCurrency } - .collectLatest { + .collectLatest { _ -> initialLoad() } } @@ -286,9 +286,9 @@ internal class MarketsTokenDetailsModel @Inject constructor( private fun loadChart(interval: PriceChangeInterval) { modelScope.launch { - state.update { - it.copy( - chartState = it.chartState.copy( + state.update { currentState -> + currentState.copy( + chartState = currentState.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.LOADING, ), ) @@ -306,10 +306,10 @@ internal class MarketsTokenDetailsModel @Inject constructor( preview = false, ) - state.update { - it.copy( + state.update { currentState -> + currentState.copy( selectedInterval = interval, - chartState = it.chartState.copy( + chartState = currentState.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.LOADING, ), ) @@ -318,15 +318,15 @@ internal class MarketsTokenDetailsModel @Inject constructor( chart .onRight { updateTokenChart(it) } .onLeft { - state.update { - it.copy( - chartState = it.chartState.copy( + state.update { currentState -> + currentState.copy( + chartState = currentState.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.ERROR, ), - body = if (it.body is MarketsTokenDetailsUM.Body.Error) { + body = if (currentState.body is MarketsTokenDetailsUM.Body.Error) { MarketsTokenDetailsUM.Body.Nothing } else { - it.body + currentState.body }, ) } @@ -343,31 +343,31 @@ internal class MarketsTokenDetailsModel @Inject constructor( x = tokenChart.timeStamps.map { it.toBigDecimal() }.toImmutableList(), ).sorted() - updateLook { - it.copy( + updateLook { currentLook -> + currentLook.copy( xAxisFormatter = xAxisFormatter, type = state.value.priceChangeType.toChartType(), ) } } - state.update { - it.copy( - chartState = it.chartState.copy( + state.update { currentState -> + currentState.copy( + chartState = currentState.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.DATA, ), - body = if (it.body is MarketsTokenDetailsUM.Body.Nothing) { + body = if (currentState.body is MarketsTokenDetailsUM.Body.Nothing) { MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked) } else { - it.body + currentState.body }, ) } } private fun loadInfo() { - state.update { - it.copy( + state.update { currentState -> + currentState.copy( body = MarketsTokenDetailsUM.Body.Loading, ) } @@ -382,15 +382,15 @@ internal class MarketsTokenDetailsModel @Inject constructor( tokenMarketInfo.fold( ifRight = { result -> updateInfo(result) }, ifLeft = { - state.update { - if (it.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) { - it.copy( + state.update { currentState -> + if (currentState.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) { + currentState.copy( body = MarketsTokenDetailsUM.Body.Error( onLoadRetryClick = ::onLoadRetryClicked, ), ) } else { - it.copy( + currentState.copy( body = MarketsTokenDetailsUM.Body.Nothing, ) } @@ -408,8 +408,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( val percent = newInfo.quotes.getPercentByInterval(interval = state.value.selectedInterval) - state.update { - it.copy( + state.update { currentState -> + currentState.copy( priceText = newInfo.quotes.currentPrice.format { fiat( fiatCurrencySymbol = currentAppCurrency.value.symbol, @@ -417,7 +417,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( ).price() }, priceChangePercentText = newInfo.quotes.getFormattedPercentByInterval( - interval = it.selectedInterval, + interval = currentState.selectedInterval, ), priceChangeType = percent.percentChangeType(), body = MarketsTokenDetailsUM.Body.Content( @@ -427,14 +427,14 @@ internal class MarketsTokenDetailsModel @Inject constructor( ) } - val allWalletsIsHot = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } + val areAllWalletsHot = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } - val networks = newInfo.networks?.filter { + val networks = newInfo.networks?.filter { network -> BlockchainUtils.isSupportedNetworkId( - blockchainId = it.networkId, + blockchainId = network.networkId, excludedBlockchains = excludedBlockchains, hotExcludedBlockchains = hotWalletExcludedBlockchains, - hasOnlyHotWallets = allWalletsIsHot, + hasOnlyHotWallets = areAllWalletsHot, ) } @@ -445,8 +445,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( } chartDataProducer.runTransaction { - updateLook { - it.copy(type = percent.percentChangeType().toChartType()) + updateLook { currentLook -> + currentLook.copy(type = percent.percentChangeType().toChartType()) } } } @@ -481,8 +481,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( val quotes = currentQuotes.value val priceChangePercent = quotes.getFormattedPercentByInterval(interval) - state.update { - it.copy( + state.update { currentState -> + currentState.copy( priceChangePercentText = priceChangePercent, selectedInterval = interval, priceChangeType = quotes.getPercentByInterval(interval)?.percentChangeType() @@ -502,10 +502,10 @@ internal class MarketsTokenDetailsModel @Inject constructor( private fun onMarkerPointSelected(markerTimestamp: BigDecimal?, price: BigDecimal?) { val currentState = state.value - val dateTimeText = markerTimestamp?.let { + val dateTimeText = markerTimestamp?.let { timestamp -> MarketsDateTimeFormatters.formatDateByIntervalWithMarker( interval = currentState.selectedInterval, - markerTimestamp = it, + markerTimestamp = timestamp, ) } ?: getDefaultDateTimeString(currentState.selectedInterval) @@ -516,18 +516,18 @@ internal class MarketsTokenDetailsModel @Inject constructor( ).price() } - val percent = price?.let { + val percent = price?.let { selectedPrice -> getChangePercentBetween( - previousPrice = it, + previousPrice = selectedPrice, currentPrice = currentQuotes.value.currentPrice, ) } ?: currentQuotes.value.getPercentByInterval(currentState.selectedInterval) - val percentText = percent?.format { percent() } ?: "" + val percentText = percent?.format { percent() }.orEmpty() state.update { stateToUpdate -> stateToUpdate.copy( - markerSet = markerTimestamp != null, + isMarkerSet = markerTimestamp != null, dateTimeText = dateTimeText, priceText = priceText, priceChangePercentText = percentText, @@ -536,8 +536,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( } chartDataProducer.runTransaction { - updateLook { - it.copy( + updateLook { currentLook -> + currentLook.copy( type = percent.percentChangeType().toChartType(), ) } @@ -603,9 +603,9 @@ internal class MarketsTokenDetailsModel @Inject constructor( ifLeft = { ExchangesBottomSheetContent.Error(onRetryClick = { onListedOnClick(exchangesCount) }) }, - ifRight = { + ifRight = { exchanges -> ExchangesBottomSheetContent.Content( - exchangeItems = ExchangeItemStateConverter.convertList(it).toImmutableList(), + exchangeItems = ExchangeItemStateConverter.convertList(exchanges).toImmutableList(), ) }, ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt index f1e55176d1..ae495c8db0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt @@ -20,29 +20,26 @@ internal class DescriptionConverter( override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.Description? { if (needApplyFCARestrictions()) return null - return value.shortDescription?.let { desc -> - MarketsTokenDetailsUM.Description( - shortDescription = stringReference(desc), - fullDescription = value.fullDescription?.let { fullDescription -> - stringReference(fullDescription) - }, - onReadMoreClick = { - onReadModeClicked( - InfoBottomSheetContent( - title = resourceReference( - R.string.markets_token_details_about_token_title, - wrappedList( - value.name, - ), - ), - body = stringReference(value.fullDescription ?: ""), - generatedAINotificationUM = InfoBottomSheetContent.GeneratedAINotificationUM( - onClick = onGeneratedAINotificationClick, + val shortDesc = value.shortDescription ?: return null + return MarketsTokenDetailsUM.Description( + shortDescription = stringReference(shortDesc), + fullDescription = value.fullDescription?.let(::stringReference), + onReadMoreClick = { + onReadModeClicked( + InfoBottomSheetContent( + title = resourceReference( + R.string.markets_token_details_about_token_title, + wrappedList( + value.name, ), ), - ) - }, - ) - } + body = stringReference(value.fullDescription.orEmpty()), + generatedAINotificationUM = InfoBottomSheetContent.GeneratedAINotificationUM( + onClick = onGeneratedAINotificationClick, + ), + ), + ) + }, + ) } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt index 738710939a..c3aa321cb1 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt @@ -56,11 +56,13 @@ internal class PricePerformanceConverter( } private fun TokenMarketInfo.Range.calculateFraction(currentPrice: BigDecimal): Float { + val lowValue = low + val highValue = high return when { - low == null || high == null || high == BigDecimal.ZERO || currentPrice < low -> 0f - currentPrice > high || low == high -> 1f + lowValue == null || highValue == null || highValue == BigDecimal.ZERO || currentPrice < lowValue -> 0f + currentPrice > highValue || lowValue == highValue -> 1f else -> { - (currentPrice - low!!).divide(high!! - low!!, RoundingMode.HALF_UP) + (currentPrice - lowValue).divide(highValue - lowValue, RoundingMode.HALF_UP) .setScale(2, RoundingMode.HALF_UP) .toFloat().coerceAtMost(1f) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/SecurityScoreConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/SecurityScoreConverter.kt index aee019594a..d3bec1f927 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/SecurityScoreConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/SecurityScoreConverter.kt @@ -31,21 +31,20 @@ internal class SecurityScoreConverter( SecurityScoreBottomSheetContent( title = resourceReference(R.string.markets_token_details_security_score), description = resourceReference(R.string.markets_token_details_security_score_description), - providers = value.securityScoreProviderData.map { + providers = value.securityScoreProviderData.map { provider -> SecurityScoreBottomSheetContent.SecurityScoreProviderUM( - name = it.providerName, - lastAuditDate = it.lastAuditDate?.let { date -> + name = provider.providerName, + lastAuditDate = provider.lastAuditDate?.let { date -> MarketsDateTimeFormatters.formatAsDate(date.millis) }, - score = it.securityScore, - urlData = it.urlData?.let { - urlData -> + score = provider.securityScore, + urlData = provider.urlData?.let { urlData -> SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData( fullUrl = urlData.fullUrl, rootHost = urlData.rootHost, ) }, - iconUrl = it.iconUrl, + iconUrl = provider.iconUrl, ) }, onProviderLinkClick = onSecurityScoreProviderLinkClick, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt index 8f67567773..e7aa05eeb5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt @@ -64,9 +64,9 @@ internal class TokenMarketInfoConverter( insights = insights, securityScore = securityScore, metrics = value.metrics?.let { metricsConverter.convert(it) }, - pricePerformance = value.pricePerformance?.let { + pricePerformance = value.pricePerformance?.let { performance -> pricePerformanceConverter.convert( - value = it, + value = performance, currentPrice = value.quotes.currentPrice, ) }, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt index 78f0a49fb3..bc1aeb77e3 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt @@ -21,7 +21,7 @@ internal fun TokenQuotes.getFormattedPercentByInterval(interval: PriceChangeInte PriceChangeInterval.ALL_TIME -> allTimeChangePercent } - return percent?.format { percent() } ?: "" + return percent?.format { percent() }.orEmpty() } internal fun TokenQuotes.getPercentByInterval(interval: PriceChangeInterval): BigDecimal? { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/state/QuotesStateUpdater.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/state/QuotesStateUpdater.kt index 74d10c5742..116fb390b4 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/state/QuotesStateUpdater.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/state/QuotesStateUpdater.kt @@ -53,7 +53,7 @@ internal class QuotesStateUpdater( val priceChangeType = percent.percentChangeType() // wait until marker is removed - state.first { it.markerSet.not() } + state.first { it.isMarkerSet.not() } currentQuotes.value = newQuotes lastUpdatedTimestamp.value = DateTime.now().millis diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt index cd2f2b68b1..a732ce35a6 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt @@ -107,10 +107,10 @@ private fun Content( .fillMaxSize(), ) { TopBar( - modifier = Modifier.onGloballyPositioned { - if (it.size.height > 0) { + modifier = Modifier.onGloballyPositioned { coordinates -> + if (coordinates.size.height > 0) { with(density) { - onHeaderSizeChange(it.size.height.toDp()) + onHeaderSizeChange(coordinates.size.height.toDp()) } } }, @@ -174,7 +174,7 @@ private fun TopBar( onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - val showPriceSubtitle by remember { + val shouldShowPriceSubtitle by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 1 } @@ -183,7 +183,7 @@ private fun TopBar( TangemTopAppBar( modifier = modifier, title = tokenName, - subtitle = if (showPriceSubtitle) tokenPrice else null, + subtitle = if (shouldShowPriceSubtitle) tokenPrice else null, startButton = TopAppBarButtonUM.Back( onBackClicked = onBackClick, enabled = isBackButtonEnabled, @@ -240,8 +240,8 @@ private fun TokenPriceText( val color = remember(generalColor) { Animatable(generalColor) } - EventEffect(triggerPriceChange) { - val nextColor = when (it) { + EventEffect(triggerPriceChange) { changeType -> + val nextColor = when (changeType) { PriceChangeType.UP, -> growColor PriceChangeType.DOWN -> fallColor diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt index 8b5e829a64..d40e37b970 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt @@ -48,12 +48,12 @@ internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) { PriceChangeInterval.MONTH, ), initialSelectedItem = PriceChangeInterval.H24, - onClick = { - currentInterval = it - state.onIntervalChanged(it) + onClick = { interval -> + currentInterval = interval + state.onIntervalChanged(interval) }, modifier = Modifier.width(IntrinsicSize.Min), - ) { + ) { interval -> Box( Modifier .fillMaxSize() @@ -65,7 +65,7 @@ internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) { ) { Text( modifier = Modifier.align(Alignment.Center), - text = it.getText().resolveReference(), + text = interval.getText().resolveReference(), style = TangemTheme.typography.caption1, color = TangemTheme.colors.text.primary1, ) @@ -82,10 +82,10 @@ internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) { GridItems( items = infoPoints, - itemContent = { + itemContent = { infoPoint -> InfoPoint( modifier = Modifier.align(Alignment.CenterStart), - infoPointUM = it, + infoPointUM = infoPoint, ) }, ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt index 5825ba908b..6d10ec8957 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt @@ -96,11 +96,11 @@ private fun SubBlock( horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { - links.fastForEach { + links.fastForEach { link -> Chip( - text = stringReference(it.title), - iconResId = it.iconRes, - onClick = { onLinkClick(it) }, + text = stringReference(link.title), + iconResId = link.iconRes, + onClick = { onLinkClick(link) }, ) } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt index 076b704bb8..0ad6b22ee0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt @@ -30,8 +30,8 @@ internal fun MarketTokenDetailsChart( val chartState = rememberMarketChartState( dataProducer = state.dataProducer, - colorMapper = { - when (it) { + colorMapper = { chartType -> + when (chartType) { MarketChartLook.Type.Growing -> growingColor MarketChartLook.Type.Falling -> fallingColor MarketChartLook.Type.Neutral -> neutralColor diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MetricsBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MetricsBlock.kt index 9d33b61034..98e74fd06f 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MetricsBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MetricsBlock.kt @@ -29,7 +29,7 @@ const val MAX_METRICS_COUNT = 6 @Composable internal fun MetricsBlock(state: MetricsUM, modifier: Modifier = Modifier) { - var expanded by remember { mutableStateOf(false) } + var isExpanded by remember { mutableStateOf(false) } InformationBlock( modifier = modifier, @@ -44,11 +44,11 @@ internal fun MetricsBlock(state: MetricsUM, modifier: Modifier = Modifier) { }, action = { if (state.metrics.size > MAX_METRICS_COUNT) { - ShowLessMoreButton(expanded = expanded, onClick = { expanded = !expanded }) + ShowLessMoreButton(expanded = isExpanded, onClick = { isExpanded = !isExpanded }) } }, content = { - val metrics = if (expanded) { + val metrics = if (isExpanded) { state.metrics } else { state.metrics.take(MAX_METRICS_COUNT).toImmutableList() diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt index efede75d0e..17e8d19e3f 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt @@ -53,12 +53,12 @@ internal fun PricePerformanceBlock(state: PricePerformanceUM, modifier: Modifier PriceChangeInterval.ALL_TIME, ), initialSelectedItem = PriceChangeInterval.H24, - onClick = { - currentInterval = it - state.onIntervalChanged(it) + onClick = { interval -> + currentInterval = interval + state.onIntervalChanged(interval) }, modifier = Modifier.width(IntrinsicSize.Min), - ) { + ) { interval -> Box( Modifier .fillMaxSize() @@ -70,7 +70,7 @@ internal fun PricePerformanceBlock(state: PricePerformanceUM, modifier: Modifier ) { Text( modifier = Modifier.align(Alignment.Center), - text = it.getText().resolveReference(), + text = interval.getText().resolveReference(), style = TangemTheme.typography.caption1, color = TangemTheme.colors.text.primary1, ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBottomSheet.kt index e6ef8e94b6..b8c4242c9a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBottomSheet.kt @@ -70,7 +70,7 @@ internal fun SecurityScoreBottomSheet(config: TangemBottomSheetConfig) { ) { SecurityScoreProviderRow( providerUM = provider, - onLinkClick = content.onProviderLinkClick, + onLinkClick = { content.onProviderLinkClick(provider) }, ) } } @@ -85,7 +85,7 @@ internal fun SecurityScoreBottomSheet(config: TangemBottomSheetConfig) { @Composable private fun SecurityScoreProviderRow( providerUM: SecurityScoreBottomSheetContent.SecurityScoreProviderUM, - onLinkClick: (SecurityScoreBottomSheetContent.SecurityScoreProviderUM) -> Unit, + onLinkClick: () -> Unit, ) { Row( modifier = Modifier @@ -118,7 +118,7 @@ private fun SecurityScoreProviderRow( style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.primary1, ) - providerUM.lastAuditDate?.let { + if (providerUM.lastAuditDate != null) { Text( text = providerUM.lastAuditDate, style = TangemTheme.typography.caption2, @@ -136,7 +136,7 @@ private fun SecurityScoreProviderRow( enabled = providerUM.urlData != null, indication = ripple(bounded = false), interactionSource = remember { MutableInteractionSource() }, - onClick = { onLinkClick(providerUM) }, + onClick = onLinkClick, ), ) { ScoreStarsBlock( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/preview/MarketsTokenDetailsPreview.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/preview/MarketsTokenDetailsPreview.kt index 46ad847bf6..83be0ff031 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/preview/MarketsTokenDetailsPreview.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/preview/MarketsTokenDetailsPreview.kt @@ -39,7 +39,7 @@ internal object MarketsTokenDetailsPreview { onDismissRequest = {}, content = TangemBottomSheetConfigContent.Empty, ), - markerSet = false, + isMarkerSet = false, triggerPriceChange = consumedEvent(), ) @@ -123,7 +123,7 @@ internal object MarketsTokenDetailsPreview { onDismissRequest = {}, content = TangemBottomSheetConfigContent.Empty, ), - markerSet = true, + isMarkerSet = true, triggerPriceChange = consumedEvent(), ) } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt index a4b669739a..94f581efd7 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt @@ -17,7 +17,7 @@ internal data class MarketsTokenDetailsUM( val priceChangePercentText: String?, val priceChangeType: PriceChangeType, val selectedInterval: PriceChangeInterval, - val markerSet: Boolean, + val isMarkerSet: Boolean, val chartState: ChartState, val onSelectedIntervalChange: (PriceChangeInterval) -> Unit, val bottomSheetConfig: TangemBottomSheetConfig, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt index a19e160096..7814672735 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt @@ -37,20 +37,21 @@ internal fun EntryBottomSheetContent( Children( stack = stackState.value, animation = stackAnimation(slide()), - ) { - when (it.configuration) { + modifier = modifier, + ) { child -> + when (child.configuration) { is MarketsEntryChildFactory.Child.TokenDetails -> { - (it.instance as MarketsTokenDetailsComponent).BottomSheetContent( + (child.instance as MarketsTokenDetailsComponent).BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, - modifier = modifier, + modifier = Modifier, ) } is MarketsEntryChildFactory.Child.TokenList -> { - (it.instance as MarketsTokenListComponent).BottomSheetContent( + (child.instance as MarketsTokenListComponent).BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, - modifier = modifier, + modifier = Modifier, ) } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/api/AvailableToAddData.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/api/AvailableToAddData.kt index 557fd53518..3ca633edf5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/api/AvailableToAddData.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/api/AvailableToAddData.kt @@ -12,7 +12,7 @@ import kotlinx.serialization.Serializable internal data class AvailableToAddData( val availableToAddWallets: Map, ) { - val availableToAdd: Boolean + val isAvailableToAdd: Boolean get() = availableToAddWallets.isNotEmpty() val isSinglePortfolio: Boolean get() = availableToAddWallets.size == 1 && availableToAddWallets.values.first().accounts.size == 1 @@ -48,11 +48,11 @@ internal data class SelectedPortfolio( val userWallet: UserWallet, val account: AvailableToAddAccount, val isAccountMode: Boolean, - val availableMorePortfolio: Boolean, + val hasMorePortfoliosAvailable: Boolean, ) internal data class SelectedNetwork( val selectedNetwork: TokenMarketInfo.Network, val cryptoCurrency: CryptoCurrency, - val availableMoreNetwork: Boolean, + val hasMoreNetworksAvailable: Boolean, ) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/DefaultAddToPortfolioComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/DefaultAddToPortfolioComponent.kt index 69b9573e71..81c2f24a63 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/DefaultAddToPortfolioComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/DefaultAddToPortfolioComponent.kt @@ -131,7 +131,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( end = 16.dp, bottom = 16.dp, ) - val scrollableContent = when (stack.active.configuration) { + val isScrollableContent = when (stack.active.configuration) { AddToPortfolioRoutes.PortfolioSelector -> false AddToPortfolioRoutes.AddToken, AddToPortfolioRoutes.Empty, @@ -139,7 +139,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( AddToPortfolioRoutes.TokenActions, -> true } - if (scrollableContent) { + if (isScrollableContent) { Column( modifier = paddingModifier.verticalScroll(rememberScrollState()), ) { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt index 4bbeec4cee..7004e37dfc 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt @@ -30,7 +30,14 @@ internal class AvailableToAddDataConverter @Inject constructor( ): AvailableToAddData { suspend fun AccountStatus.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount? { val currencies = availableNetworks - .mapNotNull { createCryptoCurrency(wallet, it, marketParams, this.account) } + .mapNotNull { network -> + createCryptoCurrency( + userWallet = wallet, + network = network, + marketParams = marketParams, + account = this.account, + ) + } if (currencies.isEmpty()) return null @@ -74,9 +81,9 @@ internal class AvailableToAddDataConverter @Inject constructor( } val availableToAddWallets: Map = balances - .map { - val (walletId, _) = it - val availableToAddWallet = getAvailableToAddWallet(it) + .map { entry -> + val (walletId, _) = entry + val availableToAddWallet = getAvailableToAddWallet(entry) walletId to availableToAddWallet } .filter { (_, wallet) -> wallet.availableToAddAccounts.isNotEmpty() } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioModel.kt index 40c339c730..2ae6b5b028 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioModel.kt @@ -101,7 +101,7 @@ internal class AddToPortfolioModel @Inject constructor( val data = featureDataFlow.value // you must control it via [AddToPortfolioManager.state] - if (!data.availableToAdd) { + if (!data.isAvailableToAdd) { finishFlow() return@channelFlow } @@ -182,8 +182,8 @@ internal class AddToPortfolioModel @Inject constructor( messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) setupTokenActionsFlow(selectedPortfolio, addedToken) - .onEach { - tokenActionsData.emit(it) + .onEach { cryptoCurrencyData -> + tokenActionsData.emit(cryptoCurrencyData) navigation.replaceAll(AddToPortfolioRoutes.TokenActions) } .onEmpty { finishFlow() } @@ -192,8 +192,8 @@ internal class AddToPortfolioModel @Inject constructor( callbackDelegate.onLaterClick.receiveAsFlow().first() finishFlow() } - .catch { - Timber.e(it) + .catch { error -> + Timber.e(error) params.callback.onDismiss() } .launchIn(modelScope) @@ -213,14 +213,15 @@ internal class AddToPortfolioModel @Inject constructor( } } - private suspend fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow { - val selectedAccount = selectedPortfolio.first().account.account.account.accountId + private fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow = flow { + val selectedPortfolioValue = selectedPortfolio.first() + val selectedAccount = selectedPortfolioValue.account.account.account.accountId portfolioSelectorController.selectAccount(selectedAccount) val changedPortfolio = setupPortfolioFlow(data) .drop(1) .onEach { portfolio -> navigation.pushNew(routeToNetworkSelector(portfolio)) } val changedNetwork = setupNetworkFlow(changedPortfolio) - return combine( + combine( flow = changedPortfolio, flow2 = changedNetwork, transform = { newPortfolio, newNetwork -> @@ -228,7 +229,7 @@ internal class AddToPortfolioModel @Inject constructor( selectedNetwork.tryEmit(newNetwork) navigation.popToFirst() }, - ) + ).collect { emit(it) } } private fun setupTokenActionsFlow( @@ -276,7 +277,7 @@ internal class AddToPortfolioModel @Inject constructor( isAccountMode = isAccountMode, userWallet = availableToAddWallets.userWallet, account = availableToAddAccount, - availableMorePortfolio = !data.isSinglePortfolio, + hasMorePortfoliosAvailable = !data.isSinglePortfolio, ) }, ) @@ -293,7 +294,7 @@ internal class AddToPortfolioModel @Inject constructor( account = selectedPortfolio.account, ) ?: return@transform null, selectedNetwork = selectedNetwork, - availableMoreNetwork = !selectedPortfolio.account.isSingleNetwork, + hasMoreNetworksAvailable = !selectedPortfolio.account.isSingleNetwork, ) }, ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt index b0f322b6f5..7c5ba2defd 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt @@ -78,8 +78,8 @@ internal class AddTokenModel @Inject constructor( val account = selectedPortfolio.account.account.account val accountId = account.accountId manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency) - .onLeft { - processError(error = it) + .onLeft { error -> + processError(error = error) uiState.value = um.toggleProgress(false) return@launch } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt index adcb476815..1d95ddde68 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt @@ -29,7 +29,7 @@ internal class AddTokenUiBuilder @Inject constructor( return AddTokenUM.Network( icon = selectedNetwork.cryptoCurrency.network.iconResId, name = stringReference(selectedNetwork.cryptoCurrency.network.name), - editable = selectedNetwork.availableMoreNetwork, + editable = selectedNetwork.hasMoreNetworksAvailable, onClick = { params.callbacks.onChangeNetworkClick() }, ) } @@ -54,7 +54,7 @@ internal class AddTokenUiBuilder @Inject constructor( icon = accountIcon, name = portfolioName, isAccountMode = selectedPortfolio.isAccountMode, - isMultiChoice = selectedPortfolio.availableMorePortfolio, + isMultiChoice = selectedPortfolio.hasMorePortfoliosAvailable, onClick = { params.callbacks.onChangePortfolioClick() }, ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/ChooseNetworkModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/ChooseNetworkModel.kt index fa3e0c3ab8..1d1ecc80d0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/ChooseNetworkModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/ChooseNetworkModel.kt @@ -82,9 +82,9 @@ internal class ChooseNetworkModel @Inject constructor( userWalletId = userWalletId, networkId = rawNetworkId, isMainNetwork = isMainNetwork, - ).getOrElse { + ).getOrElse { error -> Timber.e( - it, + error, """ Failed to check currency unsupported state |- User wallet ID: $userWalletId @@ -94,7 +94,7 @@ internal class ChooseNetworkModel @Inject constructor( ) val message = SnackbarMessage( - message = it.localizedMessage?.let(::stringReference) ?: resourceReference(R.string.common_error), + message = error.localizedMessage?.let(::stringReference) ?: resourceReference(R.string.common_error), ) messageSender.send(message) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt index 9c82131fe6..e396ef008e 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt @@ -45,7 +45,7 @@ internal class DefaultAddToPortfolioManager @AssistedInject constructor( availableNetworks = availableNetworks, marketParams = token, ) - if (data.availableToAdd) { + if (data.isAvailableToAdd) { State.AvailableToAdd(data) } else { State.NothingToAdd diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/TokenActionsContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/TokenActionsContent.kt index 97e8645655..e0b24bf672 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/TokenActionsContent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/TokenActionsContent.kt @@ -63,12 +63,12 @@ internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Mod .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) .background(TangemTheme.colors.background.action), ) { - state.quickActions.actions.fastForEach { - key(it.title) { + state.quickActions.actions.fastForEach { action -> + key(action.title) { ActionRow( - state = it, - onClick = { state.quickActions.onQuickActionClick(it) }, - onLongClick = { state.quickActions.onQuickActionLongClick(it) }, + state = action, + onClick = { state.quickActions.onQuickActionClick(action) }, + onLongClick = { state.quickActions.onQuickActionLongClick(action) }, ) } } @@ -77,7 +77,7 @@ internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Mod SpacerH16() SecondaryButton( - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), text = stringResourceSafe(R.string.common_later), onClick = state.onLaterClick, ) @@ -102,7 +102,7 @@ private fun ActionRow( modifier = modifier .fillMaxWidth() .combinedClickable( - onLongClick = onLongClickInternal.takeIf { state.longClickAvailable }, + onLongClick = onLongClickInternal.takeIf { state.isLongClickAvailable }, onClick = { hapticManager.perform(TangemHapticEffect.View.SegmentTick) onClick() @@ -123,7 +123,7 @@ private fun ActionRow( .size(36.dp) .drawWithContent { drawContent() - if (state is QuickActionUM.Exchange && state.showBadge) { + if (state is QuickActionUM.Exchange && state.shouldShowBadge) { drawBadge(containerColor = containerColor, offset = 4.dp) } }, @@ -191,7 +191,7 @@ private class TokenActionsContentPreviewProvider : PreviewParameterProvider addToPortfolioComponentFactory.create( context = childByContext(componentContext), params = AddToPortfolioComponent.Params( - addToPortfolioManager = model.newAddToPortfolioManager!!, + addToPortfolioManager = requireNotNull(model.newAddToPortfolioManager) { + "newAddToPortfolioManager must be initialized" + }, callback = model.addToPortfolioCallback, ), ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt index a547351b4c..50b5428039 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt @@ -6,7 +6,7 @@ import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContent internal class PortfolioAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) { data class EventBuilder( @@ -52,7 +52,7 @@ internal class PortfolioAnalyticsEvent( }, params = buildMap { put("Token", token.symbol) - source?.let { put("Source", source) } + if (source != null) put("Source", source) put("blockchain", blockchainName) }, ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/loader/PortfolioDataLoader.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/loader/PortfolioDataLoader.kt index d5522e7218..0dadd3bcf4 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/loader/PortfolioDataLoader.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/loader/PortfolioDataLoader.kt @@ -80,11 +80,11 @@ internal class PortfolioDataLoader @Inject constructor( YieldSupplyAvailability.Unavailable } getCryptoCurrencyActionsUseCase(wallet, status, yieldSupplyAvailability) - .map { + .map { actionStates -> PortfolioData.CryptoCurrencyData( userWallet = wallet, status = status, - actions = it.states, + actions = actionStates.states, ) } } @@ -93,18 +93,18 @@ internal class PortfolioDataLoader @Inject constructor( combine(actionsFlows) { actions -> walletsWithStatuses.mapValues { entry -> entry.value.mapNotNull { status -> - actions.firstOrNull { - it.userWallet == entry.key && it.status == status + actions.firstOrNull { data -> + data.userWallet == entry.key && data.status == status } } } }.onEmpty { emit( walletsWithStatuses.mapValues { (wallet, statuses) -> - statuses.map { + statuses.map { status -> PortfolioData.CryptoCurrencyData( userWallet = wallet, - status = it, + status = status, actions = emptyList(), ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt index 0b6b839c45..bf294a2895 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt @@ -58,7 +58,7 @@ internal class AddToPortfolioBSContentUMFactory( artworks: Map, ): TangemBottomSheetConfig { return (currentState ?: TangemBottomSheetConfig.Empty).copy( - isShown = portfolioUIData.portfolioBSVisibilityModel.addToPortfolioBSVisibility, + isShown = portfolioUIData.portfolioBSVisibilityModel.isAddToPortfolioBSVisible, onDismissRequest = { onAddToPortfolioVisibilityChange(false) }, content = if (selectedWallet != null && alreadyAddedNetworks != null) { AddToPortfolioBSContentUM( @@ -76,8 +76,8 @@ internal class AddToPortfolioBSContentUMFactory( alreadyAddedNetworks = alreadyAddedNetworks, onNetworkSwitchClick = onNetworkSwitchClick, ).convert(value = token), - isScanCardNotificationVisible = portfolioUIData.needColdWalletInteraction, - continueButtonEnabled = portfolioUIData.addToPortfolioData.isUserAddedNetworks( + isScanCardNotificationVisible = portfolioUIData.shouldRequireColdWalletInteraction, + isContinueButtonEnabled = portfolioUIData.addToPortfolioData.isUserAddedNetworks( userWalletId = selectedWallet.walletId, ), onContinueButtonClick = { @@ -90,7 +90,7 @@ internal class AddToPortfolioBSContentUMFactory( ) }, walletSelectorConfig = createWalletSelectorBSConfig( - isShow = portfolioUIData.portfolioBSVisibilityModel.walletSelectorBSVisibility, + isShow = portfolioUIData.portfolioBSVisibilityModel.isWalletSelectorBSVisible, portfolioData = portfolioData, selectedWalletId = selectedWallet.walletId, artworks = artworks, @@ -136,9 +136,9 @@ internal class AddToPortfolioBSContentUMFactory( val balance = portfolioData.walletsWithBalance[userWallet.walletId] UserWalletItemUMConverter( - onClick = { - if (it != selectedWalletId) { - onAnotherWalletSelect(it) + onClick = { walletId -> + if (walletId != selectedWalletId) { + onAnotherWalletSelect(walletId) onWalletSelectorVisibilityChange(false) } }, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioManager.kt index c617f718cd..15718128de 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioManager.kt @@ -124,8 +124,8 @@ internal class AddToPortfolioManager @Inject constructor( return } - update { - it.toMutableMap().apply { + update { currentMap -> + currentMap.toMutableMap().apply { this[userWalletId] = if (isAddAction) { this[userWalletId].orEmpty() + network } else { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt index a314e9a0e1..529f20adbc 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt @@ -77,8 +77,8 @@ internal class MarketsPortfolioModel @Inject constructor( private val newMarketsPortfolioDelegateFactory: NewMarketsPortfolioDelegate.Factory, ) : Model() { - val state: StateFlow get() = _state private val _state: MutableStateFlow = MutableStateFlow(value = MyPortfolioUM.Loading) + val state: StateFlow get() = _state private val params = paramsContainer.require() private val analyticsEventBuilder = PortfolioAnalyticsEvent.EventBuilder( @@ -99,6 +99,16 @@ internal class MarketsPortfolioModel @Inject constructor( override fun onDismiss() = bottomSheetNavigation.dismiss() } + private val currentAppCurrency = getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + private val tokenActionsHandler = tokenActionsIntentsFactory.create( currentAppCurrency = Provider { currentAppCurrency.value }, updateTokenReceiveBSConfig = { updateBlock -> @@ -109,10 +119,11 @@ internal class MarketsPortfolioModel @Inject constructor( } }, onHandleQuickAction = { handledAction -> + val currencyNetwork = handledAction.cryptoCurrencyData.status.currency.network analyticsEventHandler.send( analyticsEventBuilder.quickActionClick( actionUM = handledAction.action, - blockchainName = handledAction.cryptoCurrencyData.status.currency.network.name, + blockchainName = currencyNetwork.name, ), ) if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) { @@ -121,16 +132,6 @@ internal class MarketsPortfolioModel @Inject constructor( }, ) - private val currentAppCurrency = getSelectedAppCurrencyUseCase() - .map { maybeAppCurrency -> - maybeAppCurrency.getOrElse { AppCurrency.Default } - } - .stateIn( - scope = modelScope, - started = SharingStarted.Eagerly, - initialValue = AppCurrency.Default, - ) - private val factory = MyPortfolioUMFactory( onAddClick = { onAddToPortfolioBSVisibilityChange(isShow = true) @@ -145,8 +146,8 @@ internal class MarketsPortfolioModel @Inject constructor( onAddToPortfolioVisibilityChange = ::onAddToPortfolioBSVisibilityChange, onWalletSelectorVisibilityChange = ::onWalletSelectorVisibilityChange, onNetworkSwitchClick = ::onNetworkSwitchClick, - onAnotherWalletSelect = { - onWalletSelect(it) + onAnotherWalletSelect = { walletId -> + onWalletSelect(walletId) // === Analytics === analyticsEventHandler.send( analyticsEventBuilder.addToPortfolioWalletChanged(), @@ -186,8 +187,8 @@ internal class MarketsPortfolioModel @Inject constructor( scope = modelScope, token = params.token, tokenActionsHandler = tokenActionsHandler, - buttonState = newAddToPortfolioManager.state.map { - when (it) { + buttonState = newAddToPortfolioManager.state.map { managerState -> + when (managerState) { is NewAddToPortfolioManager.State.AvailableToAdd -> AddButtonState.Available NewAddToPortfolioManager.State.Init -> AddButtonState.Loading NewAddToPortfolioManager.State.NothingToAdd -> AddButtonState.Unavailable @@ -230,8 +231,8 @@ internal class MarketsPortfolioModel @Inject constructor( Timber.e("Failed to load selected wallet: $e") error("Failed to load selected wallet") } - .onEach { - selectedMultiWalletIdFlow.value = it.takeIf { it.isMultiCurrency }?.walletId + .onEach { wallet -> + selectedMultiWalletIdFlow.value = wallet.takeIf { it.isMultiCurrency }?.walletId } .launchIn(modelScope) } @@ -277,7 +278,10 @@ internal class MarketsPortfolioModel @Inject constructor( portfolioBSVisibilityModel = portfolioBSVisibilityModel, selectedWalletId = selectedWalletId, addToPortfolioData = addToPortfolioData, - needColdWalletInteraction = needColdWalletInteraction(selectedWalletId, addToPortfolioData), + shouldRequireColdWalletInteraction = needColdWalletInteraction( + selectedWalletId, + addToPortfolioData, + ), ) }, ) @@ -333,9 +337,9 @@ internal class MarketsPortfolioModel @Inject constructor( userWalletId = userWalletId, networkId = rawNetworkId, isMainNetwork = isMainNetwork, - ).getOrElse { + ).getOrElse { error -> Timber.e( - it, + error, """ Failed to check currency unsupported state |- User wallet ID: $userWalletId @@ -345,7 +349,7 @@ internal class MarketsPortfolioModel @Inject constructor( ) val message = SnackbarMessage( - message = it.localizedMessage + message = error.localizedMessage ?.let(::stringReference) ?: resourceReference(R.string.common_error), ) @@ -401,13 +405,13 @@ internal class MarketsPortfolioModel @Inject constructor( private fun onAddToPortfolioBSVisibilityChange(isShow: Boolean) { portfolioBSVisibilityModelFlow.update { - it.copy(addToPortfolioBSVisibility = isShow, walletSelectorBSVisibility = false) + it.copy(isAddToPortfolioBSVisible = isShow, isWalletSelectorBSVisible = false) } } private fun onWalletSelectorVisibilityChange(isShow: Boolean) { portfolioBSVisibilityModelFlow.update { - it.copy(addToPortfolioBSVisibility = true, walletSelectorBSVisibility = isShow) + it.copy(isAddToPortfolioBSVisible = true, isWalletSelectorBSVisible = isShow) } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt index 9fe46dc75b..d5c027adb8 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt @@ -143,8 +143,8 @@ internal class MyPortfolioUMFactory( ): List { val networkIds = networks.map(TokenMarketInfo.Network::networkId) - return mapNotNull { - it.takeIf { networkIds.contains(it.status.currency.network.backendId) } + return mapNotNull { currencyData -> + currencyData.takeIf { networkIds.contains(it.status.currency.network.backendId) } } } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt index fd1f0fecea..9a6de7c950 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt @@ -98,8 +98,8 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor( } } - private fun addFirstTokenFlow(): Flow = buttonState.map { - when (it) { + private fun addFirstTokenFlow(): Flow = buttonState.map { state -> + when (state) { AddButtonState.Loading -> MyPortfolioUM.Loading AddButtonState.Available -> MyPortfolioUM.AddFirstToken( onAddClick = onAddClick, @@ -120,7 +120,7 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor( }.flatten() val allAddedTokenActions = - portfolio.portfolios.map { portfolio -> portfolio.actionsFoAccountCurrencies() }.flatten() + portfolio.portfolios.map { portfolioItem -> portfolioItem.actionsFoAccountCurrencies() }.flatten() return combine( flow = combine(allAddedTokenActions) { it.toMap() }.distinctUntilChanged(), @@ -153,12 +153,12 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor( val currency = allAddedCurrency.first() // find userWallet than have this single added token portfolio.portfolios - .find { it.accountsWithAdded.find { account -> account.addedCurrency.isNotEmpty() } != null } + .find { it.accountsWithAdded.any { account -> account.addedCurrency.isNotEmpty() } } ?.userWallet ?.let { setOf(it.walletId to currency.currency.id) } - ?: setOf() + .orEmpty() } - else -> setOf() + else -> emptySet() } return MutableStateFlow(initValue) .also { this.expandedHolder = it } @@ -166,10 +166,10 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor( private fun portfolioWithThisCurrencyFLow(): Flow = allAccountSupplier().map { list -> list.map { it.addedAccountsFlow() } }.flatMapLatest { flows -> - combine(flows) { + combine(flows) { portfolios -> PortfoliosWithThisCurrency( currencyRawId = currencyRawId, - portfolios = it.toList(), + portfolios = portfolios.toList(), ) } }.distinctUntilChanged() @@ -237,8 +237,7 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor( } accountWithAdded.addedCurrency.forEach { currencyStatus -> - val actions = allActions[currencyStatus.currency]?.states - ?: emptyList() + val actions = allActions[currencyStatus.currency]?.states.orEmpty() val value = PortfolioData.CryptoCurrencyData( userWallet = userWallet, status = currencyStatus, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioBSVisibilityModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioBSVisibilityModel.kt index b2d43ea69a..acf1b7934c 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioBSVisibilityModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioBSVisibilityModel.kt @@ -3,12 +3,12 @@ package com.tangem.features.markets.portfolio.impl.model /** * Model for portfolio bottom sheet visibility * - * @property addToPortfolioBSVisibility visibility of add to portfolio bottom sheet - * @property walletSelectorBSVisibility visibility of wallet selector bottom sheet + * @property isAddToPortfolioBSVisible visibility of add to portfolio bottom sheet + * @property isWalletSelectorBSVisible visibility of wallet selector bottom sheet * [REDACTED_AUTHOR] */ internal data class PortfolioBSVisibilityModel( - val addToPortfolioBSVisibility: Boolean = false, - val walletSelectorBSVisibility: Boolean = false, + val isAddToPortfolioBSVisible: Boolean = false, + val isWalletSelectorBSVisible: Boolean = false, ) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt index b64ee6bf1f..17b0a6cdf5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt @@ -95,8 +95,8 @@ internal class PortfolioTokenUMConverter( ) } }, - onQuickActionLongClick = { - if (it == QuickActionUM.Receive) { + onQuickActionLongClick = { quickAction -> + if (quickAction == QuickActionUM.Receive) { tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.CopyAddress, cryptoCurrencyData = cryptoData, @@ -111,7 +111,9 @@ internal class PortfolioTokenUMConverter( if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { when (action) { is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy - is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(showBadge = action.showBadge) + is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange( + shouldShowBadge = action.showBadge, + ) is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake is TokenActionsState.ActionState.YieldMode -> QuickActionUM.YieldMode(apy = action.apy) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt index f0dfad20dd..855e596731 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt @@ -8,7 +8,7 @@ import com.tangem.domain.models.wallet.UserWalletId * @property portfolioBSVisibilityModel portfolio bottom sheet visibility model * @property selectedWalletId selected wallet id * @property addToPortfolioData add to portfolio data - * @property needColdWalletInteraction flag that indicates if user has missed derivations and has a cold wallet + * @property shouldRequireColdWalletInteraction flag that indicates if user has missed derivations and has a cold wallet * [REDACTED_AUTHOR] */ @@ -16,5 +16,5 @@ internal data class PortfolioUIData( val portfolioBSVisibilityModel: PortfolioBSVisibilityModel, val selectedWalletId: UserWalletId?, val addToPortfolioData: AddToPortfolioManager.AddToPortfolioData, - val needColdWalletInteraction: Boolean, + val shouldRequireColdWalletInteraction: Boolean, ) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt index e62d311737..7e6523b992 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt @@ -73,14 +73,14 @@ internal class TokenActionsHandler @AssistedInject constructor( } private fun handleDemoMode(action: TokenActionsBSContentUM.Action, userWallet: UserWallet.Cold): Boolean { - val demoCard = isDemoCardUseCase.invoke(userWallet.cardId) - val needShowDemoWarning = demoCard && disabledActionsInDemoMode.contains(action) + val isDemoCard = isDemoCardUseCase.invoke(userWallet.cardId) + val shouldShowDemoWarning = isDemoCard && disabledActionsInDemoMode.contains(action) - if (needShowDemoWarning) { + if (shouldShowDemoWarning) { showDemoModeWarning() } - return needShowDemoWarning + return shouldShowDemoWarning } private fun showDemoModeWarning() { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokensPortfolioUMConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokensPortfolioUMConverter.kt index 9e5aba6a0a..47fe6e3cf3 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokensPortfolioUMConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokensPortfolioUMConverter.kt @@ -93,10 +93,10 @@ internal class TokensPortfolioUMConverter( private fun toggleQuickActions(cryptoData: PortfolioData.CryptoCurrencyData) { updateTokens { tokenList -> - tokenList.map { - it.copy( - isQuickActionsShown = if (it.matchWith(cryptoData)) { - !it.isQuickActionsShown + tokenList.map { token -> + token.copy( + isQuickActionsShown = if (token.matchWith(cryptoData)) { + !token.isQuickActionsShown } else { false }, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt index c12a36531c..f2d2d22dd2 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt @@ -54,13 +54,13 @@ internal fun AddToPortfolioBottomSheet(config: TangemBottomSheetConfig) { containerColor = TangemTheme.colors.background.tertiary, addBottomInsets = false, titleText = resourceReference(R.string.common_add_to_portfolio), - ) { + ) { contentState -> Content( modifier = Modifier.fillMaxWidth(), - state = it, + state = contentState, ) - WalletSelectorBottomSheet(it.walletSelectorConfig) + WalletSelectorBottomSheet(contentState.walletSelectorConfig) } } @@ -130,7 +130,7 @@ private fun Content(state: AddToPortfolioBSContentUM, modifier: Modifier = Modif .onGloballyPositioned { continueButtonAreaHeight = it.size.height }, - enabled = state.continueButtonEnabled, + enabled = state.isContinueButtonEnabled, isTangemIconVisible = state.isScanCardNotificationVisible, onClick = state.onContinueButtonClick, ) @@ -355,11 +355,11 @@ private fun PreviewContentTestOnDevice( isScanCardNotificationVisible = !contentState.isScanCardNotificationVisible, ) }, - continueButtonEnabled = true, + isContinueButtonEnabled = true, selectedWallet = content.selectedWallet.copy( onClick = { contentState = contentState.copy( - continueButtonEnabled = !contentState.continueButtonEnabled, + isContinueButtonEnabled = !contentState.isContinueButtonEnabled, ) }, ), diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt index 4f47bfddab..d7800514fc 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt @@ -31,11 +31,11 @@ internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifie val tokenItemState = remember(state.tokenItemState) { when (state.tokenItemState) { is TokenItemState.Content -> state.tokenItemState.copy( - onItemClick = { + onItemClick = { cryptoCurrency -> val onClick = state.tokenItemState.onItemClick if (onClick != null) { hapticManager.perform(TangemHapticEffect.View.ContextClick) - onClick.invoke(it) + onClick.invoke(cryptoCurrency) } }, ) @@ -73,10 +73,10 @@ internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifie @Composable private fun Preview(@PreviewParameter(PortfolioTokenUMProvider::class) tokenUM: PortfolioTokenUM) { TangemThemePreview { - var quickActionsShown by remember { mutableStateOf(value = false) } + var areQuickActionsShown by remember { mutableStateOf(value = false) } val onItemClick = { - quickActionsShown = quickActionsShown.not() + areQuickActionsShown = areQuickActionsShown.not() } PortfolioItem( @@ -87,7 +87,7 @@ private fun Preview(@PreviewParameter(PortfolioTokenUMProvider::class) tokenUM: is TokenItemState.Unreachable -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() }) else -> tokenUM.tokenItemState }, - isQuickActionsShown = quickActionsShown, + isQuickActionsShown = areQuickActionsShown, ), lastInList = true, ) @@ -111,7 +111,8 @@ private class PortfolioTokenUMProvider : CollectionPreviewParameterProvider LineSeparator() QuickActionItem( state = action, onClick = { onActionClick(action) }, - onLongClick = { onActionLongClick(action) }.takeIf { action.longClickAvailable }, + onLongClick = { onActionLongClick(action) }.takeIf { action.isLongClickAvailable }, ) } } @@ -174,7 +175,7 @@ private fun AnimatedVisibilityScope.QuickActionIcon(state: QuickActionUM) { .size(TangemTheme.dimens.size32) .drawWithContent { drawContent() - if (state is QuickActionUM.Exchange && state.showBadge) { + if (state is QuickActionUM.Exchange && state.shouldShowBadge) { drawBadge(containerColor = containerColor, offset = 4.dp) } }, @@ -215,7 +216,7 @@ private fun Preview() { PortfolioQuickActions( actions = persistentListOf( QuickActionUM.Buy, - QuickActionUM.Exchange(showBadge = true), + QuickActionUM.Exchange(shouldShowBadge = true), QuickActionUM.Receive, ), isVisible = isVisible, @@ -236,7 +237,7 @@ private fun PreviewRtl() { PortfolioQuickActions( actions = persistentListOf( QuickActionUM.Buy, - QuickActionUM.Exchange(showBadge = true), + QuickActionUM.Exchange(shouldShowBadge = true), QuickActionUM.Receive, ), isVisible = true, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt index 16b7cf769f..bdc1b092e3 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt @@ -54,7 +54,7 @@ internal class PreviewAddToPortfolioBSContentProvider : PreviewParameterProvider ), isScanCardNotificationVisible = true, isWalletBlockVisible = true, - continueButtonEnabled = true, + isContinueButtonEnabled = true, onContinueButtonClick = {}, walletSelectorConfig = TangemBottomSheetConfig.Empty, ), @@ -78,7 +78,7 @@ internal class PreviewAddToPortfolioBSContentProvider : PreviewParameterProvider ), isScanCardNotificationVisible = true, isWalletBlockVisible = false, - continueButtonEnabled = false, + isContinueButtonEnabled = false, onContinueButtonClick = {}, walletSelectorConfig = TangemBottomSheetConfig.Empty, ), diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt index 6f8a95f4fc..4e1b5c5bd9 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt @@ -17,6 +17,76 @@ import java.util.UUID internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider { + val sampleToken + get() = PortfolioTokenUM( + tokenItemState = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = CurrencyIconState.Locked, + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "My wallet")), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = "486,65 \$"), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "733,71097 MATIC"), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = stringReference(value = "XRP Ledger token"), + ), + onItemClick = {}, + onItemLongClick = {}, + ), + isQuickActionsShown = false, + quickActions = PortfolioTokenUM.QuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(shouldShowBadge = true), + QuickActionUM.Receive, + ), + onQuickActionClick = {}, + onQuickActionLongClick = {}, + ), + isBalanceHidden = false, + walletId = UserWalletId(""), + ) + + val walletHeader + get() = WalletHeader( + id = UUID.randomUUID().toString(), + name = stringReference("Wallet 1"), + ) + + val walletPortfolioHeader + get() = PortfolioHeader( + state = AccountTitleUM.Text(title = stringReference("Wallet 1")), + id = UUID.randomUUID().toString(), + ) + + val accountHeader + get() = PortfolioHeader( + state = AccountTitleUM.Account( + icon = AccountIconPreviewData.randomAccountIcon(), + name = stringReference("Main Account"), + prefixText = TextReference.EMPTY, + ), + id = UUID.randomUUID().toString(), + ) + val coinIconState + get() = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = com.tangem.core.ui.R.drawable.img_polygon_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ) + val accountToken + get() = sampleToken.copy( + tokenItemState = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = coinIconState, + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Polygon")), + fiatAmountState = FiatAmountState.Content(text = "321 $"), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "5,412 MATIC"), + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(value = "Token")), + onItemClick = {}, + onItemLongClick = {}, + ), + ) + override val values: Sequence get() = sequenceOf( MyPortfolioUM.Tokens( @@ -77,74 +147,4 @@ internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider Unit, val walletSelectorConfig: TangemBottomSheetConfig, ) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/QuickActionUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/QuickActionUM.kt index 777dd019c1..040f3d243e 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/QuickActionUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/QuickActionUM.kt @@ -12,7 +12,7 @@ internal sealed class QuickActionUM( val title: TextReference, val description: TextReference, @DrawableRes val icon: Int, - val longClickAvailable: Boolean = false, + val isLongClickAvailable: Boolean = false, ) { data object Buy : QuickActionUM( title = resourceReference(R.string.common_buy), @@ -21,7 +21,7 @@ internal sealed class QuickActionUM( ) data class Exchange( - val showBadge: Boolean, + val shouldShowBadge: Boolean, ) : QuickActionUM( title = resourceReference(R.string.common_exchange), description = resourceReference(R.string.exсhange_token_description), @@ -32,7 +32,7 @@ internal sealed class QuickActionUM( title = resourceReference(R.string.common_receive), description = resourceReference(R.string.receive_token_description), icon = R.drawable.ic_arrow_down_24, - longClickAvailable = true, + isLongClickAvailable = true, ) data object Stake : QuickActionUM( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt index db7d46227c..1cdaebeff0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt @@ -79,8 +79,8 @@ internal class TokenMarketBlockModel @Inject constructor( currencyID = params.cryptoCurrency.id, interval = PriceChangeInterval.H24, refresh = true, - ).collect { - it.onSome { res -> + ).collect { quotesOption -> + quotesOption.onSome { res -> quotesState = QuotesState( currentPrice = res.fiatRate, h24Percent = res.priceChange, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt index 14e1c4dc72..16c594bee1 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt @@ -107,12 +107,12 @@ internal class DefaultMarketsTokenListComponent @AssistedInject constructor( Scaffold( contentWindowInsets = WindowInsetsZero, containerColor = TangemTheme.colors.background.primary, - ) { + ) { paddingValues -> MarketsListWithBack( modifier = Modifier .statusBarsPadding() .imePadding() - .padding(it), + .padding(paddingValues), state = state, bottomSheetState = BottomSheetState.EXPANDED, onBackClick = router::pop, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt index 996e95d31b..ee943d293b 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt @@ -6,7 +6,7 @@ import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM internal sealed class MarketsListAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent(category = "Markets", event = event, params = params) { class BottomSheetOpened : MarketsListAnalyticsEvent(event = "Markets Screen Opened") @@ -39,10 +39,10 @@ internal sealed class MarketsListAnalyticsEvent( class YieldModeMoreInfoClicked : MarketsListAnalyticsEvent(event = "Yield Mode More Info") - data class TokenSearched(val tokenFound: Boolean) : MarketsListAnalyticsEvent( + data class TokenSearched(val wasTokenFound: Boolean) : MarketsListAnalyticsEvent( event = "Token Searched", params = mapOf( - "Result" to if (tokenFound) "Yes" else "No", + "Result" to if (wasTokenFound) "Yes" else "No", ), ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt index d33935747e..c898c68a73 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt @@ -52,7 +52,7 @@ internal class MarketsListModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { - private var updateQuotesJob = JobHolder() + private val updateQuotesJob = JobHolder() private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> maybeAppCurrency.getOrElse { AppCurrency.Default } @@ -64,6 +64,8 @@ internal class MarketsListModel @Inject constructor( private val visibleItemIds = MutableStateFlow>(emptyList()) + private lateinit var activeListManager: MarketsListBatchFlowManager + private val marketsListUMStateManager = MarketsListUMStateManager( currentVisibleIds = Provider { visibleItemIds.value }, onLoadMoreUiItems = { activeListManager.loadMore() }, @@ -95,7 +97,9 @@ internal class MarketsListModel @Inject constructor( dispatchers = dispatchers, ) - private var activeListManager: MarketsListBatchFlowManager = mainMarketsListManager + init { + activeListManager = mainMarketsListManager + } private val _tokenSelected = MutableSharedFlow>() @@ -173,8 +177,8 @@ internal class MarketsListModel @Inject constructor( } } - state.onEach { - if (it.list !is ListUM.Content) { + state.onEach { currentState -> + if (currentState.list !is ListUM.Content) { visibleItemIds.value = emptyList() } }.launchIn(modelScope) @@ -188,8 +192,8 @@ internal class MarketsListModel @Inject constructor( }.launchIn(modelScope) // load charts when new batch is being loaded - mainMarketsListManager.onLastBatchLoadedSuccess.onEach { - mainMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) + mainMarketsListManager.onLastBatchLoadedSuccess.onEach { batchKey -> + mainMarketsListManager.loadCharts(setOf(batchKey), marketsListUMStateManager.selectedInterval) modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) }.launchIn(modelScope) @@ -210,15 +214,19 @@ internal class MarketsListModel @Inject constructor( // reload list when sorting type has changed modelScope.launch { - marketsListUMStateManager.state.map { it.selectedSortBy }.distinctUntilChanged().drop(1).collectLatest { - mainMarketsListManager.reload() - } + marketsListUMStateManager.state + .map { state -> state.selectedSortBy } + .distinctUntilChanged() + .drop(1) + .collectLatest { + mainMarketsListManager.reload() + } } // listen current visible batch and update charts modelScope.launch { - visibleItemIds.mapNotNull { - if (it.isNotEmpty()) { + visibleItemIds.mapNotNull { itemIds -> + if (itemIds.isNotEmpty()) { activeListManager.getBatchKeysByItemIds(visibleItemIds.value) } else { null @@ -252,20 +260,20 @@ internal class MarketsListModel @Inject constructor( } modelScope.launch { - searchMarketsListManager.onLastBatchLoadedSuccess.collectLatest { - searchMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) + searchMarketsListManager.onLastBatchLoadedSuccess.collectLatest { batchKey -> + searchMarketsListManager.loadCharts(setOf(batchKey), marketsListUMStateManager.selectedInterval) modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) } } - searchMarketsListManager.isSearchNotFoundState.onEach { - if (it) { - analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(tokenFound = false)) + searchMarketsListManager.isSearchNotFoundState.onEach { isNotFound -> + if (isNotFound) { + analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(wasTokenFound = false)) } }.launchIn(modelScope) searchMarketsListManager.onFirstBatchLoadedSuccess.onEach { - analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(tokenFound = true)) + analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(wasTokenFound = true)) }.launchIn(modelScope) // analytics diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt index 4847ff333a..e96284ced9 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt @@ -68,17 +68,17 @@ internal class MarketsListBatchFlowManager( val onLastBatchLoadedSuccess = batchFlow.state .distinctUntilChanged { old, new -> old.status == new.status && old.data.size == new.data.size } - .mapNotNull { - when (val status = it.status) { + .mapNotNull { state -> + when (val status = state.status) { is PaginationStatus.Paginating -> { if (status.lastResult is BatchFetchResult.Success) { - it.data.lastOrNull()?.key + state.data.lastOrNull()?.key } else { null } } is PaginationStatus.EndOfPagination -> { - it.data.lastOrNull()?.key + state.data.lastOrNull()?.key } else -> null } @@ -86,22 +86,22 @@ internal class MarketsListBatchFlowManager( val onFirstBatchLoadedSuccess = batchFlow.state .distinctUntilChanged { old, new -> old.status == new.status && old.data.size == new.data.size } - .mapNotNull { - when (val status = it.status) { + .mapNotNull { state -> + when (val status = state.status) { is PaginationStatus.Paginating -> { if (status.lastResult is BatchFetchResult.Success) { - it.data.size == 1 + state.data.size == 1 } else { null } } is PaginationStatus.EndOfPagination -> { - it.data.size == 1 + state.data.size == 1 } else -> null } } - .filter { it } + .filter { isFirstBatch -> isFirstBatch } val isInInitialLoadingErrorState = batchFlow.state .map { it.status is PaginationStatus.InitialLoadingError } @@ -113,10 +113,10 @@ internal class MarketsListBatchFlowManager( ) val isSearchNotFoundState = batchFlow.state - .map { + .map { state -> currentSearchText().isNullOrEmpty().not() && - it.status is PaginationStatus.EndOfPagination && - it.data.isEmpty() + state.status is PaginationStatus.EndOfPagination && + state.data.isEmpty() } .distinctUntilChanged() .stateIn( @@ -175,22 +175,21 @@ internal class MarketsListBatchFlowManager( forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key val outItems = if (isInitialLoading) { - newList.map { + newList.map { batch -> Batch( - key = it.key, - data = converter.convertList(it.data), + key = batch.key, + data = converter.convertList(batch.data), ) } } else { - previousList!! if (previousList.size != newList.size) { val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) val newBatches = newList.filter { keysToAdd.contains(it.key) } - items + newBatches.map { + items + newBatches.map { batch -> Batch( - key = it.key, - data = converter.convertList(it.data), + key = batch.key, + data = converter.convertList(batch.data), ) } } else { @@ -264,8 +263,8 @@ internal class MarketsListBatchFlowManager( modelScope.launch { val currentData = batchFlow.state.value.data val alreadyLoadedChartsBatchKeys = currentData - .filter { - val first = it.data.firstOrNull() ?: return@filter false + .filter { batch -> + val first = batch.data.firstOrNull() ?: return@filter false val chartByInterval = when (interval) { TrendInterval.H24 -> first.tokenCharts.h24 TrendInterval.D7 -> first.tokenCharts.week @@ -273,7 +272,7 @@ internal class MarketsListBatchFlowManager( } chartByInterval != null } - .map { it.key } + .map { batch -> batch.key } .toSet() val batchesKeysToLoad = batchKeys.minus(alreadyLoadedChartsBatchKeys) @@ -302,9 +301,12 @@ internal class MarketsListBatchFlowManager( }, ) + val batchKeys = batchFlow.state.value.data + .map { it.key } + .toSet() actionsFlow.emit( BatchAction.UpdateBatches( - keys = batchFlow.state.value.data.map { it.key }.toSet(), + keys = batchKeys, updateRequest = TokenMarketUpdateRequest.UpdateQuotes( currencyId = currentAppCurrency().code, ), @@ -332,7 +334,10 @@ internal class MarketsListBatchFlowManager( } fun getTokenById(id: CryptoCurrency.RawID): TokenMarket? { - return batchFlow.state.value.data.map { it.data }.flatten().find { it.id == id } + val allTokens = batchFlow.state.value.data + .map { it.data } + .flatten() + return allTokens.find { it.id == id } } private fun SortByTypeUM.toRequestOrder(): TokenMarketListConfig.Order { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt index 33f89173a9..8c33b39d79 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt @@ -29,15 +29,19 @@ internal class MarketsListUMStateManager( private val onShowTokensUnder100kClicked: () -> Unit, ) { - private var sortByBottomSheetIsShown + val state = MutableStateFlow(createInitialState()) + val isInSearchStateFlow = state.map { it.searchBar.isActive }.distinctUntilChanged() + val searchQueryFlow = state.map { it.searchBar.query }.distinctUntilChanged() + + private var isSortByBottomSheetShown get() = state.value.sortByBottomSheet.isShown set(value) = state.update { it.copy(sortByBottomSheet = it.sortByBottomSheet.copy(isShown = value)) } var searchQuery get() = state.value.searchBar.query - private set(value) = state.update { - it.copy( - searchBar = it.searchBar.copy( + private set(value) = state.update { currentState -> + currentState.copy( + searchBar = currentState.searchBar.copy( query = value, isActive = value.isNotEmpty(), ), @@ -50,42 +54,42 @@ internal class MarketsListUMStateManager( var selectedSortByType get() = state.value.selectedSortBy - set(value) = state.update { - it.copy( + set(value) = state.update { currentState -> + currentState.copy( selectedSortBy = value, - sortByBottomSheet = it.sortByBottomSheet.copy( - content = (it.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( + sortByBottomSheet = currentState.sortByBottomSheet.copy( + content = (currentState.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( selectedOption = value, ), ), - list = if (it.list is ListUM.Content && it.selectedSortBy != value) { - it.list.copy(triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }) + list = if (currentState.list is ListUM.Content && currentState.selectedSortBy != value) { + currentState.list.copy( + triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }, + ) } else { - it.list + currentState.list }, ) } var selectedInterval get() = state.value.selectedInterval - set(value) = state.update { - it.copy( + set(value) = state.update { currentState -> + currentState.copy( selectedInterval = value, - list = if (it.list is ListUM.Content && - it.selectedSortBy != SortByTypeUM.Rating && - it.selectedInterval != value + list = if (currentState.list is ListUM.Content && + currentState.selectedSortBy != SortByTypeUM.Rating && + currentState.selectedInterval != value ) { - it.list.copy(triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }) + currentState.list.copy( + triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }, + ) } else { - it.list + currentState.list }, ) } - val state = MutableStateFlow(state()) - val isInSearchStateFlow = state.map { it.searchBar.isActive }.distinctUntilChanged() - val searchQueryFlow = state.map { it.searchBar.query }.distinctUntilChanged() - fun onUiItemsChanged( isInErrorState: Boolean, isSearchNotFound: Boolean, @@ -127,7 +131,7 @@ internal class MarketsListUMStateManager( return currentState.copy( list = generalContentState(itemsWithFilteredPriceChange) .copy( - showUnder100kTokensNotificationWasHidden = currentState.showUnder100kButtonAlreadyPressed(), + wasUnder100kTokensNotificationHidden = currentState.showUnder100kButtonAlreadyPressed(), ), marketsNotificationUM = marketsNotificationUM, ) @@ -144,8 +148,8 @@ internal class MarketsListUMStateManager( return currentState.copy( list = generalContentState(filtered).copy( - showUnder100kTokensNotificationWasHidden = false, - showUnder100kTokensNotification = true, + wasUnder100kTokensNotificationHidden = false, + shouldShowUnder100kTokensNotification = true, onShowTokensUnder100kClicked = { onShowTokensUnder100kClicked() state.update { s -> @@ -153,8 +157,8 @@ internal class MarketsListUMStateManager( s.copy( list = s.list.copy( items = searchUiItemsCached, - showUnder100kTokensNotification = false, - showUnder100kTokensNotificationWasHidden = true, + shouldShowUnder100kTokensNotification = false, + wasUnder100kTokensNotificationHidden = true, ), ) } ?: s @@ -170,17 +174,17 @@ internal class MarketsListUMStateManager( } private fun MarketsListUM.showUnder100kButtonAlreadyPressed(): Boolean { - return this.list is ListUM.Content && this.isInSearchMode && this.list.showUnder100kTokensNotificationWasHidden + return this.list is ListUM.Content && this.isInSearchMode && this.list.wasUnder100kTokensNotificationHidden } // Show price change animation for visible items only private fun ImmutableList.filterPriceChangeByVisibility(): ImmutableList { val visibleItemIds = currentVisibleIds() - return map { - it.copy( - price = it.price.copy( - changeType = if (visibleItemIds.contains(it.id)) { - it.price.changeType + return map { item -> + item.copy( + price = item.price.copy( + changeType = if (visibleItemIds.contains(item.id)) { + item.price.changeType } else { null }, @@ -194,15 +198,43 @@ internal class MarketsListUMStateManager( items = newItems, loadMore = onLoadMoreUiItems, visibleIdsChanged = visibleItemsChanged, - showUnder100kTokensNotification = false, + shouldShowUnder100kTokensNotification = false, onShowTokensUnder100kClicked = {}, triggerScrollReset = consumedEvent(), onItemClick = onTokenClick, - showUnder100kTokensNotificationWasHidden = false, + wasUnder100kTokensNotificationHidden = false, ) } - private fun state(): MarketsListUM = MarketsListUM( + private fun onBottomSheetOptionClicked(sortByTypeUM: SortByTypeUM) { + state.update { currentState -> + currentState.copy( + selectedSortBy = sortByTypeUM, + sortByBottomSheet = currentState.sortByBottomSheet.copy( + isShown = false, + content = (currentState.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( + selectedOption = sortByTypeUM, + ), + ), + ) + } + } + + private fun consumeTriggerResetScrollEvent() { + state.update { currentState -> + currentState.copy( + list = if (currentState.list is ListUM.Content) { + currentState.list.copy( + triggerScrollReset = consumedEvent(), + ) + } else { + currentState.list + }, + ) + } + } + + private fun createInitialState(): MarketsListUM = MarketsListUM( list = ListUM.Loading, searchBar = SearchBarUM( placeholderText = resourceReference(R.string.markets_search_header_title), @@ -214,10 +246,10 @@ internal class MarketsListUMStateManager( selectedSortBy = SortByTypeUM.Rating, selectedInterval = MarketsListUM.TrendInterval.H24, onIntervalClick = { selectedInterval = it }, - onSortByButtonClick = { sortByBottomSheetIsShown = true }, + onSortByButtonClick = { isSortByBottomSheetShown = true }, sortByBottomSheet = TangemBottomSheetConfig( isShown = false, - onDismissRequest = { sortByBottomSheetIsShown = false }, + onDismissRequest = { isSortByBottomSheetShown = false }, content = SortByBottomSheetContentUM( selectedOption = SortByTypeUM.Rating, onOptionClicked = ::onBottomSheetOptionClicked, @@ -225,32 +257,4 @@ internal class MarketsListUMStateManager( ), marketsNotificationUM = null, ) - - private fun onBottomSheetOptionClicked(sortByTypeUM: SortByTypeUM) { - state.update { - it.copy( - selectedSortBy = sortByTypeUM, - sortByBottomSheet = it.sortByBottomSheet.copy( - isShown = false, - content = (it.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( - selectedOption = sortByTypeUM, - ), - ), - ) - } - } - - private fun consumeTriggerResetScrollEvent() { - state.update { - it.copy( - list = if (it.list is ListUM.Content) { - it.list.copy( - triggerScrollReset = consumedEvent(), - ) - } else { - it.list - }, - ) - } - } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt index 0a7e31ebfc..79b42c8c55 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt @@ -81,10 +81,10 @@ internal fun MarketsList( end = TangemTheme.dimens.spacing16, bottom = 8.dp, ) - .onGloballyPositioned { - if (it.size.height > 0) { + .onGloballyPositioned { layoutCoordinates -> + if (layoutCoordinates.size.height > 0) { with(density) { - onHeaderSizeChange(it.size.height.toDp()) + onHeaderSizeChange(layoutCoordinates.size.height.toDp()) } } } @@ -312,23 +312,23 @@ private fun ItemsList( val searchLazyListState = rememberLazyListState() val mainLazyListState = rememberLazyListState() - val mainScrolled by remember { + val isMainScrolled by remember { derivedStateOf { mainLazyListState.firstVisibleItemScrollOffset > 0 } } - val searchScrolledState by remember { + val isSearchScrolled by remember { derivedStateOf { searchLazyListState.firstVisibleItemScrollOffset > 0 } } - LaunchedEffect(mainScrolled, isInSearchMode, searchScrolledState) { + LaunchedEffect(isMainScrolled, isInSearchMode, isSearchScrolled) { scrolledState.value = if (isInSearchMode) { - searchScrolledState + isSearchScrolled } else { - mainScrolled + isMainScrolled } } @@ -392,8 +392,8 @@ private fun Preview() { item.copy(id = CryptoCurrency.RawID(index.toString())) } .toImmutableList(), - showUnder100kTokensNotification = false, - showUnder100kTokensNotificationWasHidden = false, + shouldShowUnder100kTokensNotification = false, + wasUnder100kTokensNotificationHidden = false, loadMore = {}, visibleIdsChanged = {}, onShowTokensUnder100kClicked = {}, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt index e0daa6f742..1854695357 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt @@ -295,11 +295,11 @@ private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::cl Button( onClick = { - prices = prices.map { + prices = prices.map { priceInfo -> if (Random.nextBoolean()) { - it.first.inc() to PriceChangeType.UP + priceInfo.first.inc() to PriceChangeType.UP } else { - it.first.dec() to PriceChangeType.DOWN + priceInfo.first.dec() to PriceChangeType.DOWN } } state1 = state1.copy( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt index bbdcbf2697..b38a9cd3d9 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt @@ -101,7 +101,7 @@ internal fun MarketsListLazyColumn( ) } - if (isInSearchMode && state.showUnder100kTokensNotification) { + if (isInSearchMode && state.shouldShowUnder100kTokensNotification) { item(key = "show tokens under 100k".hashCode()) { ShowTokensUnder100kItem( onShowTokensClick = state.onShowTokensUnder100kClicked, @@ -126,7 +126,7 @@ internal fun MarketsListLazyColumn( triggerLoadMoreCheckOnItemsCountChange = true, onLoadMore = remember(state) { { - if (state is ListUM.Content && state.showUnder100kTokensNotification.not()) { + if (state is ListUM.Content && state.shouldShowUnder100kTokensNotification.not()) { state.loadMore() true } else { @@ -197,8 +197,8 @@ private fun SearchNothingFoundText(modifier: Modifier = Modifier) { private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) { val visibleItems by remember { derivedStateOf { - listState.layoutInfo.visibleItemsInfo.mapNotNull { - (it.key as? String)?.split(TOKEN_LAZY_LIST_ID_SEPARATOR)?.first() + listState.layoutInfo.visibleItemsInfo.mapNotNull { item -> + (item.key as? String)?.split(TOKEN_LAZY_LIST_ID_SEPARATOR)?.first() ?.let { rawId -> CryptoCurrency.RawID(rawId) } } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt index a83ea2e7d6..58b80368b4 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt @@ -36,7 +36,7 @@ data class MarketsListItemUM( ) fun getComposeKey(): String { - return id.value + TOKEN_LAZY_LIST_ID_SEPARATOR + marketCap.toString() + updateTimestamp + return id.value + TOKEN_LAZY_LIST_ID_SEPARATOR + marketCap.orEmpty() + updateTimestamp } companion object { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt index 8404856a87..d0ade846a6 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt @@ -46,8 +46,8 @@ sealed class ListUM { data class Content( val items: ImmutableList, - val showUnder100kTokensNotification: Boolean, - val showUnder100kTokensNotificationWasHidden: Boolean, + val shouldShowUnder100kTokensNotification: Boolean, + val wasUnder100kTokensNotificationHidden: Boolean, val loadMore: () -> Unit, val visibleIdsChanged: (List) -> Unit, val onShowTokensUnder100kClicked: () -> Unit, diff --git a/features/nft/impl/detekt-baseline-debug.xml b/features/nft/impl/detekt-baseline-debug.xml index e079f03d2a..97fee15b2b 100644 --- a/features/nft/impl/detekt-baseline-debug.xml +++ b/features/nft/impl/detekt-baseline-debug.xml @@ -8,7 +8,6 @@ BooleanPropertyNaming:NFTCollectionsModel.kt$NFTCollectionsModel$val assetsFulfillQuery = if (query.isEmpty()) { true } else { when (val assets = it.assets) { is NFTCollection.Assets.Empty, is NFTCollection.Assets.Failed, is NFTCollection.Assets.Loading, -> false is NFTCollection.Assets.Value -> { assets.items.any { asset -> asset.name?.lowercase()?.contains(query.lowercase()) == true } } } } BooleanPropertyNaming:NFTCollectionsModel.kt$NFTCollectionsModel$val collectionFulfillQuery = query.isEmpty() || it.name?.lowercase()?.contains(query.lowercase()) == true BooleanPropertyNaming:UpdateDataStateTransformer.kt$UpdateDataStateTransformer$val custom = derivationPath is Network.DerivationPath.Custom - CanBeNonNullable:NFTReceive.kt$bottomSheetConfig: TangemBottomSheetConfig? MultilineLambdaItParameter:ChangeCollectionExpandedStateTransformer.kt$ChangeCollectionExpandedStateTransformer${ val collectionId = collection.collectionIdProvider() if (it.id == collectionId && it is NFTCollectionUM) { if (!it.isExpanded) { onFirstExpanded() } it.copy(isExpanded = !it.isExpanded) } else { it } } MultilineLambdaItParameter:NFTCollectionsContent.kt${ key(it.id) { NFTCollectionWarning( modifier = Modifier .padding(top = TangemTheme.dimens.spacing16), state = it, ) } } MultilineLambdaItParameter:NFTCollectionsModel.kt$NFTCollectionsModel${ ChangeCollectionExpandedStateTransformer( collection = collection, collectionIdProvider = collectionIdProvider, onFirstExpanded = { onFirstExpanded(collection) }, ).transform(it) } diff --git a/features/staking/impl/detekt-baseline-debug.xml b/features/staking/impl/detekt-baseline-debug.xml index 79c9fb9f11..113f53de5d 100644 --- a/features/staking/impl/detekt-baseline-debug.xml +++ b/features/staking/impl/detekt-baseline-debug.xml @@ -7,41 +7,30 @@ BooleanPropertyNaming:StakingModel.kt$StakingModel$val noBalanceState = balanceState == null BooleanPropertyNaming:StakingUiState.kt$StakingStates.InitialInfoState.Data$val showBanner: Boolean BooleanPropertyNaming:StakingUiState.kt$StakingUiState$val showColdWalletInteractionIcon: Boolean - CanBeNonNullable:StakingScreen.kt$bottomSheetConfig: TangemBottomSheetConfig? CastNullableToNonNullableType:SetApprovalBottomSheetInProgressTransformer.kt$SetApprovalBottomSheetInProgressTransformer$as CastNullableToNonNullableType:SetApprovalBottomSheetTypeChangeTransformer.kt$SetApprovalBottomSheetTypeChangeTransformer$as MultilineLambdaItParameter:AddStakingNotificationsTransformer.kt$AddStakingNotificationsTransformer${ it is StakingNotification.Error || it is NotificationUM.Error || it is NotificationUM.Warning.NetworkFeeUnreachable || it is StakingNotification.Warning.TransactionInProgress || it is StakingNotification.Warning.InitializeTonAccount } MultilineLambdaItParameter:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler${ val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true) val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true isNetwork && isCurrency } MultilineLambdaItParameter:StakingFeeBlock.kt${ if (it == FeeState.Error) { Text( text = DASH_SIGN, color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.body1, ) } } MultilineLambdaItParameter:StakingFeeBlock.kt${ if (it == FeeState.Loading) { RectangleShimmer( radius = TangemTheme.dimens.radius3, modifier = Modifier.size( height = TangemTheme.dimens.size24, width = TangemTheme.dimens.size90, ), ) } } - MultilineLambdaItParameter:StakingFeeTransactionLoader.kt$StakingFeeTransactionLoader${ onStakingFeeError(it) null } - MultilineLambdaItParameter:StakingFeeTransactionLoader.kt$StakingFeeTransactionLoader${ onStakingFeeError(it) return } MultilineLambdaItParameter:StakingInfoNotificationsFactory.kt$StakingInfoNotificationsFactory${ it.type == BalanceType.PREPARING || it.type == BalanceType.STAKED || it.type == BalanceType.LOCKED } - MultilineLambdaItParameter:StakingInfoNotificationsFactory.kt$StakingInfoNotificationsFactory${ it.type == BalanceType.STAKED || it.type == BalanceType.PREPARING || it.type == BalanceType.UNSTAKED } MultilineLambdaItParameter:StakingModel.kt$StakingModel${ EnterAmountBoundary( amount = it, fiatRate = status.value.fiatRate.orZero(), ) } MultilineLambdaItParameter:StakingModel.kt$StakingModel${ Timber.e(it) false } MultilineLambdaItParameter:StakingModel.kt$StakingModel${ isBalanceHiddenFlow.value = it.isBalanceHidden stateController.update( transformer = HideBalanceStateTransformer( isBalanceHidden = it.isBalanceHidden, cryptoCurrencyStatus = cryptoCurrencyStatus, appCurrency = appCurrency, ), ) } MultilineLambdaItParameter:StakingModel.kt$StakingModel${ stateController.update( SetFeeToTonInitializeBottomSheetTransformer( appCurrencyProvider = Provider { appCurrency }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = it.normal, isFeeApproximate = false, ), ) } MultilineLambdaItParameter:StakingStateController.kt$StakingStateController${ it.copy( showColdWalletInteractionIcon = userWallet is UserWallet.Cold, ) } - MultilineLambdaItParameter:StakingTransactionSender.kt$StakingTransactionSender${ onConstructError(it) null } - MultilineLambdaItParameter:StakingTransactionSender.kt$StakingTransactionSender${ onConstructError(it) return emptyList() } - NoNameShadowing:StakingFeeTransactionLoader.kt$StakingFeeTransactionLoader$amount - NoNameShadowing:StakingFeeTransactionLoader.kt$StakingFeeTransactionLoader${ if (!it.amount.isZero()) return feeResult } NullCheckOnMutableProperty:StakingModel.kt$StakingModel$if (feeCryptoCurrencyStatus != null && fee != null) { getBalanceNotEnoughForFeeWarningUseCase( fee = fee, userWalletId = userWalletId, tokenStatus = cryptoCurrencyStatus, coinStatus = feeCryptoCurrencyStatus ?: cryptoCurrencyStatus, ).getOrNull() } else { null } NullableToStringCall:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$$networkId NullableToStringCall:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$$tokenId PropertyUsedBeforeDeclaration:StakingFeeBlock.kt$FeeBlockPreviewProvider$contentState PropertyUsedBeforeDeclaration:StakingModel.kt$StakingModel$isAmountSubtractAvailable PropertyUsedBeforeDeclaration:StakingStateController.kt$StakingStateController$uiState - ReusedModifierInstance:StakingClaimRewardsValidatorContent.kt$InputRowImageInfo( subtitle = item.title, caption = item.getAprTextNeutral(), infoTitle = item.formattedFiatAmount, infoSubtitle = item.formattedCryptoAmount, imageUrl = item.validator?.image.orEmpty(), onImageError = { ValidatorImagePlaceholder() }, modifier = modifier .roundedShapeItemDecoration(index, state.rewards.lastIndex, false) .background(TangemTheme.colors.background.action) .clickable( enabled = item.pendingActions.isNotEmpty(), onClick = { clickIntents.onActiveStake(item) }, ), ) SuspendFunSwallowedCancellation:StakingModel.kt$StakingModel$runCatching UnnecessaryEventHandlerParameter:StakingInitialInfoContent.kt$onClick: (BalanceState) -> Unit - UnnecessaryFilter:StakingInfoNotificationsFactory.kt$StakingInfoNotificationsFactory$filter { it.validator?.address == validatorAddress } UnnecessaryLet:StakingModel.kt$StakingModel$let(::add) UnnecessaryLet:StakingTosText.kt$let { onTextClick(PRIVACY_POLICY_URL) } UnnecessaryLet:StakingTosText.kt$let { onTextClick(TERMS_OF_USE_URL) } UnsafeCallOnNullableType:StakingModel.kt$StakingModel$tonAccountInitializeTransaction!! - UseOrEmpty:StakingTransactionSender.kt$StakingTransactionSender$getExplorerTransactionUrlUseCase( txHash = transactionHashes.last(), networkId = cryptoCurrencyStatus.currency.network.id, ).getOrNull() ?: "" VarCouldBeVal:StakingModel.kt$StakingModel$private var actionsJobHolder: JobHolder = JobHolder() VarCouldBeVal:StakingModel.kt$StakingModel$private var approvalJobHolder: JobHolder = JobHolder() VarCouldBeVal:StakingModel.kt$StakingModel$private var feeJobHolder: JobHolder = JobHolder() diff --git a/features/tester/impl/detekt-baseline-debug.xml b/features/tester/impl/detekt-baseline-debug.xml index be0960afae..84eba5dc41 100644 --- a/features/tester/impl/detekt-baseline-debug.xml +++ b/features/tester/impl/detekt-baseline-debug.xml @@ -3,7 +3,6 @@ BooleanPropertyNaming:ExcludedBlockchainsScreenUM.kt$ExcludedBlockchainsScreenUM$val showRecoverWarning: Boolean - CanBeNonNullable:TestPushDeepLinkBottomSheet.kt$config: TangemBottomSheetConfig? CastNullableToNonNullableType:TestPushMarketTokenSearchBottomSheetTransformer.kt$TestPushMarketTokenSearchBottomSheetTransformer$as CastNullableToNonNullableType:TestPushViewModel.kt$TestPushViewModel$as ExplicitCollectionElementAccessMethod:TestPushAddKeyDataTransformer.kt$TestPushAddKeyDataTransformer$mutableData.set(index = index, updated) diff --git a/features/wallet/impl/detekt-baseline-debug.xml b/features/wallet/impl/detekt-baseline-debug.xml index f3a1111f2a..4d05f69171 100644 --- a/features/wallet/impl/detekt-baseline-debug.xml +++ b/features/wallet/impl/detekt-baseline-debug.xml @@ -25,7 +25,6 @@ BooleanPropertyNaming:WalletScreen.kt$var visible by remember { mutableStateOf(value = false) } BooleanPropertyNaming:WalletScreenState.kt$WalletScreenState$val showMarketsOnboarding: Boolean BooleanPropertyNaming:WalletWithFundsChecker.kt$WalletWithFundsChecker$val prevStatus = statusByWalletId.get(userWalletId) - CanBeNonNullable:WalletScreen.kt$bottomSheetConfig: TangemBottomSheetConfig? IgnoredReturnValue:MultiWalletTokenListStore.kt$MultiWalletTokenListStore$remove(userWalletId) MaxChainedCallsOnSameLine:HasSingleWalletSignedHashesUseCase.kt$HasSingleWalletSignedHashesUseCase$userWallet.scanResponse.card.wallets.firstOrNull()?.totalSignedHashes MultilineLambdaItParameter:BasicTokenListSubscriber.kt$BasicTokenListSubscriber${ it.getOrElse { e -> Timber.e("Failed to load app currency: $e") AppCurrency.Default } } @@ -47,11 +46,7 @@ MultilineLambdaItParameter:SingleWalletExpressStatusesSubscriber.kt$SingleWalletExpressStatusesSubscriber${ Timber.e("Unable to get primary currency status: $it") return@onEach } MultilineLambdaItParameter:TokenListAnalyticsSender.kt$TokenListAnalyticsSender${ val status = it.value if (status is CryptoCurrencyStatus.Loaded) { sendTokenBalancesForSpecificBlockchains(it, status) } } MultilineLambdaItParameter:TokenListStateConverter.kt$TokenListStateConverter${ if (isExtend) { clickIntents.onAccountCollapseClick(it) } else { clickIntents.onAccountExpandClick(it) } } - MultilineLambdaItParameter:TxHistorySubscriber.kt$TxHistorySubscriber${ SetTxHistoryCountErrorTransformer( userWallet = userWallet, error = it, pendingTransactions = status.value.pendingTransactions, clickIntents = clickIntents, ) } - MultilineLambdaItParameter:TxHistorySubscriber.kt$TxHistorySubscriber${ SetTxHistoryCountTransformer( userWalletId = userWallet.walletId, transactionsCount = it, clickIntents = clickIntents, ) } MultilineLambdaItParameter:TxHistorySubscriber.kt$TxHistorySubscriber${ SetTxHistoryItemsErrorTransformer( userWalletId = userWallet.walletId, error = it, clickIntents = clickIntents, ) } - MultilineLambdaItParameter:TxHistorySubscriberV2.kt$TxHistorySubscriberV2${ SetTxHistoryCountErrorTransformer( userWallet = userWallet, error = it, pendingTransactions = status.value.pendingTransactions, clickIntents = clickIntents, ) } - MultilineLambdaItParameter:TxHistorySubscriberV2.kt$TxHistorySubscriberV2${ SetTxHistoryCountTransformer( userWalletId = userWallet.walletId, transactionsCount = it, clickIntents = clickIntents, ) } MultilineLambdaItParameter:TxHistorySubscriberV2.kt$TxHistorySubscriberV2${ SetTxHistoryItemsErrorTransformer( userWalletId = userWallet.walletId, error = it, clickIntents = clickIntents, ) } MultilineLambdaItParameter:UpdateMultiWalletActionsTransformer.kt$UpdateMultiWalletActionsTransformer${ when (it) { is WalletManageButton.Buy -> { it.copy( enabled = buyStatus.isContent(), dimContent = !buyStatus.isContent(), ) } is WalletManageButton.Sell -> { it.copy( enabled = sellStatus.isContent(), dimContent = !sellStatus.isContent(), ) } is WalletManageButton.Swap -> { it.copy( enabled = swapStatus.isContent(), dimContent = !swapStatus.isContent(), ) } else -> it } } MultilineLambdaItParameter:UseCaseExt.kt${ Timber.e("Impossible to get primary currency status $it") null } @@ -83,7 +78,6 @@ MultilineLambdaItParameter:WalletWarningsClickIntents.kt$WalletWarningsClickIntentsImplementor${ Timber.e( """ Unable to get user wallet |- ID: $userWalletId |- Exception: $it """.trimIndent(), ) null } MultilineLambdaItParameter:WalletWarningsClickIntents.kt$WalletWarningsClickIntentsImplementor${ router.openOnboardingScreen( scanResponse = it.scanResponse, continueBackup = true, ) } MultilineLambdaItParameter:WalletWithFundsChecker.kt$WalletWithFundsChecker${ val amount = it.value.amount ?: return@any false !amount.isZero() } - NamedArguments:BasicAccountListSubscriber.kt$BasicAccountListSubscriber$updateContent(convertParams, appCurrency, yieldSupplyApyMap, stakingApyMap) NamedArguments:GetMultiWalletWarningsFactory.kt$GetMultiWalletWarningsFactory$addInformationalNotifications(userWallet, cardTypesResolver, flattenCurrencies, clickIntents) NamedArguments:GetMultiWalletWarningsFactory.kt$GetMultiWalletWarningsFactory$addWarningNotifications(cardTypesResolver, flattenCurrencies, isNeedToBackup, clickIntents) NamedArguments:TangemSnapFlingBehavior.kt$HighVelocityApproachAnimation$animateDecay(offset, animationState, decayAnimationSpec, onAnimationStep) diff --git a/features/welcome/impl/detekt-baseline-debug.xml b/features/welcome/impl/detekt-baseline-debug.xml index 9c54bf2d00..4a47c5be0a 100644 --- a/features/welcome/impl/detekt-baseline-debug.xml +++ b/features/welcome/impl/detekt-baseline-debug.xml @@ -5,7 +5,6 @@ BooleanPropertyNaming:WelcomeModel.kt$WelcomeModel$private var routedOut = false BooleanPropertyNaming:WelcomeUM.kt$WelcomeUM.SelectWallet$val showUnlockWithBiometricButton: Boolean = false MultilineLambdaItParameter:WelcomeModel.kt$WelcomeModel${ if (it.isEmpty()) { router.replaceAll(AppRoute.Home()) } wallets.value = it } - MultilineLambdaItParameter:WelcomeModel.kt$WelcomeModel${ it.handle( specificWalletId = null, onUserCancelled = { tryToUnlockWithAccessCodeRightAway() }, ) setSelectWalletState() } ReusedModifierInstance:Welcome.kt$WelcomePlain(modifier = modifier) ReusedModifierInstance:Welcome.kt$WelcomeSelectWallet( state = st, modifier = modifier, ) From f3e7d4b0f639d3d264d6aef1b417ec1010e34aee Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 13 Jan 2026 14:21:40 +0700 Subject: [PATCH 082/172] Updated on 2026-08-14 --- .../walletsettings/model/WalletSettingsModel.kt | 2 +- .../walletsettings/utils/AccountItemsDelegate.kt | 15 +++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index b5ba400298..dbab06d6f6 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -200,7 +200,7 @@ internal class WalletSettingsModel @Inject constructor( isUpgradeNotificationEnabled: Boolean, accountList: List, ): PersistentList { - val isAccountsFeatureEnabled = accountsFeatureToggles.isFeatureEnabled + val isAccountsFeatureEnabled = accountItemsDelegate.isAccountsSupported(userWallet) val isMultiCurrency = when (userWallet) { is UserWallet.Cold -> userWallet.isMultiCurrency is UserWallet.Hot -> true diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt index 0d40442830..aa4c1a4b46 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt @@ -16,7 +16,6 @@ import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier -import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -45,7 +44,6 @@ internal class AccountItemsDelegate @Inject constructor( private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, - private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val accountListSortingSaver: AccountListSortingSaver, private val accountsFeatureToggles: AccountsFeatureToggles, private val analyticsEventHandler: AnalyticsEventHandler, @@ -53,15 +51,16 @@ internal class AccountItemsDelegate @Inject constructor( private val userWalletId = paramsContainer.require().userWalletId + fun isAccountsSupported(wallet: UserWallet) = accountsFeatureToggles.isFeatureEnabled && wallet.isAccountsSupported + fun loadAccount(wallet: UserWallet): Flow> { - if (!accountsFeatureToggles.isFeatureEnabled || !wallet.isAccountsSupported) return flowOf(emptyList()) + if (!isAccountsSupported(wallet)) return flowOf(emptyList()) return combine( flow = singleAccountStatusListSupplier(userWalletId), flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(), flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(), - flow4 = isAccountsModeEnabledUseCase(), - flow5 = accountListSortingSaver.accountsOrderFlow, + flow4 = accountListSortingSaver.accountsOrderFlow, transform = ::buildUiList, ) } @@ -70,7 +69,6 @@ internal class AccountItemsDelegate @Inject constructor( accountStatusList: AccountStatusList, appCurrency: AppCurrency, isBalanceHidden: Boolean, - isAccountsMode: Boolean, accountsOrder: List?, ): List = buildList { fun AccountStatus.CryptoPortfolio.mapCryptoPortfolio(): WalletSettingsAccountsUM { @@ -98,10 +96,7 @@ internal class AccountItemsDelegate @Inject constructor( ) add(header) - - if (isAccountsMode) { - addAll(accounts.map(::mapAccount).applySortingOrder(order = accountsOrder)) - } + addAll(accounts.map(::mapAccount).applySortingOrder(order = accountsOrder)) val isAddAccountEnabled = accounts.size < AccountList.MAX_ACCOUNTS_COUNT val shouldShowDescription = accounts.size > 1 From 38aaf7fd56900fd869b3d8c9816a2fb93359b659 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 13 Jan 2026 14:17:00 +0500 Subject: [PATCH 083/172] Updated on 2026-08-14 --- .../tap/di/domain/WalletsDomainModule.kt | 20 ------------- .../data/wallets/DefaultWalletsRepository.kt | 13 --------- .../wallets/repository/WalletsRepository.kt | 4 --- ...DismissUpgradeWalletNotificationUseCase.kt | 12 -------- ...UpgradeWalletNotificationEnabledUseCase.kt | 13 --------- .../hotwallet/accesscode/ui/AccessCode.kt | 2 +- .../forgetwallet/ForgetWalletModel.kt | 28 ++++++------------- .../forgetwallet/entity/ForgetWalletUM.kt | 6 ++-- .../forgetwallet/ui/ForgetWalletContent.kt | 17 +++-------- .../preview/PreviewWalletSettingsComponent.kt | 2 -- .../entity/WalletSettingsItemUM.kt | 1 - .../model/WalletSettingsModel.kt | 19 ++----------- .../walletsettings/ui/WalletSettingsScreen.kt | 2 -- .../walletsettings/utils/ItemsBuilder.kt | 27 ++++++------------ .../welcome/impl/ui/WelcomeSelectWallet.kt | 2 +- 15 files changed, 27 insertions(+), 141 deletions(-) delete mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt delete mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt index f6d5141d1e..374af96dd0 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt @@ -396,26 +396,6 @@ internal object WalletsDomainModule { ) } - @Provides - @Singleton - fun providesIsUpgradeWalletNotificationEnabledUseCase( - walletsRepository: WalletsRepository, - ): IsUpgradeWalletNotificationEnabledUseCase { - return IsUpgradeWalletNotificationEnabledUseCase( - walletsRepository = walletsRepository, - ) - } - - @Provides - @Singleton - fun providesDismissUpgradeWalletNotificationUseCase( - walletsRepository: WalletsRepository, - ): DismissUpgradeWalletNotificationUseCase { - return DismissUpgradeWalletNotificationUseCase( - walletsRepository = walletsRepository, - ) - } - @Provides @Singleton fun providesUnlockHotWalletContextualUseCase( diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt index 45b444d02f..def9a96402 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt @@ -50,9 +50,6 @@ internal class DefaultWalletsRepository( private val moshi: com.squareup.moshi.Moshi, ) : WalletsRepository { - private val upgradeWalletNotificationDisabled: MutableStateFlow> = - MutableStateFlow(mutableSetOf()) - @Deprecated("Hot wallet feature makes app always save user wallets. Do not use this method") override suspend fun shouldSaveUserWalletsSync(): Boolean { return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false) @@ -339,16 +336,6 @@ internal class DefaultWalletsRepository( } } - override fun isUpgradeWalletNotificationEnabled(userWalletId: UserWalletId): Flow { - return upgradeWalletNotificationDisabled.map { - it.contains(userWalletId) - } - } - - override suspend fun dismissUpgradeWalletNotification(userWalletId: UserWalletId) { - upgradeWalletNotificationDisabled.update { it.plus(userWalletId) } - } - override suspend fun setWalletName(walletId: UserWalletId, walletName: String) = withContext(dispatchers.io) { val userWallet = userWalletsStore.getSyncOrNull(key = walletId) diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt index 430653e85c..1431dc5e6f 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt @@ -60,10 +60,6 @@ interface WalletsRepository { suspend fun setNotificationsEnabled(userWalletId: UserWalletId, isEnabled: Boolean) - fun isUpgradeWalletNotificationEnabled(userWalletId: UserWalletId): Flow - - suspend fun dismissUpgradeWalletNotification(userWalletId: UserWalletId) - @Throws suspend fun setWalletName(walletId: UserWalletId, walletName: String) diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt deleted file mode 100644 index 2aeb755fba..0000000000 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.domain.wallets.usecase - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.repository.WalletsRepository - -class DismissUpgradeWalletNotificationUseCase( - private val walletsRepository: WalletsRepository, -) { - suspend operator fun invoke(userWalletId: UserWalletId) { - walletsRepository.dismissUpgradeWalletNotification(userWalletId) - } -} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt deleted file mode 100644 index d38fec4fe2..0000000000 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.domain.wallets.usecase - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.repository.WalletsRepository -import kotlinx.coroutines.flow.Flow - -class IsUpgradeWalletNotificationEnabledUseCase( - private val walletsRepository: WalletsRepository, -) { - operator fun invoke(userWalletId: UserWalletId): Flow { - return walletsRepository.isUpgradeWalletNotificationEnabled(userWalletId) - } -} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt index f92f63bdfe..0be023b18b 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt @@ -104,7 +104,7 @@ internal fun AccessCode( ) { PinTextField( length = state.accessCodeLength, - isPasswordVisual = state.isConfirmMode, + isPasswordVisual = true, value = state.accessCode, pinTextColor = state.accessCodeColor, onValueChange = state.onAccessCodeChange, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt index 88fc9882cd..a4d9c89ffb 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt @@ -38,31 +38,19 @@ internal class ForgetWalletModel @Inject constructor( field = MutableStateFlow( ForgetWalletUM( onBackClick = { router.pop() }, - firstCheckboxChecked = false, - secondCheckboxChecked = false, - onFirstCheckboxClick = ::onFirstCheckboxClick, - onSecondCheckboxClick = ::onSecondCheckboxClick, + isCheckboxChecked = false, + onCheckboxClick = ::onCheckboxClick, onForgetWalletClick = ::onForgetWalletClick, isForgetButtonEnabled = false, ), ) - private fun onFirstCheckboxClick() { - uiState.update { - val newValue = !it.firstCheckboxChecked - it.copy( - firstCheckboxChecked = newValue, - isForgetButtonEnabled = newValue && it.secondCheckboxChecked, - ) - } - } - - private fun onSecondCheckboxClick() { - uiState.update { - val newValue = !it.secondCheckboxChecked - it.copy( - secondCheckboxChecked = newValue, - isForgetButtonEnabled = it.firstCheckboxChecked && newValue, + private fun onCheckboxClick() { + uiState.update { currentState -> + val newValue = !currentState.isCheckboxChecked + currentState.copy( + isCheckboxChecked = newValue, + isForgetButtonEnabled = newValue, ) } } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/entity/ForgetWalletUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/entity/ForgetWalletUM.kt index 0cb0af23d1..f84f77d5c5 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/entity/ForgetWalletUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/entity/ForgetWalletUM.kt @@ -2,10 +2,8 @@ package com.tangem.features.hotwallet.forgetwallet.entity internal data class ForgetWalletUM( val onBackClick: () -> Unit, - val firstCheckboxChecked: Boolean, - val secondCheckboxChecked: Boolean, - val onFirstCheckboxClick: () -> Unit, - val onSecondCheckboxClick: () -> Unit, + val isCheckboxChecked: Boolean, + val onCheckboxClick: () -> Unit, val onForgetWalletClick: () -> Unit, val isForgetButtonEnabled: Boolean, ) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ui/ForgetWalletContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ui/ForgetWalletContent.kt index 5e4d2f42ec..5dfc22b13c 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ui/ForgetWalletContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ui/ForgetWalletContent.kt @@ -70,15 +70,8 @@ internal fun ForgetWalletContent(state: ForgetWalletUM, modifier: Modifier = Mod Spacer(modifier = Modifier.height(48.dp)) CheckboxItem( modifier = Modifier.padding(horizontal = 16.dp), - checked = state.firstCheckboxChecked, - onCheckedChange = state.onFirstCheckboxClick, - text = stringResourceSafe(R.string.hw_remove_wallet_warning_device), - ) - Spacer(modifier = Modifier.height(16.dp)) - CheckboxItem( - modifier = Modifier.padding(horizontal = 16.dp), - checked = state.secondCheckboxChecked, - onCheckedChange = state.onSecondCheckboxClick, + checked = state.isCheckboxChecked, + onCheckedChange = state.onCheckboxClick, text = stringResourceSafe(R.string.hw_remove_wallet_warning_access), ) Spacer(modifier = Modifier.height(32.dp)) @@ -142,10 +135,8 @@ private fun PreviewForgetWalletContent() { ForgetWalletContent( state = ForgetWalletUM( onBackClick = {}, - firstCheckboxChecked = true, - secondCheckboxChecked = false, - onFirstCheckboxClick = {}, - onSecondCheckboxClick = {}, + isCheckboxChecked = false, + onCheckboxClick = {}, onForgetWalletClick = {}, isForgetButtonEnabled = false, ), diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt index bdf3f94838..34449ac010 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt @@ -65,9 +65,7 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { onNotificationsDescriptionClick = {}, isNotificationsPermissionGranted = false, onAccessCodeClick = {}, - walletUpgradeDismissed = false, onUpgradeWalletClick = {}, - onDismissUpgradeWalletClick = {}, onBackupClick = {}, onCardSettingsClick = {}, accountsUM = previewAccounts(), diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt index 668efd566b..a495db9417 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt @@ -52,7 +52,6 @@ internal sealed class WalletSettingsItemUM { val title: TextReference, val description: TextReference, val onClick: () -> Unit, - val onDismissClick: () -> Unit, ) : WalletSettingsItemUM() } diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index dbab06d6f6..31edff8e10 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -86,8 +86,6 @@ internal class WalletSettingsModel @Inject constructor( private val settingsManager: SettingsManager, private val permissionsRepository: PermissionRepository, private val notificationsRepository: NotificationsRepository, - private val isUpgradeWalletNotificationEnabledUseCase: IsUpgradeWalletNotificationEnabledUseCase, - private val dismissUpgradeWalletNotificationUseCase: DismissUpgradeWalletNotificationUseCase, private val unlockHotWalletContextualUseCase: UnlockHotWalletContextualUseCase, private val accountsFeatureToggles: AccountsFeatureToggles, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, @@ -140,10 +138,9 @@ internal class WalletSettingsModel @Inject constructor( flow2 = getWalletNotificationsEnabledUseCase(params.userWalletId) .distinctUntilChanged() .conflate(), - flow3 = isUpgradeWalletNotificationEnabledUseCase(params.userWalletId), - flow4 = walletCardItemDelegate.cardItemFlow(wallet), - flow5 = accountItemsDelegate.loadAccount(wallet), - ) { nftEnabled, notificationsEnabled, isUpgradeNotificationEnabled, cardItem, accountList -> + flow3 = walletCardItemDelegate.cardItemFlow(wallet), + flow4 = accountItemsDelegate.loadAccount(wallet), + ) { nftEnabled, notificationsEnabled, cardItem, accountList -> val isWalletBackedUp = when (wallet) { is UserWallet.Hot -> wallet.backedUp is UserWallet.Cold -> true @@ -156,7 +153,6 @@ internal class WalletSettingsModel @Inject constructor( isNFTEnabled = nftEnabled, isNotificationsEnabled = notificationsEnabled, isNotificationsPermissionGranted = isNotificationsPermissionGranted(), - isUpgradeNotificationEnabled = isUpgradeNotificationEnabled, accountList = accountList, ), accountReorderUM = AccountReorderUM( @@ -197,7 +193,6 @@ internal class WalletSettingsModel @Inject constructor( isNFTEnabled: Boolean, isNotificationsEnabled: Boolean, isNotificationsPermissionGranted: Boolean, - isUpgradeNotificationEnabled: Boolean, accountList: List, ): PersistentList { val isAccountsFeatureEnabled = accountItemsDelegate.isAccountsSupported(userWallet) @@ -254,9 +249,7 @@ internal class WalletSettingsModel @Inject constructor( onCheckedNotificationsChanged = ::onCheckedNotificationsChange, onNotificationsDescriptionClick = ::onNotificationsDescriptionClick, onAccessCodeClick = { onAccessCodeClick(userWallet) }, - walletUpgradeDismissed = isUpgradeNotificationEnabled, onUpgradeWalletClick = { onUpgradeWalletClick() }, - onDismissUpgradeWalletClick = ::onDismissUpgradeWalletClick, onBackupClick = ::onBackupClick, onCardSettingsClick = ::onCardSettingsClick, accountsUM = accountList, @@ -411,12 +404,6 @@ internal class WalletSettingsModel @Inject constructor( } } - private fun onDismissUpgradeWalletClick() { - modelScope.launch { - dismissUpgradeWalletNotificationUseCase.invoke(params.userWalletId) - } - } - private fun onBackupClick() { analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonBackup()) router.push(AppRoute.WalletBackup(params.userWalletId)) diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt index 3041a0c1c6..7f9cb9d418 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt @@ -281,7 +281,6 @@ private fun UpgradeWalletBlock(model: WalletSettingsItemUM.UpgradeWallet, modifi iconResId = R.drawable.ic_hardware_backup_36, iconSize = 36.dp, onClick = model.onClick, - onCloseClick = model.onDismissClick, shouldShowArrowIcon = false, ), modifier = modifier, @@ -493,7 +492,6 @@ private fun Preview_WalletSettingsScreen1() { title = stringReference("Upgrade wallet with a hardware backup"), description = stringReference("Keep your crypto safe with Tangem’s best-in-class hardware wallet."), onClick = {}, - onDismissClick = {}, ), ) } diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt index ed3c42d51e..86b1637320 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt @@ -38,9 +38,7 @@ internal class ItemsBuilder @Inject constructor() { onReferralClick: () -> Unit, onManageTokensClick: () -> Unit, onAccessCodeClick: () -> Unit, - walletUpgradeDismissed: Boolean, onUpgradeWalletClick: () -> Unit, - onDismissUpgradeWalletClick: () -> Unit, onBackupClick: () -> Unit, onCardSettingsClick: () -> Unit, ): PersistentList = persistentListOf() @@ -48,9 +46,7 @@ internal class ItemsBuilder @Inject constructor() { .addAll( buildUpgradeWalletItem( userWallet = userWallet, - walletUpgradeDismissed = walletUpgradeDismissed, onUpgradeWalletClick = onUpgradeWalletClick, - onDismissUpgradeWalletClick = onDismissUpgradeWalletClick, ), ) .addAll(buildAccessCodeItem(userWallet, onAccessCodeClick)) @@ -122,24 +118,17 @@ internal class ItemsBuilder @Inject constructor() { private fun buildUpgradeWalletItem( userWallet: UserWallet, - walletUpgradeDismissed: Boolean, onUpgradeWalletClick: () -> Unit, - onDismissUpgradeWalletClick: () -> Unit, ): List = when (userWallet) { is UserWallet.Cold -> emptyList() - is UserWallet.Hot -> if (!walletUpgradeDismissed) { - listOf( - WalletSettingsItemUM.UpgradeWallet( - id = "upgrade_wallet", - title = resourceReference(id = R.string.hw_upgrade_to_cold_banner_title), - description = resourceReference(id = R.string.hw_upgrade_to_cold_banner_description), - onClick = onUpgradeWalletClick, - onDismissClick = onDismissUpgradeWalletClick, - ), - ) - } else { - emptyList() - } + is UserWallet.Hot -> listOf( + WalletSettingsItemUM.UpgradeWallet( + id = "upgrade_wallet", + title = resourceReference(id = R.string.hw_upgrade_to_cold_banner_title), + description = resourceReference(id = R.string.hw_upgrade_to_cold_banner_description), + onClick = onUpgradeWalletClick, + ), + ) } private fun buildNotificationsPermissionItem() = WalletSettingsItemUM.NotificationPermission( diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomeSelectWallet.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomeSelectWallet.kt index 17c774d9ad..0ede259e38 100644 --- a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomeSelectWallet.kt +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomeSelectWallet.kt @@ -83,7 +83,7 @@ internal fun AnimatedContentScope.WelcomeSelectWallet(state: WelcomeUM.SelectWal BottomFade(modifier = Modifier.align(Alignment.BottomCenter)) if (state.showUnlockWithBiometricButton) { - SecondaryButton( + PrimaryButton( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() From 7e1fcc18d1baaeabe7dcffc78ddce29d3b9d1e4d Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 13 Jan 2026 13:14:21 +0200 Subject: [PATCH 084/172] Updated on 2026-08-14 --- .../staking/model/P2PEthPoolIntegration.kt | 10 +++++++++ .../staking/model/StakeKitIntegration.kt | 12 ++++++++++ .../staking/model/StakingIntegration.kt | 4 ++++ .../domain/staking/model/StakingLegalUrls.kt | 6 +++++ .../impl/presentation/state/StakingUiState.kt | 6 +++++ .../previewdata/InitialStakingStatePreview.kt | 4 ++++ .../SetInitialDataStateTransformer.kt | 4 ++++ .../ui/StakingInitialInfoContent.kt | 17 +++++++++++--- .../impl/presentation/ui/StakingTosText.kt | 22 +++++++++---------- 9 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 domain/staking/src/main/java/com/tangem/domain/staking/model/StakingLegalUrls.kt diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt index a47b643544..7f779afa4e 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt @@ -73,6 +73,13 @@ class P2PEthPoolIntegration( override val rewardClaiming: RewardClaiming = RewardClaiming.AUTO + // Legal URLs + + override val legalUrls: StakingLegalUrls = StakingLegalUrls( + termsOfServiceUrl = TERMS_OF_SERVICE_URL, + privacyPolicyUrl = PRIVACY_POLICY_URL, + ) + override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = token private fun calculateMaximumStakeAmount(): BigDecimal? { @@ -88,5 +95,8 @@ class P2PEthPoolIntegration( private const val MIN_COOLDOWN_DAYS = 1 private const val MAX_COOLDOWN_DAYS = 4 private val DEFAULT_MINIMUM_STAKE = BigDecimal("0.01") + + private const val TERMS_OF_SERVICE_URL = "https://www.p2p.org/terms-of-use" + private const val PRIVACY_POLICY_URL = "https://www.p2p.org/privacy-policy" } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt index 540f4f4955..185d52ea8b 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt @@ -59,6 +59,13 @@ class StakeKitIntegration( override val rewardClaiming: RewardClaiming = yield.metadata.rewardClaiming.toRewardClaiming() + // Legal URLs + + override val legalUrls: StakingLegalUrls = StakingLegalUrls( + termsOfServiceUrl = TERMS_OF_SERVICE_URL, + privacyPolicyUrl = PRIVACY_POLICY_URL, + ) + // Basic override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = @@ -98,4 +105,9 @@ class StakeKitIntegration( Yield.Metadata.RewardClaiming.UNKNOWN -> RewardClaiming.UNKNOWN } } + + companion object { + private const val TERMS_OF_SERVICE_URL = "https://docs.yield.xyz/docs/terms-of-use" + private const val PRIVACY_POLICY_URL = "https://docs.yield.xyz/docs/privacy-policy" + } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt index b9b95360db..12895e0629 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt @@ -51,6 +51,10 @@ sealed interface StakingIntegration { val rewardClaiming: RewardClaiming + // Legal URLs + + val legalUrls: StakingLegalUrls + // Basic fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingLegalUrls.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingLegalUrls.kt new file mode 100644 index 0000000000..370e87a188 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingLegalUrls.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.staking.model + +data class StakingLegalUrls( + val termsOfServiceUrl: String, + val privacyPolicyUrl: String, +) \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt index 698d8f8181..1b6605e4ff 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt @@ -71,8 +71,14 @@ internal sealed class StakingStates { val onInfoClick: (InfoType) -> Unit, val yieldBalance: InnerYieldBalanceState, val pullToRefreshConfig: PullToRefreshConfig, + val legalUrls: LegalUrls, ) : InitialInfoState() + data class LegalUrls( + val termsOfServiceUrl: String, + val privacyPolicyUrl: String, + ) + data class Empty( override val isPrimaryButtonEnabled: Boolean = false, ) : InitialInfoState() diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt index bcad264866..c54d001d68 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt @@ -60,6 +60,10 @@ internal object InitialStakingStatePreview { onInfoClick = {}, yieldBalance = InnerYieldBalanceState.Empty, pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), + legalUrls = StakingStates.InitialInfoState.LegalUrls( + termsOfServiceUrl = "https://docs.yield.xyz/docs/terms-of-use", + privacyPolicyUrl = "https://docs.yield.xyz/docs/privacy-policy", + ), ) val stateWithYield = defaultState.copy( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index c1340346e2..1dbb49e096 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -104,6 +104,10 @@ internal class SetInitialDataStateTransformer( onRefresh = { clickIntents.onRefreshSwipe(it.value) }, isRefreshing = false, ), + legalUrls = StakingStates.InitialInfoState.LegalUrls( + termsOfServiceUrl = integration.legalUrls.termsOfServiceUrl, + privacyPolicyUrl = integration.legalUrls.privacyPolicyUrl, + ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt index 190331da23..61c54c78ea 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt @@ -112,7 +112,10 @@ internal fun StakingInitialInfoContent( item(STAKE_PRIMARY_BUTTON_KEY) { SpacerH12() - StakeButtonBlock(buttonState) + StakeButtonBlock( + legalUrls = state.legalUrls, + buttonState = buttonState, + ) } } } @@ -386,7 +389,10 @@ private fun RowScope.StakingBalanceIcon(balance: BalanceState, icon: Int?, iconT } @Composable -private fun StakeButtonBlock(buttonState: NavigationButtonsState) { +private fun StakeButtonBlock( + legalUrls: StakingStates.InitialInfoState.LegalUrls, + buttonState: NavigationButtonsState, +) { val state = buttonState as? NavigationButtonsState.Data val primaryButton = state?.primaryButton @@ -395,7 +401,12 @@ private fun StakeButtonBlock(buttonState: NavigationButtonsState) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { - state?.onTextClick?.let { StakingTosText(it) } + state?.let { buttonsState -> + StakingTosText( + onTermsOfServiceClick = { buttonsState.onTextClick(legalUrls.termsOfServiceUrl) }, + onPrivacyPolicyClick = { buttonsState.onTextClick(legalUrls.privacyPolicyUrl) }, + ) + } NavigationPrimaryButton(primaryButton = primaryButton) } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTosText.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTosText.kt index b09a9ab029..6a4b6f9d3c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTosText.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTosText.kt @@ -15,11 +15,8 @@ import com.tangem.features.staking.impl.R private const val TERMS_OF_USE_KEY = "termsOfUse" private const val PRIVACY_POLICY_KEY = "privacyPolicy" -private const val TERMS_OF_USE_URL = "https://docs.yield.xyz/docs/terms-of-use#/" -private const val PRIVACY_POLICY_URL = "https://docs.yield.xyz/docs/privacy-policy#/" - @Composable -internal fun StakingTosText(onTextClick: (String) -> Unit) { +internal fun StakingTosText(onTermsOfServiceClick: () -> Unit, onPrivacyPolicyClick: () -> Unit) { val termsOfUse = stringResourceSafe(R.string.common_terms_of_use) val privacyPolicy = stringResourceSafe(R.string.common_privacy_policy) val tosText = stringResourceSafe(R.string.staking_legal, termsOfUse, privacyPolicy) @@ -45,20 +42,23 @@ internal fun StakingTosText(onTextClick: (String) -> Unit) { textAlign = TextAlign.Center, ), onClick = { offset -> - clickableAnnotation.getStringAnnotations( + val isTermsOfUseClicked = clickableAnnotation.getStringAnnotations( tag = TERMS_OF_USE_KEY, start = offset, end = offset, - ).firstOrNull()?.let { - onTextClick(TERMS_OF_USE_URL) - } + ).any() - clickableAnnotation.getStringAnnotations( + val isPrivacyPolicyClicked = clickableAnnotation.getStringAnnotations( tag = PRIVACY_POLICY_KEY, start = offset, end = offset, - ).firstOrNull()?.let { - onTextClick(PRIVACY_POLICY_URL) + ).any() + + if (isTermsOfUseClicked) { + onTermsOfServiceClick() + } + if (isPrivacyPolicyClicked) { + onPrivacyPolicyClick() } }, modifier = Modifier.testTag(StakingDetailsScreenTestTags.TOS_TEXT), From ee4c5a872c939587071d61fbf444c3a5487af23b Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 13 Jan 2026 13:14:36 +0200 Subject: [PATCH 085/172] Updated on 2026-08-14 --- .../com/tangem/common/ui/tokens/TokenItemStateConverter.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt index dedbfff746..b66a09112f 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt @@ -284,6 +284,7 @@ class TokenItemStateConverter( val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit + val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool val rateInfo = when (val stakingOptions = stakingAvailability.option) { is StakingOption.P2PEthPool -> { @@ -316,7 +317,7 @@ class TokenItemStateConverter( return StakingLocalInfo( rate = rateInfo?.rate, - isActive = stakeKitBalance != null, // todo add p2p check + isActive = stakeKitBalance != null || p2pEthPoolBalance != null, rewardType = rateInfo?.type, ) } From 1ce06627c85b165548d05e0e4f4016251f5eaff6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 13 Jan 2026 15:13:18 +0300 Subject: [PATCH 086/172] Updated on 2026-08-14 --- .../datasource/api/gasless/GaslessTxServiceApi.kt | 8 ++++---- .../api/gasless/models/GaslessFeeRecipient.kt | 9 +++++++++ .../java/com/tangem/datasource/di/NetworkModule.kt | 7 +++++++ .../DefaultGaslessTransactionRepository.kt | 11 +++++++++-- .../MockedGaslessTransactionRepository.kt | 2 +- .../transaction/GaslessTransactionRepository.kt | 2 +- .../usecase/gasless/TokenFeeCalculatorTest.kt | 14 +++++++------- 7 files changed, 38 insertions(+), 15 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessFeeRecipient.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt index 72b01f4139..514dc9f20e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt @@ -1,10 +1,7 @@ package com.tangem.datasource.api.gasless import com.tangem.datasource.api.common.response.ApiResponse -import com.tangem.datasource.api.gasless.models.GaslessServiceResponse -import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO -import com.tangem.datasource.api.gasless.models.GaslessSupportedTokens -import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest +import com.tangem.datasource.api.gasless.models.* import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST @@ -18,4 +15,7 @@ interface GaslessTxServiceApi { suspend fun signGaslessTransaction( @Body transaction: GaslessTransactionRequest, ): ApiResponse> + + @GET("api/v1/config/fee-recipient") + suspend fun getFeeRecipient(): ApiResponse> } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessFeeRecipient.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessFeeRecipient.kt new file mode 100644 index 0000000000..f66c161faf --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessFeeRecipient.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaslessFeeRecipient( + @Json(name = "feeRecipientAddress") val address: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 8624353f33..25833cc27c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -37,6 +37,7 @@ import javax.inject.Singleton internal object NetworkModule { private const val TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS = 60L + private const val TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS = 60L private const val STAKE_KIT_API_TIMEOUT_SECONDS = 60L private const val P2P_ETH_POOL_API_TIMEOUT_SECONDS = 60L @@ -196,6 +197,12 @@ internal object NetworkModule { return retrofitApiBuilder.build( apiConfigId = ApiConfig.ID.GaslessTxService, applyTimeoutAnnotations = false, + timeouts = Timeouts( + callTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, + connectTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, + readTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, + writeTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, + ), ) } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt index f821a49766..9587374de8 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -69,8 +69,15 @@ class DefaultGaslessTransactionRepository( } } - override fun getTokenFeeReceiverAddress(): String { - return TOKEN_RECEIVER_ADDRESS + override suspend fun getTokenFeeReceiverAddress(): String { + return withContext(coroutineDispatcherProvider.io) { + val response = gaslessTxServiceApi.getFeeRecipient().getOrThrow() + if (response.isSuccess) { + response.result.address + } else { + error("Gasless service returned unsuccessful response") + } + } } override suspend fun signGaslessTransaction( diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt index ae642382e9..dd3e5600f5 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt @@ -34,7 +34,7 @@ class MockedGaslessTransactionRepository( return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}") } - override fun getTokenFeeReceiverAddress(): String { + override suspend fun getTokenFeeReceiverAddress(): String { return TOKEN_RECEIVER_ADDRESS } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt index bbef57a432..29d57171a6 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt @@ -11,7 +11,7 @@ interface GaslessTransactionRepository { suspend fun getSupportedTokens(network: Network): Set - fun getTokenFeeReceiverAddress(): String + suspend fun getTokenFeeReceiverAddress(): String /** * Sends gasless transaction to the gasless service for signing and returns the signed result. diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt index 2cd02cdee6..8e58e9f4eb 100644 --- a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt @@ -198,7 +198,7 @@ class TokenFeeCalculatorTest { val initialFee = createMockEIP1559Fee() coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) - every { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") // When @@ -231,7 +231,7 @@ class TokenFeeCalculatorTest { val initialFee = createMockEIP1559Fee() coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) - every { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") // When @@ -252,7 +252,7 @@ class TokenFeeCalculatorTest { @Test fun `calculateTokenFee should return error when fiatRate is null`() = runTest { coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) - every { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") // Given val tokenStatus = createMockTokenStatus( @@ -285,7 +285,7 @@ class TokenFeeCalculatorTest { val failure = Result.Failure(BlockchainSdkError.NPError("Gas limit fetch failed")) coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns failure - every { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" // When val result = tokenFeeCalculator.calculateTokenFee( @@ -314,7 +314,7 @@ class TokenFeeCalculatorTest { ) coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) - every { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") // When @@ -332,7 +332,7 @@ class TokenFeeCalculatorTest { @Test fun `calculateTokenFee should reject TokenCurrency as initial fee`() = runTest { coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) - every { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") // Given @@ -389,7 +389,7 @@ class TokenFeeCalculatorTest { val initialFee = createMockEIP1559Fee() coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) - every { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") // When From 54d1103944065ef28099daaa9a42bc2eac5223c9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 13 Jan 2026 13:14:55 +0100 Subject: [PATCH 087/172] Updated on 2026-08-14 --- .../MarketsListBatchFlowManager.kt | 2 +- .../feed/model/news/list/NewsListModel.kt | 2 +- .../NewsListStateManager.kt | 2 +- .../feed/ui/market/list/MarketsList.kt | 22 ++++++++++++------- .../list/components/NewsListLazyColumn.kt | 11 +++++++++- 5 files changed, 27 insertions(+), 12 deletions(-) rename features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/{calculator => statemanager}/NewsListStateManager.kt (96%) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt index 5e87294672..779f8af9e3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt @@ -55,7 +55,7 @@ internal class MarketsListBatchFlowManager( private val internalConverter: MarketsTokenItemConverter get() = MarketsTokenItemConverter( - currentTrendInterval = currentTrendInterval(), // Теперь тут всегда актуальное значение + currentTrendInterval = currentTrendInterval(), appCurrency = currentAppCurrency(), ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt index 1e205fe187..1acfe71d32 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt @@ -8,7 +8,7 @@ import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase import com.tangem.features.feed.components.news.list.DefaultNewsListComponent -import com.tangem.features.feed.model.news.list.calculator.NewsListStateManager +import com.tangem.features.feed.model.news.list.statemanager.NewsListStateManager import com.tangem.features.feed.model.news.list.loader.NewsCategoriesLoader import com.tangem.features.feed.model.news.list.statemanager.NewsListBatchFlowManager import com.tangem.features.feed.ui.news.list.state.NewsListState diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/calculator/NewsListStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListStateManager.kt similarity index 96% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/calculator/NewsListStateManager.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListStateManager.kt index b711909253..c4e30a7ff1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/calculator/NewsListStateManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListStateManager.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.model.news.list.calculator +package com.tangem.features.feed.model.news.list.statemanager import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.features.feed.ui.news.list.state.NewsListState diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt index 85ebbae800..ea208ba663 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt @@ -47,6 +47,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay private const val SHOW_MORE_KEY = "privacyPolicy" +private const val DELAY_FOR_FOCUS_REQUEST = 200L @Composable internal fun TopBarWithSearch( @@ -56,13 +57,17 @@ internal fun TopBarWithSearch( marketsSearchBar: MarketsSearchBar, ) { val background = LocalMainBottomSheetColor.current.value - val focusRequester: FocusRequester = remember { FocusRequester() } + val focusRequester = remember { FocusRequester() } + + val shouldShowAppBar = !marketsSearchBar.shouldAlwaysShowSearchBar && + !marketsSearchBar.searchBarUM.isActive && + marketsSearchBar.searchBarUM.query.isEmpty() AnimatedContent( - targetState = !marketsSearchBar.shouldAlwaysShowSearchBar && - !marketsSearchBar.searchBarUM.isActive && marketsSearchBar.searchBarUM.query.isEmpty(), - ) { showAppBarWithBackIcon -> - if (showAppBarWithBackIcon) { + targetState = shouldShowAppBar, + label = "TopBarTransition", + ) { showAppBar -> + if (showAppBar) { AppBarWithBackButtonAndIcon( onBackClick = onBackClick, backButtonEnabled = buttonsEnabled, @@ -85,9 +90,10 @@ internal fun TopBarWithSearch( ), focusRequester = focusRequester, ) - LaunchedEffect(marketsSearchBar.shouldAlwaysShowSearchBar) { - if (marketsSearchBar.shouldAlwaysShowSearchBar) { - delay(timeMillis = 200) + + if (marketsSearchBar.shouldAlwaysShowSearchBar || marketsSearchBar.searchBarUM.isActive) { + LaunchedEffect(Unit) { + delay(DELAY_FOR_FOCUS_REQUEST) focusRequester.requestFocus() } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt index 52d66787a6..1b06a93082 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt @@ -1,6 +1,9 @@ package com.tangem.features.feed.ui.news.list.components import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -44,7 +47,13 @@ internal fun NewsListLazyColumn( } } - AnimatedContent(targetState = screenState, label = "NewsListTransition") { state -> + AnimatedContent( + transitionSpec = { + fadeIn() togetherWith fadeOut() + }, + targetState = screenState, + label = "NewsListTransition", + ) { state -> when (state) { NewsListScreenState.Content -> { Content( From 4423770ba81b118727dde9b21441249ad4374b42 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 13 Jan 2026 19:15:23 +0700 Subject: [PATCH 088/172] Updated on 2026-08-14 --- .../com/tangem/domain/walletconnect/WcAnalyticEvents.kt | 2 +- .../account/analytics/AccountSettingsAnalyticEvents.kt | 1 + .../portfolio/impl/analytics/PortfolioAnalyticsEvent.kt | 4 ++-- .../com/tangem/feature/referral/analytics/ReferralEvents.kt | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt index aba74660de..3e6826fb6a 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt @@ -294,6 +294,6 @@ sealed class WcAnalyticAccountEvents( ) companion object { - const val WC_CATEGORY_ACCOUNT_NAME = "WalletConnect - Account" + const val WC_CATEGORY_ACCOUNT_NAME = "WalletConnect / Account" } } \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/analytics/AccountSettingsAnalyticEvents.kt b/features/account/impl/src/main/java/com/tangem/features/account/analytics/AccountSettingsAnalyticEvents.kt index dc8afe9786..02d6dd5ae5 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/analytics/AccountSettingsAnalyticEvents.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/analytics/AccountSettingsAnalyticEvents.kt @@ -84,6 +84,7 @@ sealed class AccountSettingsAnalyticEvents( event = "Account Error", params = buildMap { put("Error", error) + put("Source", source.value) }, ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt index 50b5428039..ac6125d3c0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt @@ -22,11 +22,11 @@ internal class PortfolioAnalyticsEvent( ) fun popupToChooseAccount() = PortfolioAnalyticsEvent( - event = "Popup to choose account", + event = "Choose Account Opened", ) fun addToNotMainAccount() = PortfolioAnalyticsEvent( - event = "Button - Add (token not to main Account)", + event = "Button - Add To Account", ) fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent(event = "Wallet Selected") diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/analytics/ReferralEvents.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/analytics/ReferralEvents.kt index 364b76ce0f..46154e0e95 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/analytics/ReferralEvents.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/analytics/ReferralEvents.kt @@ -14,8 +14,8 @@ sealed class ReferralEvents(event: String) : AnalyticsEvent(REFERRAL_PROGRAM_CAT sealed class ReferralEventsAccounts(event: String) : AnalyticsEvent(REFERRAL_PROGRAM_ACCOUNT_CATEGORY, event) { - class ListChooseAccount : ReferralEventsAccounts(event = "List - choose account") + class ListChooseAccount : ReferralEventsAccounts(event = "List - Choose Account") } private const val REFERRAL_PROGRAM_CATEGORY = "Referral Program" -private const val REFERRAL_PROGRAM_ACCOUNT_CATEGORY = "Referral program - Account" \ No newline at end of file +private const val REFERRAL_PROGRAM_ACCOUNT_CATEGORY = "Referral program / Account" \ No newline at end of file From c425d8640deb118d5b4278432b6d000b2aa1373f Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 12 Jan 2026 18:03:06 +0300 Subject: [PATCH 089/172] Updated on 2026-08-14 --- .../gasless/CreateAndSendGaslessTransactionUseCase.kt | 5 +++-- gradle/tangem_dependencies.toml | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt index c0bc106f89..27cbc7cd31 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -169,7 +169,7 @@ class CreateAndSendGaslessTransactionUseCase( val eip7702Auth = Eip7702Authorization( chainId = context.chainId, - address = transactionData.sourceAddress, + address = eip7702Data.executorAddress, nonce = eip7702Data.nonce, yParity = extendedEip7702Data.recId, r = extendedEip7702Data.r.toFormattedHex(), @@ -310,10 +310,11 @@ class CreateAndSendGaslessTransactionUseCase( private fun getDestinationAddress(txData: TransactionData.Uncompiled): String { val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData + val contractAddress = txData.contractAddress return if (ethereumCallData is EthereumYieldSupplySendCallData) { ethereumCallData.destinationAddress } else { - txData.destinationAddress + contractAddress ?: error("supports only Token transaction with contract address") } } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 65b0b9e0d3..f77f7250bc 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-1355" +tangemBlockchainSdk = "develop-1361" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From a5b29a4449523b1a00bf1c76c052ee05d1ea2675 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 14 Jan 2026 12:20:34 +0700 Subject: [PATCH 090/172] Updated on 2026-08-14 --- data/account/build.gradle.kts | 1 + .../data/account/di/AccountDataModule.kt | 34 +++++ .../DefaultAccountsExpandedRepository.kt | 66 +++++++++ .../DefaultAccountsExpandedRepositoryTest.kt | 125 ++++++++++++++++++ .../account/models/AccountExpandedState.kt | 8 ++ .../repository/AccountsExpandedRepository.kt | 14 ++ .../intents/WalletContentClickIntents.kt | 6 +- .../account/ExpandedAccountsHolder.kt | 102 +++++++++----- 8 files changed, 321 insertions(+), 35 deletions(-) create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsExpandedRepositoryTest.kt create mode 100644 domain/account/src/main/java/com/tangem/domain/account/models/AccountExpandedState.kt create mode 100644 domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index 7e38a27c0c..a07d7fac8a 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -70,5 +70,6 @@ dependencies { testImplementation(projects.common.test) testImplementation(projects.test.core) testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.turbine) // endregion } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt index 5dc4c61679..42eb66d25b 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -1,11 +1,16 @@ package com.tangem.data.account.di import android.content.Context +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.squareup.moshi.Moshi import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.data.account.converter.AccountConverterFactoryContainer import com.tangem.data.account.featuretoggle.DefaultAccountsFeatureToggles import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher +import com.tangem.data.account.repository.AccountsExpandedDTO import com.tangem.data.account.repository.DefaultAccountsCRUDRepository +import com.tangem.data.account.repository.DefaultAccountsExpandedRepository import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.store.ArchivedAccountsStoreFactory import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration @@ -13,11 +18,16 @@ import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.data.common.account.WalletAccountsSaver import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.accounts.AccountTokenMigrationStore import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.datasource.utils.MoshiDataStoreSerializer +import com.tangem.datasource.utils.mapWithStringKeyTypes +import com.tangem.datasource.utils.setTypes import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.repository.AccountsExpandedRepository import com.tangem.domain.account.tokens.MainAccountTokensMigration import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -25,6 +35,8 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -63,6 +75,28 @@ internal object AccountDataModule { ) } + @Provides + @Singleton + fun provideAccountsExpandedRepository( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + dispatchers: CoroutineDispatcherProvider, + ): AccountsExpandedRepository { + val store = DataStoreFactory.create>>( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = mapWithStringKeyTypes(valueTypes = setTypes()), + defaultValue = emptyMap(), + ), + produceFile = { context.dataStoreFile(fileName = "account_expanded_store") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ) + + return DefaultAccountsExpandedRepository( + store = store, + ) + } + @Provides @Singleton fun provideWalletAccountsFetcher(impl: DefaultWalletAccountsFetcher): WalletAccountsFetcher = impl diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt new file mode 100644 index 0000000000..6fab883946 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt @@ -0,0 +1,66 @@ +package com.tangem.data.account.repository + +import androidx.datastore.core.DataStore +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.data.account.converter.toAccountId +import com.tangem.domain.account.models.AccountExpandedState +import com.tangem.domain.account.repository.AccountsExpandedRepository +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +internal class DefaultAccountsExpandedRepository( + private val store: DataStore>>, +) : AccountsExpandedRepository { + + override val expandedAccounts: Flow>> = store.data + .map { stored -> + stored.map { (rawWalletId, setOfDto) -> + val walletId = UserWalletId(rawWalletId) + val setOfState = setOfDto.mapTo(mutableSetOf()) { dto -> + AccountExpandedState( + accountId = dto.accountId.toAccountId(walletId), + isExpanded = dto.isExpanded, + ) + } + walletId to setOfState + }.toMap() + } + + override suspend fun syncStore(walletId: UserWalletId, existAccounts: Set) { + store.updateData { map -> + val setOfDto = map[walletId.stringValue] ?: return@updateData map + val existingAccountIds = existAccounts.map { it.value }.toSet() + val syncedSet = setOfDto + .filterTo(mutableSetOf()) { (accountId, _) -> existingAccountIds.contains(accountId) } + + map.plus(walletId.stringValue to syncedSet) + } + } + + override suspend fun update(accountState: AccountExpandedState) { + store.updateData { map -> + val walletId = accountState.accountId.userWalletId + val setOfDto = map[walletId.stringValue].orEmpty() + val newDto = AccountsExpandedDTO( + accountId = accountState.accountId.value, + isExpanded = accountState.isExpanded, + ) + val updatedSet = setOfDto + .filterTo(mutableSetOf()) { it.accountId != accountState.accountId.value } + .plus(newDto) + + map.plus(walletId.stringValue to updatedSet) + } + } +} + +@JsonClass(generateAdapter = true) +internal data class AccountsExpandedDTO( + @Json(name = "accountId") + val accountId: String, + @Json(name = "isExpanded") + val isExpanded: Boolean, +) \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsExpandedRepositoryTest.kt b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsExpandedRepositoryTest.kt new file mode 100644 index 0000000000..78c1e93de4 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsExpandedRepositoryTest.kt @@ -0,0 +1,125 @@ +package com.tangem.data.account.repository + +import app.cash.turbine.test +import com.google.common.truth.Truth +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.domain.account.models.AccountExpandedState +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultAccountsExpandedRepositoryTest { + + private val walletId = UserWalletId("011") + private val mainAccountId = AccountId.forMainCryptoPortfolio(walletId) + private val secondAccountId = AccountId.forCryptoPortfolio(walletId, DerivationIndex(1).getOrNull()!!) + + @Test + fun `expandedAccounts emits updated state when store changes`() = runTest { + val dataStore = MockStateDataStore>>( + default = emptyMap() + ) + + val repository = DefaultAccountsExpandedRepository(dataStore) + + repository.expandedAccounts.test { + // initial emission + val initial = awaitItem() + Truth.assertThat(initial.isEmpty()).isTrue() + + // update store + dataStore.updateData { + mapOf( + walletId.stringValue to setOf( + AccountsExpandedDTO( + accountId = mainAccountId.value, + isExpanded = true + ) + ) + ) + } + + // next emission + val updated = awaitItem() + val states = updated[walletId]!! + + Truth.assertThat(states.size).isEqualTo(1) + val state = states.first() + + Truth.assertThat(state.accountId).isEqualTo(mainAccountId) + Truth.assertThat(state.isExpanded).isTrue() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `expandedAccounts emits when update is called`() = runTest { + val dataStore = MockStateDataStore>>( + default = emptyMap() + ) + + val repository = DefaultAccountsExpandedRepository(dataStore) + + val state = AccountExpandedState( + accountId = mainAccountId, + isExpanded = true + ) + + repository.expandedAccounts.test { + // initial + awaitItem() + + // when + repository.update(state) + + // then + val updated = awaitItem() + val states = updated[walletId]!! + + Truth.assertThat(states.size).isEqualTo(1) + Truth.assertThat(states.first().isExpanded).isTrue() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `expandedAccounts emits synced state after syncStore`() = runTest { + val dataStore = MockStateDataStore( + mapOf( + walletId.stringValue to setOf( + AccountsExpandedDTO(mainAccountId.value, true), + AccountsExpandedDTO(secondAccountId.value, false) + ) + ) + ) + + val repository = DefaultAccountsExpandedRepository(dataStore) + + repository.expandedAccounts.test { + // initial + val initial = awaitItem() + Truth.assertThat(initial[walletId]!!.size).isEqualTo(2) + + // when + repository.syncStore( + walletId = walletId, + existAccounts = setOf(mainAccountId) // without secondAccountId + ) + + // then + val synced = awaitItem() + val states = synced[walletId]!! + + Truth.assertThat(states.size).isEqualTo(1) + Truth.assertThat(states.first().accountId.value).isEqualTo(mainAccountId.value) + + cancelAndIgnoreRemainingEvents() + } + } +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/AccountExpandedState.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountExpandedState.kt new file mode 100644 index 0000000000..5da580f8eb --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountExpandedState.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.account.models + +import com.tangem.domain.models.account.AccountId + +data class AccountExpandedState( + val accountId: AccountId, + val isExpanded: Boolean, +) \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt new file mode 100644 index 0000000000..79e58ae8c3 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.account.repository + +import com.tangem.domain.account.models.AccountExpandedState +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow + +interface AccountsExpandedRepository { + + val expandedAccounts: Flow>> + + suspend fun syncStore(walletId: UserWalletId, existAccounts: Set) + suspend fun update(accountState: AccountExpandedState) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index 2fa16a3f5f..5b3b279808 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -233,15 +233,13 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( } override fun onAccountExpandClick(account: Account) { - val userWalletId = stateHolder.getSelectedWalletId() analyticsEventHandler.send(MainScreenAnalyticsEvent.AccountShowTokens()) - accountDependencies.expandedAccountsHolder.expandAccount(userWalletId, account.accountId) + accountDependencies.expandedAccountsHolder.expandAccount(account.accountId) } override fun onAccountCollapseClick(account: Account) { - val userWalletId = stateHolder.getSelectedWalletId() analyticsEventHandler.send(MainScreenAnalyticsEvent.AccountHideTokens()) - accountDependencies.expandedAccountsHolder.collapseAccount(userWalletId, account.accountId) + accountDependencies.expandedAccountsHolder.collapseAccount(account.accountId) } private fun openYieldSupply( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt index 47735160d8..e0f0d2f4bf 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt @@ -1,60 +1,100 @@ package com.tangem.feature.wallet.presentation.account import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.account.models.AccountExpandedState import com.tangem.domain.account.models.AccountList -import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.repository.AccountsExpandedRepository import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import javax.inject.Inject @ModelScoped internal class ExpandedAccountsHolder @Inject constructor( private val singleAccountListSupplier: SingleAccountListSupplier, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val accountsExpandedRepository: AccountsExpandedRepository, + private val dispatchers: CoroutineDispatcherProvider, ) { - private val expandedAccounts = MutableStateFlow>>(mapOf()) + private val actionChannel = MutableSharedFlow>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) fun expandedAccounts(userWallet: UserWallet): Flow> = channelFlow { - combine( - flow = walletAccounts(userWallet), - flow2 = isAccountsModeEnabledUseCase.invoke(), - transform = { accountList, isAccountsMode -> - val isSingleAccount = accountList.accounts.size == 1 - val defaultExpanded = when { - !isAccountsMode -> setOf() - isSingleAccount -> setOf(accountList.mainAccount.accountId) - else -> setOf() + val walletId = userWallet.walletId + + val storedState = accountsExpandedRepository.expandedAccounts + .map { it[walletId].orEmpty() } + .stateIn(this) + + val isAccountsMode = isAccountsModeEnabledUseCase.invoke() + .stateIn(this) + + val initExpandedState = storedState.value + .mapNotNull { it.takeIf { state -> state.isExpanded }?.accountId } + .toSet() + // main state holder + val expandedAccounts = MutableStateFlow(initExpandedState) + + actionChannel + .filter { (accountId, _) -> accountId.userWalletId == walletId } + .onEach { (accountId, isExpand) -> + val newState = AccountExpandedState(accountId, isExpand) + launch { accountsExpandedRepository.update(newState) } + if (isExpand) { + expandedAccounts.update { it.plus(accountId) } + } else { + expandedAccounts.update { it.minus(accountId) } } - expandedAccounts.update { map -> - var expandedSet = map[userWallet.walletId] ?: defaultExpanded - // force expand for single account - if (isSingleAccount || !isAccountsMode) expandedSet = defaultExpanded - map.plus(userWallet.walletId to expandedSet) + } + .launchIn(this) + + walletAccounts(walletId).onEach { accountList -> + val idsSet = accountList.accounts.mapTo(mutableSetOf()) { it.accountId } + accountsExpandedRepository.syncStore(walletId, idsSet) + + if (!isAccountsMode.value) return@onEach + + val isSingleAccount = accountList.accounts.size == 1 + val storedMainAccountState = storedState.value + .find { it.accountId == accountList.mainAccount.accountId } + + if (isSingleAccount && storedMainAccountState == null) { + // force expand for single and not stored account + expandedAccounts.update { setOf(accountList.mainAccount.accountId) } + } + }.launchIn(this) + + combine( + flow = expandedAccounts, + flow2 = isAccountsMode, + transform = { expanded, isAccountMode -> + if (isAccountMode) { + channel.send(expanded) + } else { + channel.send(setOf()) } }, - ).launchIn(this) + ).collect() + } + .flowOn(dispatchers.default) + .distinctUntilChanged() - expandedAccounts - .mapNotNull { map -> map[userWallet.walletId] } - .onEach { expanded -> channel.send(expanded) } - .collect() + fun expandAccount(accountId: AccountId) { + actionChannel.tryEmit(accountId to true) } - fun expandAccount(userWalletId: UserWalletId, accountId: AccountId) = expandedAccounts.update { map -> - val expandedSet = map[userWalletId]?.plus(accountId) ?: return@update map - map.plus(userWalletId to expandedSet) + fun collapseAccount(accountId: AccountId) { + actionChannel.tryEmit(accountId to false) } - fun collapseAccount(userWalletId: UserWalletId, accountId: AccountId) = expandedAccounts.update { map -> - val expandedSet = map[userWalletId]?.minus(accountId) ?: return@update map - map.plus(userWalletId to expandedSet) - } - - private fun walletAccounts(userWallet: UserWallet): Flow = - singleAccountListSupplier(SingleAccountListProducer.Params(userWallet.walletId)) + private fun walletAccounts(walletId: UserWalletId): Flow = singleAccountListSupplier(walletId) } \ No newline at end of file From f28ffbc0f9713a4833bf8730572c7e96e878254d Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 14 Jan 2026 16:00:05 +0700 Subject: [PATCH 091/172] Updated on 2026-08-14 --- .../managetokens/choosetoken/model/ChooseManagedTokensModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt index a877ba39b6..3698d18280 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt @@ -59,7 +59,7 @@ internal class ChooseManagedTokensModel @Inject constructor( private val params: ChooseManagedTokensComponent.Params = paramsContainer.require() private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory - .create(mode = ManageTokensMode.None) + .create(mode = ManageTokensMode.Wallet(params.userWalletId)) private val manageTokensListManager = manageTokensListManagerFactory.create( scope = modelScope, From 0ced6def88d5ca9483c3881f92c7993540e68b2a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 13 Jan 2026 15:12:33 +0300 Subject: [PATCH 092/172] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 4 + .../features/swap/SwapFeatureToggles.kt | 4 +- features/swap/impl/build.gradle.kts | 3 + .../feature/swap/DefaultSwapFeatureToggles.kt | 8 +- .../swap/converters/TokensDataConverterV2.kt | 2 + .../feature/swap/di/SwapFeatureModule.kt | 5 +- .../tangem/feature/swap/model/SwapModel.kt | 82 +++++- .../swap/models/SwapSelectTokenStateHolder.kt | 14 +- .../market/SwapMarketsListBatchFlowManager.kt | 234 ++++++++++++++++++ .../SwapMarketsTokenItemConverter.kt | 148 +++++++++++ .../models/market/state/SwapMarketState.kt | 25 ++ .../tangem/feature/swap/ui/StateBuilder.kt | 3 +- 12 files changed, 524 insertions(+), 8 deletions(-) create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 6fa865b480..9531c5a206 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -66,5 +66,9 @@ { "name": "GASLESS_TRANSACTIONS_ENABLED", "version": "5.33.0" + }, + { + "name": "SWAP_MARKET_LIST_ENABLED", + "version": "undefined" } ] diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt index e0fe076fab..d782e276ca 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt @@ -1,3 +1,5 @@ package com.tangem.features.swap -interface SwapFeatureToggles \ No newline at end of file +interface SwapFeatureToggles { + val isMarketListFeatureEnabled: Boolean +} \ No newline at end of file diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index 9be6f2453e..ebb3536b3e 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -23,6 +23,8 @@ dependencies { implementation(projects.common.ui) implementation(projects.core.decompose) // For Route supertype implementation(projects.core.error) + implementation(projects.common.uiMarkets) + implementation(projects.common.uiCharts) /** Domain modules **/ implementation(projects.domain.models) @@ -48,6 +50,7 @@ dependencies { implementation(projects.domain.account) implementation(projects.domain.account.status) implementation(projects.domain.visa) + implementation(projects.domain.markets) /** Feature modules */ implementation(projects.features.swap.domain) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index c202111fb6..60d50e49f0 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -1,5 +1,11 @@ package com.tangem.feature.swap +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.swap.SwapFeatureToggles -internal class DefaultSwapFeatureToggles : SwapFeatureToggles \ No newline at end of file +internal class DefaultSwapFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : SwapFeatureToggles { + override val isMarketListFeatureEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("SWAP_MARKET_LIST_ENABLED") +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt index 90dd691aaa..e7b9c5ee8c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt @@ -31,6 +31,7 @@ internal class TokensDataConverterV2( override fun transform(prevState: SwapStateHolder): SwapStateHolder { val accountList = tokensDataState.accountCurrencyList + val currentMarketsState = prevState.selectTokenState?.marketsState return prevState.copy( selectTokenState = SwapSelectTokenStateHolder( availableTokens = persistentListOf(), @@ -57,6 +58,7 @@ internal class TokensDataConverterV2( }.toPersistentList(), ) }, + marketsState = currentMarketsState, onSearchEntered = onSearchEntered, onTokenSelected = onTokenSelected, isBalanceHidden = isBalanceHidden, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt index 990f8c8ec9..5cf4ea502f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.di +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.feature.swap.DefaultSwapComponent import com.tangem.feature.swap.DefaultSwapFeatureToggles import com.tangem.features.swap.SwapComponent @@ -17,8 +18,8 @@ internal object SwapFeatureModule { @Provides @Singleton - fun provideSwapFeatureToggles(): SwapFeatureToggles { - return DefaultSwapFeatureToggles() + fun provideSwapFeatureToggles(featureTogglesManager: FeatureTogglesManager): SwapFeatureToggles { + return DefaultSwapFeatureToggles(featureTogglesManager) } } 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 1255f83a96..c73c840784 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 @@ -33,6 +33,7 @@ import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -64,6 +65,8 @@ import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.models.SwapStateHolder import com.tangem.feature.swap.models.UiActions +import com.tangem.feature.swap.models.market.SwapMarketsListBatchFlowManager +import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.feature.swap.presentation.R import com.tangem.feature.swap.router.SwapNavScreen @@ -71,6 +74,7 @@ import com.tangem.feature.swap.router.SwapRouter import com.tangem.feature.swap.ui.StateBuilder import com.tangem.feature.swap.utils.formatToUIRepresentation import com.tangem.features.swap.SwapComponent +import com.tangem.features.swap.SwapFeatureToggles import com.tangem.utils.Provider import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP import com.tangem.utils.coroutines.* @@ -120,6 +124,8 @@ internal class SwapModel @Inject constructor( private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase, private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, + private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, + private val swapFeatureToggles: SwapFeatureToggles, ) : Model() { private val params = paramsContainer.require() @@ -160,6 +166,7 @@ internal class SwapModel @Inject constructor( ?: error("NumberFormat is not DecimalFormat"), ) private val amountDebouncer = Debouncer() + private val searchDebouncer = Debouncer() private val singleTaskScheduler = SingleTaskScheduler>() private var dataState by mutableStateOf(SwapProcessDataState()) @@ -197,6 +204,19 @@ internal class SwapModel @Inject constructor( private var isAmountChangedByUser: Boolean = false private var lastPermissionNotificationTokens: Pair? = null + private val searchQueryState = MutableStateFlow("") + private val visibleMarketItemIds = MutableStateFlow>(emptyList()) + private val searchMarketsListManager by lazy { + SwapMarketsListBatchFlowManager( + getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, + batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, + currentAppCurrency = Provider { selectedAppCurrencyFlow.value }, + currentSearchText = Provider { searchQueryState.value }, + modelScope = modelScope, + dispatchers = dispatchers, + ) + } + val currentScreen: SwapNavScreen get() = swapRouter.currentScreen @@ -259,6 +279,61 @@ internal class SwapModel @Inject constructor( uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden) } .launchIn(modelScope) + + if (swapFeatureToggles.isMarketListFeatureEnabled) { + combine( + flow = searchQueryState + .onEach { searchQuery -> + searchMarketsListManager.reload(searchQuery) + }, + flow2 = searchMarketsListManager.uiItems, + flow3 = searchMarketsListManager.isInInitialLoadingErrorState, + flow4 = searchMarketsListManager.isSearchNotFoundState, + ) { searchQuery, uiItems, isError, isSearchNotFound -> + when { + searchQuery.isEmpty() -> { + visibleMarketItemIds.value = emptyList() + null + } + isError -> SwapMarketState.LoadingError( + onRetryClicked = { searchMarketsListManager.reload(searchQuery) }, + ) + isSearchNotFound -> SwapMarketState.SearchNothingFound + uiItems.isEmpty() -> SwapMarketState.Loading + else -> SwapMarketState.Content( + items = uiItems, + loadMore = { searchMarketsListManager.loadMore() }, + onItemClick = { item -> + // TODO [REDACTED_TASK_KEY] Add currency to swap form market list + }, + visibleIdsChanged = { visibleMarketItemIds.value = it }, + ) + } + } + .distinctUntilChanged() + .onEach { marketsState -> + uiState.selectTokenState?.let { currentSelectState -> + uiState = uiState.copy( + selectTokenState = currentSelectState.copy( + marketsState = marketsState, + ), + ) + } + } + .launchIn(modelScope) + } + + modelScope.launch { + visibleMarketItemIds.mapNotNull { rawIDS -> + if (rawIDS.isNotEmpty()) { + searchMarketsListManager.getBatchKeysByItemIds(rawIDS) + } else { + null + } + }.distinctUntilChanged().collectLatest { visibleBatchKeys -> + searchMarketsListManager.loadCharts(visibleBatchKeys) + } + } } fun onStart() { @@ -987,8 +1062,10 @@ internal class SwapModel @Inject constructor( } private fun onSearchEntered(searchQuery: String) { - modelScope.launch(dispatchers.io) { - val tokenDataState = dataState.tokensDataState ?: return@launch + searchDebouncer.debounce(modelScope, DEBOUNCE_SEARCH_DELAY) { + searchQueryState.value = searchQuery + + val tokenDataState = dataState.tokensDataState ?: return@debounce val group = if (isOrderReversed) { tokenDataState.fromGroup } else { @@ -1811,6 +1888,7 @@ internal class SwapModel @Inject constructor( const val INITIAL_AMOUNT = "" const val UPDATE_DELAY = 10000L const val DEBOUNCE_AMOUNT_DELAY = 1000L + const val DEBOUNCE_SEARCH_DELAY = 500L const val UPDATE_BALANCE_DELAY_MILLIS = 11000L const val CHANGELLY_PROVIDER_ID = "changelly" } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt index 8d9c1bf701..c6408b6467 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt @@ -3,12 +3,14 @@ package com.tangem.feature.swap.models import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.feature.swap.models.market.state.SwapMarketState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf internal data class SwapSelectTokenStateHolder( val availableTokens: ImmutableList, val unavailableTokens: ImmutableList, + val marketsState: SwapMarketState? = null, val tokensListData: TokenListUMData, val isBalanceHidden: Boolean, val isAfterSearch: Boolean, @@ -51,4 +53,14 @@ internal sealed interface TokenListUMData { data object EmptyList : TokenListUMData { override val tokensList: ImmutableList = persistentListOf() } -} \ No newline at end of file +} + +internal val SwapSelectTokenStateHolder.isNotFoundState: Boolean + get() = availableTokens.isEmpty() && unavailableTokens.isEmpty() && + tokensListData.tokensList.isEmpty() && isAfterSearch && + marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading + +internal val SwapSelectTokenStateHolder.isEmptyState: Boolean + get() = availableTokens.isEmpty() && unavailableTokens.isEmpty() && + tokensListData.tokensList.isEmpty() && !isAfterSearch && + marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt new file mode 100644 index 0000000000..7070acfa61 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt @@ -0,0 +1,234 @@ +package com.tangem.feature.swap.models.market + +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.* +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.feature.swap.models.market.converter.SwapMarketsTokenItemConverter +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchAction +import com.tangem.pagination.PaginationStatus +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +@Suppress("LongParameterList") +internal class SwapMarketsListBatchFlowManager( + getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, + private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType, + private val currentAppCurrency: Provider, + private val currentSearchText: Provider, + private val modelScope: CoroutineScope, + private val dispatchers: CoroutineDispatcherProvider, +) { + private val actionsFlow = MutableSharedFlow>() + private val updateStateJob = JobHolder() + + private val batchFlow = getMarketsTokenListFlowUseCase( + batchingContext = TokenListBatchingContext( + actionsFlow = actionsFlow, + coroutineScope = modelScope, + ), + batchFlowType = batchFlowType, + ) + + private val resultBatches = MutableStateFlow(ResultBatches()) + private val uiBatches = resultBatches.map { it.uiBatches } + + val uiItems: StateFlow> + get() = uiBatches + .map { batches -> + batches.asSequence() + .map { it.data } + .flatten() + .toImmutableList() + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = persistentListOf(), + ) + + val isInInitialLoadingErrorState = batchFlow.state + .map { it.status is PaginationStatus.InitialLoadingError } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + + val isSearchNotFoundState = batchFlow.state + .map { batchListState -> + currentSearchText().isNullOrEmpty().not() && + batchListState.status is PaginationStatus.EndOfPagination && + batchListState.data.isEmpty() + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + + init { + batchFlow.state + .map { it.data } + .distinctUntilChanged { a, b -> + a.size == b.size && + a.map { it.key } == b.map { it.key } && + a.map { it.data }.flatten() == b.map { it.data }.flatten() + } + .onEach { + coroutineScope { + launch { + updateState(it) + }.saveIn(updateStateJob) + } + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private suspend fun updateState(newList: List>>, forceUpdate: Boolean = false) = + withContext(dispatchers.default) { + resultBatches.update { resultBatches -> + val items = resultBatches.uiBatches + val previousList = resultBatches.processedItems + + val converter = SwapMarketsTokenItemConverter(appCurrency = currentAppCurrency()) + + if (newList.isEmpty()) { + return@update ResultBatches(processedItems = emptyList()) + } + + val isInitialLoading = + forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key + + val outItems = if (isInitialLoading) { + newList.map { batch -> + Batch( + key = batch.key, + data = converter.convertList(batch.data), + ) + } + } else { + if (previousList.size != newList.size) { + val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) + val newBatches = newList.filter { keysToAdd.contains(it.key) } + + items + newBatches.map { batch -> + Batch( + key = batch.key, + data = converter.convertList(batch.data), + ) + } + } else { + items.mapIndexed { batchIndex, batch -> + val prevBatch = previousList[batchIndex] + val newBatch = newList[batchIndex] + if (prevBatch == newBatch) return@mapIndexed batch + + Batch( + key = batch.key, + data = batch.data.mapIndexed { index, marketsListItemUM -> + val prevItem = prevBatch.data.getOrNull(index) + val newItem = newBatch.data.getOrNull(index) + if (prevItem != null && newItem != null) { + converter.update(prevItem, marketsListItemUM, newItem) + } else { + newItem?.let { converter.convert(it) } ?: marketsListItemUM + } + }, + ) + } + } + } + + currentCoroutineContext().ensureActive() + + ResultBatches( + uiBatches = outItems, + processedItems = newList, + ) + } + } + + fun reload(searchText: String? = null) { + modelScope.launch { + resultBatches.value = ResultBatches() + actionsFlow.emit( + BatchAction.Reload( + requestParams = TokenMarketListConfig( + fiatPriceCurrency = currentAppCurrency().code, + searchText = if (currentSearchText() == null) { + null + } else { + searchText ?: currentSearchText() + }, + priceChangeInterval = TokenMarketListConfig.Interval.H24, + order = TokenMarketListConfig.Order.ByRating, + ), + ), + ) + } + } + + fun loadMore() { + modelScope.launch { + actionsFlow.emit(BatchAction.LoadMore()) + } + } + + fun loadCharts(batchKeys: Set) { + if (batchKeys.isEmpty()) return + + modelScope.launch { + val currentData = batchFlow.state.value.data + val alreadyLoadedChartsBatchKeys = currentData + .filter { batch -> + val first = batch.data.firstOrNull() ?: return@filter false + first.tokenCharts.h24 != null + } + .map { it.key } + .toSet() + + val batchesKeysToLoad = batchKeys.minus(alreadyLoadedChartsBatchKeys) + + if (batchesKeysToLoad.isNotEmpty()) { + actionsFlow.emit( + BatchAction.UpdateBatches( + keys = batchesKeysToLoad, + updateRequest = TokenMarketUpdateRequest.UpdateChart( + interval = TokenMarketListConfig.Interval.H24, + currency = currentAppCurrency().code, + ), + async = true, + operationId = batchesKeysToLoad.toString() + "h24", + ), + ) + } + } + } + + fun getBatchKeysByItemIds(ids: List): Set { + val currentData = batchFlow.state.value.data + + return currentData + .filter { d -> d.data.any { ids.contains(it.id) } } + .map { it.key } + .toSet() + } + + private data class ResultBatches( + val uiBatches: List>> = emptyList(), + val processedItems: List>>? = null, + ) +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt new file mode 100644 index 0000000000..78c52b2aab --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt @@ -0,0 +1,148 @@ +package com.tangem.feature.swap.models.market.converter + +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter +import com.tangem.common.ui.charts.state.sorted +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.compact +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.core.ui.format.bigdecimal.price +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarket +import com.tangem.feature.swap.presentation.R +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal +import java.math.RoundingMode + +internal class SwapMarketsTokenItemConverter( + private val appCurrency: AppCurrency, +) : Converter { + + private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false) + + override fun convert(value: TokenMarket): MarketsListItemUM { + return MarketsListItemUM( + id = value.id, + name = value.name, + currencySymbol = value.symbol, + ratingPosition = value.marketRating?.toString(), + marketCap = value.getMarketCap(), + iconUrl = value.imageUrlLarge, + price = value.getCurrentPrice(), + trendPercentText = value.getTrendPercent(), + trendType = value.getTrendType(), + chartData = value.getChartData(), + isUnder100kMarketCap = value.isUnderMarketCapLimit, + stakingRate = value.yieldRate?.format { percent() }?.let { + resourceReference(R.string.markets_apy_placeholder, wrappedList(it)) + }, + updateTimestamp = value.updateTimestamp, + ) + } + + fun update(prev: TokenMarket, prevUI: MarketsListItemUM, new: TokenMarket): MarketsListItemUM { + require(prev.id == new.id) { + "Ids is not the same during update TokenMarket item: previousItem[${prev.id}] != newItem[${new.id}]" + } + + return prevUI.copy( + name = new.name, + currencySymbol = new.symbol, + ratingPosition = new.marketRating?.toString(), + marketCap = ifChanged(prev.marketCap, new.marketCap, prevUI.marketCap) { new.getMarketCap() }, + iconUrl = new.imageUrlLarge, + price = ifChanged(prev = prev.tokenQuotesShort, new = new.tokenQuotesShort, prevR = prevUI.price) { + new.getCurrentPrice( + prev = prev, + ) + }, + trendPercentText = ifChanged( + prev.tokenQuotesShort, + new.tokenQuotesShort, + prevUI.trendPercentText, + ) { new.getTrendPercent() }, + trendType = ifChanged(prev.tokenQuotesShort, new.tokenQuotesShort, prevUI.trendType) { new.getTrendType() }, + chartData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chartData) { new.getChartData() }, + ) + } + + private inline fun ifChanged(prev: T, new: T, prevR: R, force: Boolean = false, change: (T) -> R): R { + return if (force || prev != new) change(new) else prevR + } + + private fun TokenMarket.getMarketCap(): String? { + val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null + + return value.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ).compact( + threeDigitsMethod = true, + ) + } + } + + private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price { + val prevPrice = prev?.tokenQuotesShort?.currentPrice + + val priceText = tokenQuotesShort.currentPrice.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ).price() + } + + val changeType = if (prevPrice != null) { + if (tokenQuotesShort.currentPrice > prevPrice) { + PriceChangeType.UP + } else { + PriceChangeType.DOWN + } + } else { + null + } + + return MarketsListItemUM.Price( + text = priceText, + changeType = changeType, + ) + } + + private fun TokenMarket.getChartData(): MarketChartRawData? { + val chart = tokenCharts.h24 + + return chart?.let { ct -> + priceAndTimePointValuesConverter.convert( + MarketChartData.Data( + y = ct.priceY.toImmutableList(), + x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(), + ).sorted(), + ) + } + } + + @Suppress("MagicNumber") + private fun TokenMarket.getTrendType(): PriceChangeType { + val percent = tokenQuotesShort.h24ChangePercent + val scaled = percent?.setScale(4, RoundingMode.HALF_UP) + return when { + scaled == null -> PriceChangeType.NEUTRAL + scaled > BigDecimal.ZERO -> PriceChangeType.UP + scaled < BigDecimal.ZERO -> PriceChangeType.DOWN + else -> PriceChangeType.NEUTRAL + } + } + + private fun TokenMarket.getTrendPercent(): String { + val percent = tokenQuotesShort.h24ChangePercent + return percent.format { percent() } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt new file mode 100644 index 0000000000..8a84fb4d8b --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt @@ -0,0 +1,25 @@ +package com.tangem.feature.swap.models.market.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.domain.models.currency.CryptoCurrency +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class SwapMarketState { + + data class Content( + val items: ImmutableList, + val loadMore: () -> Unit, + val onItemClick: (MarketsListItemUM) -> Unit, + val visibleIdsChanged: (List) -> Unit, + ) : SwapMarketState() + + data object Loading : SwapMarketState() + + data class LoadingError( + val onRetryClicked: () -> Unit, + ) : SwapMarketState() + + data object SearchNothingFound : SwapMarketState() +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 860cd0017c..fcf5220ed6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -617,13 +617,14 @@ internal class StateBuilder( fromToken: CryptoCurrency, tokensDataState: CurrenciesGroup, ): SwapStateHolder { + val currentMarketsState = uiState.selectTokenState?.marketsState return uiState.copy( selectTokenState = tokensDataConverter.convert( value = CurrenciesGroupWithFromCurrency( fromCurrency = fromToken, group = tokensDataState, ), - ), + ).copy(marketsState = currentMarketsState), ) } From 3ed3dc91b1d2942badafa014dea2fa2df0ae139c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 14 Jan 2026 13:41:35 +0400 Subject: [PATCH 093/172] Updated on 2026-08-14 --- .../src/main/assets/configs/feature_toggles_config.json | 4 ++++ .../features/yield/supply/api/YieldSupplyFeatureToggles.kt | 1 + .../yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt | 3 +++ 3 files changed, 8 insertions(+) diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 9531c5a206..36923953bd 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -51,6 +51,10 @@ "name": "YIELD_SUPPLY_FEATURE_ENABLED", "version": "5.30.0" }, + { + "name": "YIELD_SUPPLY_PENDING_TRANSACTIONS_ENABLED", + "version": "undefined" + }, { "name": "NEW_ONRAMP_MAIN_ENABLED", "version": "5.31.0" diff --git a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt index 93edb89872..37c2e71379 100644 --- a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt +++ b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt @@ -3,4 +3,5 @@ package com.tangem.features.yield.supply.api interface YieldSupplyFeatureToggles { val isYieldSupplyFeatureEnabled: Boolean + val isYieldSupplyPendingTransactionsEnabled: Boolean } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt index db476c617b..4f0a442329 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt @@ -8,4 +8,7 @@ internal class DefaultYieldSupplyFeatureToggles( ) : YieldSupplyFeatureToggles { override val isYieldSupplyFeatureEnabled: Boolean get() = featureToggles.isFeatureEnabled("YIELD_SUPPLY_FEATURE_ENABLED") + + override val isYieldSupplyPendingTransactionsEnabled: Boolean + get() = featureToggles.isFeatureEnabled("YIELD_SUPPLY_PENDING_TRANSACTIONS_ENABLED") } \ No newline at end of file From 7c0cc2d75ab63628b3993e46a3ca015bb3153b4c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 14 Jan 2026 16:01:54 +0500 Subject: [PATCH 094/172] Updated on 2026-08-14 --- .../ui/components/CurrencyPlaceholderIcon.kt | 51 ------- .../com/tangem/core/ui/components/Dialogs.kt | 56 ------- .../core/ui/components/MiddleEllipsisText.kt | 142 ------------------ .../core/ui/components/rows/ActionRow.kt | 86 ----------- .../components/states/SelectableItemsState.kt | 17 --- .../com/tangem/core/ui/utils/ScrollUtils.kt | 15 -- 6 files changed, 367 deletions(-) delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/CurrencyPlaceholderIcon.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/rows/ActionRow.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/states/SelectableItemsState.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/utils/ScrollUtils.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/CurrencyPlaceholderIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/CurrencyPlaceholderIcon.kt deleted file mode 100644 index 0cfef1c3d7..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/CurrencyPlaceholderIcon.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.tangem.core.ui.components - -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.res.TangemTheme - -/** - * Currency placeholder icon - * - * @param id currency id - * @param modifier modifier - */ -@Composable -fun CurrencyPlaceholderIcon(id: String, modifier: Modifier = Modifier) { - Box( - contentAlignment = Alignment.Center, - modifier = modifier.background(color = TangemTheme.colors.icon.secondary, shape = CircleShape), - ) { - Text( - text = id.firstOrNull()?.titlecase().orEmpty(), - modifier = Modifier.padding(TangemTheme.dimens.spacing4), - color = TangemTheme.colors.text.primary2, - textAlign = TextAlign.Center, - // FIXME("Incorrect typography. Replace with typography from design system") - style = TangemTheme.typography.h3.copy(fontWeight = FontWeight.Bold), - ) - } -} - -// region preview - -@Preview(showBackground = true, heightDp = 40, widthDp = 40) -@Preview(showBackground = true, heightDp = 40, widthDp = 40, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun Preview_CurrencyPlaceholderIcon() { - TangemThemePreview { - CurrencyPlaceholderIcon(id = "DAI") - } -} -// endregion preview \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt index faeb20ff2f..bf788f49a2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt @@ -70,17 +70,6 @@ fun BasicDialog( ) } -@Composable -fun SimpleOkDialog(message: String, onDismissDialog: () -> Unit) { - TangemDialog( - type = DialogType.Message(message), - confirmButton = DialogButtonUM(onClick = onDismissDialog), - onDismissDialog = onDismissDialog, - title = null, - dismissButton = null, - ) -} - /** * Dialog with text field * @@ -124,34 +113,6 @@ fun TextInputDialog( ) } -@Composable -fun TextInputDialog( - fieldValue: String, - confirmButton: DialogButtonUM, - onDismissDialog: () -> Unit, - onValueChange: (String) -> Unit, - textFieldParams: AdditionalTextInputDialogUM = remember { AdditionalTextInputDialogUM() }, - title: String? = null, - dismissButton: DialogButtonUM? = null, - isDismissable: Boolean = true, -) { - TangemDialog( - type = DialogType.SimpleTextInput( - value = fieldValue, - onValueChange = onValueChange, - params = textFieldParams, - ), - confirmButton = confirmButton, - onDismissDialog = onDismissDialog, - title = title, - dismissButton = dismissButton, - properties = DialogProperties( - dismissOnBackPress = isDismissable, - dismissOnClickOutside = isDismissable, - ), - ) -} - @Composable fun SelectorDialog( selectedItemIndex: Int, @@ -441,14 +402,6 @@ private sealed class DialogType { // endregion Defaults // region Preview -@Composable -private fun SimpleOkDialogPreview() { - SimpleOkDialog( - message = "All protected passwords will be deleted from the " + - "secure storage, you must enter the wallet password to work with the app.", - ) {} -} - @Composable private fun BasicDialogPreview() { BasicDialog( @@ -461,15 +414,6 @@ private fun BasicDialogPreview() { ) } -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun Preview_SimpleOkDialog() { - TangemThemePreview { - SimpleOkDialogPreview() - } -} - @Preview(showBackground = true) @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt b/core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt deleted file mode 100644 index 892ef05493..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt +++ /dev/null @@ -1,142 +0,0 @@ -package com.tangem.core.ui.components - -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.SubcomposeLayout -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.TextUnit -import com.tangem.core.ui.components.atoms.text.BoundCounter - -/** - * https://stackoverflow.com/questions/69083061/how-to-make-middle-ellipsis-in-text-with-jetpack-compose - */ -@Deprecated("Use EllipsisText with TextEllipsis.Middle ellipsis instead") -@Suppress("LongMethod") -@Composable -fun MiddleEllipsisText( - text: String, - modifier: Modifier = Modifier, - color: Color = Color.Unspecified, - fontSize: TextUnit = TextUnit.Unspecified, - fontStyle: FontStyle? = null, - fontWeight: FontWeight? = null, - fontFamily: FontFamily? = null, - letterSpacing: TextUnit = TextUnit.Unspecified, - textDecoration: TextDecoration? = null, - textAlign: TextAlign? = null, - lineHeight: TextUnit = TextUnit.Unspecified, - softWrap: Boolean = true, - onTextLayout: (TextLayoutResult) -> Unit = {}, - style: TextStyle = LocalTextStyle.current, -) { - // some letters, like "r", will have less width when placed right before "." - // adding a space to prevent such case - val layoutText = remember(text) { "$text $ellipsisText" } - val textLayoutResultState = remember(layoutText) { - mutableStateOf(null) - } - SubcomposeLayout(modifier) { constraints -> - // result is ignored - we only need to fill our textLayoutResult - subcompose("measure") { - Text( - text = layoutText, - color = color, - fontSize = fontSize, - fontStyle = fontStyle, - fontWeight = fontWeight, - fontFamily = fontFamily, - letterSpacing = letterSpacing, - textDecoration = textDecoration, - textAlign = textAlign, - lineHeight = lineHeight, - softWrap = softWrap, - maxLines = 1, - onTextLayout = { textLayoutResultState.value = it }, - style = style, - ) - }.first().measure(Constraints()) - // to allow smart cast - val textLayoutResult = textLayoutResultState.value - ?: // shouldn't happen - onTextLayout is called before subcompose finishes - return@SubcomposeLayout layout(0, 0) {} - val placeable = subcompose("visible") { - val finalText = remember(text, textLayoutResult, constraints.maxWidth) { - if ( - text.isEmpty() || - textLayoutResult.getBoundingBox(text.indices.last).right <= constraints.maxWidth - ) { - // text not including ellipsis fits on the first line. - return@remember text - } - - val ellipsisWidth = layoutText.indices.toList() - .takeLast(ELLIPSIS_CHARACTERS_COUNT) - .let widthLet@{ indices -> - // fix this bug: https://issuetracker.google.com/issues/197146630 - // in this case width is invalid - for (i in indices) { - val width = textLayoutResult.getBoundingBox(i).width - if (width > 0) { - return@widthLet width * ELLIPSIS_CHARACTERS_COUNT - } - } - // this should not happen, because - // this error occurs only for the last character in the string - error("all ellipsis chars have invalid width") - } - val availableWidth = constraints.maxWidth - ellipsisWidth - val startCounter = BoundCounter(text, textLayoutResult) { it } - val endCounter = BoundCounter(text, textLayoutResult) { text.indices.last - it } - - while (availableWidth - startCounter.width - endCounter.width > 0) { - val possibleEndWidth = endCounter.widthWithNextChar() - if ( - startCounter.width >= possibleEndWidth && - availableWidth - startCounter.width - possibleEndWidth >= 0 - ) { - endCounter.addNextChar() - } else if (availableWidth - startCounter.widthWithNextChar() - endCounter.width >= 0) { - startCounter.addNextChar() - } else { - break - } - } - startCounter.string.trimEnd() + ellipsisText + endCounter.string.reversed().trimStart() - } - Text( - text = finalText, - color = color, - fontSize = fontSize, - fontStyle = fontStyle, - fontWeight = fontWeight, - fontFamily = fontFamily, - letterSpacing = letterSpacing, - textDecoration = textDecoration, - textAlign = textAlign, - lineHeight = lineHeight, - softWrap = softWrap, - onTextLayout = onTextLayout, - style = style, - ) - }[0].measure(constraints) - layout(placeable.width, placeable.height) { - placeable.place(0, 0) - } - } -} - -private const val ELLIPSIS_CHARACTERS_COUNT = 3 -private const val ELLIPSIS_CHARACTER = '.' -private val ellipsisText = List(ELLIPSIS_CHARACTERS_COUNT) { ELLIPSIS_CHARACTER }.joinToString(separator = "") \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/ActionRow.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/ActionRow.kt deleted file mode 100644 index 781bc8b851..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/rows/ActionRow.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.tangem.core.ui.components.rows - -import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerH28 -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview - -/** - * Simple clickable action row, without input and icon - * - * https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-807&mode=design&t=Ygv5sohTTHYAQcBS-4 - */ -@Composable -fun SimpleActionRow(title: String, description: String, modifier: Modifier = Modifier, isClickable: Boolean = true) { - Box( - modifier = modifier - .background(color = TangemTheme.colors.background.action) - .height(TangemTheme.dimens.size44) - .fillMaxWidth(), - ) { - Column( - modifier = Modifier - .padding(end = TangemTheme.dimens.spacing48) - .align(Alignment.CenterStart), - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), - ) { - AnimatedContent(targetState = title, label = "") { title -> - Text( - text = title, - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.secondary, - ) - } - AnimatedContent(targetState = description, label = "") { description -> - Text( - text = description, - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.primary1, - ) - } - } - - if (isClickable) { - Icon( - painter = painterResource(id = R.drawable.ic_chevron_right_24), - contentDescription = null, - modifier = Modifier - .align(alignment = Alignment.CenterEnd) - .padding(end = TangemTheme.dimens.spacing12), - tint = TangemTheme.colors.icon.informative, - ) - } - } -} - -@Preview -@Composable -private fun SimpleActionRowPreview() { - Column { - TangemThemePreview(isDark = false) { - SimpleActionRow( - title = "Title", - description = "Description", - ) - } - - SpacerH28() - - TangemThemePreview(isDark = false) { - SimpleActionRow( - title = "Title", - description = "Description", - ) - } - } -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/states/SelectableItemsState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/states/SelectableItemsState.kt deleted file mode 100644 index 0d7b55596b..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/states/SelectableItemsState.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.core.ui.components.states - -import com.tangem.core.ui.extensions.TextReference -import kotlinx.collections.immutable.ImmutableList - -data class SelectableItemsState( - val selectedItem: Item, - val items: ImmutableList>, -) - -data class Item( - val id: Int, - val startText: TextReference, - val endText: TextReference, - val isSelected: Boolean, - val data: T, -) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/ScrollUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/ScrollUtils.kt deleted file mode 100644 index 3cef274d3a..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/ScrollUtils.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.core.ui.utils - -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll - -fun Modifier.disableNestedScroll(): Modifier = nestedScroll(DisableParentConnection) - -private object DisableParentConnection : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - return available.copy(x = 0f) - } -} \ No newline at end of file From 1b73add2508366a3ea4e0af81e373951905121c2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 14 Jan 2026 16:02:34 +0500 Subject: [PATCH 095/172] Updated on 2026-08-14 --- .../tangem/core/ui/ds/badge/TangemBadge.kt | 23 ++ .../tangem/core/ui/ds/badge/TangemBadgeUM.kt | 27 ++ .../core/ui/ds/row/TangemRowContainer.kt | 189 +++++++++++++ .../core/ui/ds/row/token/TangemTokenRow.kt | 168 +++++++++++ .../core/ui/ds/row/token/TangemTokenRowUM.kt | 172 +++++++++++ .../internal/TangemTokenRowPreviewData.kt | 266 ++++++++++++++++++ .../internal/TokenRowEndBottomContent.kt | 100 +++++++ .../token/internal/TokenRowEndTopContent.kt | 115 ++++++++ .../internal/TokenRowPriceChangeContent.kt | 71 +++++ .../row/token/internal/TokenRowPromoBanner.kt | 107 +++++++ .../ds/row/token/internal/TokenRowSubtitle.kt | 100 +++++++ .../ui/ds/row/token/internal/TokenRowTail.kt | 81 ++++++ .../ui/ds/row/token/internal/TokenRowTitle.kt | 101 +++++++ .../core/ui/extensions/ColorReference.kt | 17 +- .../main/res/drawable/ic_down_dynamic_24.xml | 9 + core/ui/src/main/res/drawable/ic_drag_24.xml | 8 +- .../res/drawable/ic_static_dynamic_24.xml | 9 + .../main/res/drawable/ic_up_dynamic_24.xml | 9 + 18 files changed, 1568 insertions(+), 4 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRow.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRowUM.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndBottomContent.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndTopContent.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPromoBanner.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowSubtitle.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowTail.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowTitle.kt create mode 100644 core/ui/src/main/res/drawable/ic_down_dynamic_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_static_dynamic_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_up_dynamic_24.xml diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt index 1c9fd7cf14..f41322e237 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt @@ -29,6 +29,29 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +/** + * Tangem badge component to display a small piece of information with optional icon. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8441-83535&m=dev) + * + * @param badgeUM [TangemBadgeUM] containing all the badge parameters. + * @param modifier Modifier to be applied to the badge. + * +[REDACTED_AUTHOR] + */ +@Composable +fun TangemBadge(badgeUM: TangemBadgeUM, modifier: Modifier = Modifier) { + TangemBadge( + text = badgeUM.text, + iconRes = badgeUM.iconRes, + size = badgeUM.size, + shape = badgeUM.shape, + color = badgeUM.color, + type = badgeUM.type, + iconPosition = badgeUM.iconPosition, + modifier = modifier, + ) +} + /** * Tangem badge component to display a small piece of information with optional icon. * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8441-83535&m=dev) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt new file mode 100644 index 0000000000..e28cbdd6ba --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt @@ -0,0 +1,27 @@ +package com.tangem.core.ui.ds.badge + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.ds.badge.TangemBadgeSize.X9 +import com.tangem.core.ui.extensions.TextReference + +/** + * UI model for [TangemBadge] component + * + * @param text TextReference for the badge label. + * @param modifier Modifier to be applied to the badge. + * @param iconRes Drawable resource ID for the icon to be displayed in the badge. + * @param size [TangemBadgeSize] defining the size of the badge. + * @param shape [TangemBadgeShape] defining the shape of the badge. + * @param color [TangemBadgeColor] defining the color scheme of the badge. + * @param type [TangemBadgeType] defining the style of the badge. + * @param iconPosition [TangemBadgeIconPosition] defining icon position of the badge. + */ +class TangemBadgeUM( + val text: TextReference, + @DrawableRes val iconRes: Int? = null, + val size: TangemBadgeSize = X9, + val shape: TangemBadgeShape = TangemBadgeShape.Default, + val color: TangemBadgeColor = TangemBadgeColor.Gray, + val type: TangemBadgeType = TangemBadgeType.Solid, + val iconPosition: TangemBadgeIconPosition = TangemBadgeIconPosition.Start, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt new file mode 100644 index 0000000000..b1a24c2067 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt @@ -0,0 +1,189 @@ +package com.tangem.core.ui.ds.row + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Constraints +import com.tangem.core.ui.res.TangemTheme +import kotlin.math.max + +/** + * A custom layout composable that arranges its children in a row with specific layout IDs. + */ +internal enum class TangemRowLayoutId { + HEAD, START_TOP, END_TOP, START_BOTTOM, END_BOTTOM, TAIL, EXTRA_TOP +} + +/** + * A custom layout composable that arranges its children in a row with specific layout IDs. + * + * @param modifier Modifier to be applied to the layout. + * @param contentPadding Padding values to be applied around the content. + * @param content Composable content to be laid out within the TangemRow. + */ +@Suppress("LongMethod") +@Composable +internal fun TangemRowContainer( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(TangemTheme.dimens2.x3), + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + val localDirection = LocalLayoutDirection.current + val verticalPadding = with(density) { TangemTheme.dimens2.x1.roundToPx() } + val contentTopPadding = with(density) { contentPadding.calculateTopPadding().roundToPx() } + val contentBottomPadding = with(density) { contentPadding.calculateBottomPadding().roundToPx() } + val contentStartPadding = with(density) { contentPadding.calculateLeftPadding(localDirection).roundToPx() } + val contentEndPadding = with(density) { contentPadding.calculateRightPadding(localDirection).roundToPx() } + Layout( + content = content, + modifier = modifier, + ) { measurables, constraints -> + val layoutWidth = constraints.maxWidth - contentStartPadding - contentEndPadding + + val startTopMinWidth = (layoutWidth * TITLE_MIN_WIDTH_COEFFICIENT).toInt() + val startBottomMinWidth = (layoutWidth * PRICE_MIN_WIDTH_COEFFICIENT).toInt() + + // Head composable measurement + val headPlaceable = measurables.measure( + layoutId = TangemRowLayoutId.HEAD, + constraints = constraints, + ) + + // Tail composable measurement + val tailPlaceable = measurables.measure( + layoutId = TangemRowLayoutId.TAIL, + constraints = constraints, + ) + + val availableWidthForBody = layoutWidth - headPlaceable.widthOrZero() - tailPlaceable.widthOrZero() + + // End top composable width must take the whole free space but is not greater the Start top min size + val endTopPlaceable = measurables.measure( + layoutId = TangemRowLayoutId.END_TOP, + constraints = constraints.copy( + minWidth = 0, + maxWidth = availableWidthForBody - startTopMinWidth, + ), + ) + + // End bottom composable width must take the whole free space but is not greater the Start bottom min size + val endBottomPlaceable = measurables.measure( + layoutId = TangemRowLayoutId.END_BOTTOM, + constraints = constraints.copy( + minWidth = 0, + maxWidth = availableWidthForBody - startBottomMinWidth, + ), + ) + + /* Start top composable will take take the whole REMAINING width space width but no less than minimum */ + val startTopPlaceable = measurables.measure( + layoutId = TangemRowLayoutId.START_TOP, + constraints = constraints.copy( + minWidth = startTopMinWidth, + maxWidth = max( + a = startTopMinWidth, + b = availableWidthForBody - endTopPlaceable.widthOrZero(), + ), + ), + ) + + /* Start bottom composable will take take the whole REMAINING width space width but no less than minimum */ + val startBottomPlaceable = measurables.measure( + layoutId = TangemRowLayoutId.START_BOTTOM, + constraints = constraints.copy( + minWidth = startBottomMinWidth, + maxWidth = max( + a = startBottomMinWidth, + b = availableWidthForBody - endBottomPlaceable.widthOrZero(), + ), + ), + ) + + val extraTopPlaceable = measurables.measure( + layoutId = TangemRowLayoutId.EXTRA_TOP, + constraints = constraints, + ) + + val mainLayoutHeight = maxOf( + headPlaceable.heightOrZero(), + tailPlaceable.heightOrZero(), + startTopPlaceable.heightOrZero() + startBottomPlaceable.heightOrZero() + verticalPadding, + endTopPlaceable.heightOrZero() + endBottomPlaceable.heightOrZero() + verticalPadding, + ) + + val mainContentTopPadding = if (extraTopPlaceable != null) { + extraTopPlaceable.heightOrZero() + } else { + contentTopPadding + } + + val layoutHeight = mainLayoutHeight + mainContentTopPadding + contentBottomPadding + + layout(width = constraints.maxWidth, height = layoutHeight) { + extraTopPlaceable?.placeRelative(x = 0, y = 0) + + headPlaceable?.placeRelative( + x = contentStartPadding, + y = mainContentTopPadding + (mainLayoutHeight - headPlaceable.height).div(other = 2), + ) + + startTopPlaceable?.placeRelative( + x = contentStartPadding + headPlaceable.widthOrZero(), + y = mainContentTopPadding + if (startBottomPlaceable == null) { + (mainLayoutHeight - startTopPlaceable.height).div(2) + } else { + 0 + }, + ) + + startBottomPlaceable?.placeRelative( + x = contentStartPadding + headPlaceable.widthOrZero(), + y = mainContentTopPadding + if (startTopPlaceable == null) { + (mainLayoutHeight - startBottomPlaceable.height).div(2) + } else { + startTopPlaceable.heightOrZero() + verticalPadding + }, + ) + + endTopPlaceable?.placeRelative( + x = layoutWidth - endTopPlaceable.widthOrZero() - tailPlaceable.widthOrZero() + contentEndPadding, + y = mainContentTopPadding + if (endBottomPlaceable == null) { + (mainLayoutHeight - endTopPlaceable.height).div(2) + } else { + 0 + }, + ) + + endBottomPlaceable?.placeRelative( + x = layoutWidth - endBottomPlaceable.widthOrZero() - tailPlaceable.widthOrZero() + contentEndPadding, + y = mainContentTopPadding + if (endTopPlaceable == null) { + (mainLayoutHeight - endBottomPlaceable.height).div(2) + } else { + endTopPlaceable.heightOrZero() + verticalPadding + }, + ) + + tailPlaceable?.placeRelative( + x = layoutWidth - tailPlaceable.width + contentEndPadding, + y = mainContentTopPadding + (mainLayoutHeight - tailPlaceable.height).div(other = 2), + ) + } + } +} + +private const val TITLE_MIN_WIDTH_COEFFICIENT = 0.3 +private const val PRICE_MIN_WIDTH_COEFFICIENT = 0.32 + +private fun List.measure(layoutId: TangemRowLayoutId, constraints: Constraints): Placeable? { + return firstOrNull { it.layoutId == layoutId }?.measure(constraints) +} + +private fun Placeable?.widthOrZero(): Int = this?.width ?: 0 +private fun Placeable?.heightOrZero(): Int = this?.height ?: 0 \ No newline at end of file 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 new file mode 100644 index 0000000000..8d904b5ce7 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRow.kt @@ -0,0 +1,168 @@ +package com.tangem.core.ui.ds.row.token + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.ds.row.token.internal.* +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 reorderableTokenListState The state of the reorderable lazy list, if applicable. + * @param modifier The modifier to be applied to the row. + */ +@Composable +fun TangemTokenRow( + tokenRowUM: TangemTokenRowUM, + isBalanceHidden: Boolean, + reorderableTokenListState: ReorderableLazyListState?, + modifier: Modifier = Modifier, +) { + TangemRowContainer( + content = { + CurrencyIcon( + state = tokenRowUM.iconState, + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x2) + .testTag(TokenElementsTestTags.TOKEN_ICON), + ) + + TokenRowPromoBanner( + promoBannerUM = tokenRowUM.promoBannerUM, + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.EXTRA_TOP) + .testTag(TokenElementsTestTags.TOKEN_YIELD_PROMO_BANNER) + .padding(horizontal = TangemTheme.dimens2.x3) + .fillMaxWidth(), + ) + + TokenRowTitle( + titleUM = tokenRowUM.titleUM, + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.START_TOP) + .padding(end = TangemTheme.dimens2.x2) + .testTag(tag = TokenElementsTestTags.TOKEN_TITLE), + ) + + TokenRowSubtitle( + subtitleUM = tokenRowUM.subtitleUM, + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.START_BOTTOM) + .padding(end = TangemTheme.dimens2.x2) + .testTag(tag = TokenElementsTestTags.TOKEN_PRICE), + ) + + TokenRowEndTopContent( + endContentUM = tokenRowUM.topEndContentUM, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.END_TOP) + .testTag(tag = TokenElementsTestTags.TOKEN_FIAT_AMOUNT), + ) + + TokenRowEndBottomContent( + endContentUM = tokenRowUM.bottomEndContentUM, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.END_BOTTOM) + .testTag(tag = TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT), + ) + + TokenRowTail( + tailUM = tokenRowUM.tailUM, + reorderableTokenListState = reorderableTokenListState, + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.TAIL) + .testTag(TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK), + ) + }, + modifier = modifier + .tokenClickable(tokenRowUM = tokenRowUM), + ) +} + +@OptIn(ExperimentalFoundationApi::class) +private fun Modifier.tokenClickable(tokenRowUM: TangemTokenRowUM): Modifier = composed { + val hapticFeedback = LocalHapticFeedback.current + + val onClick = tokenRowUM.onItemClick + val onLongClick = tokenRowUM.onItemLongClick + val onHapticLongClick = if (onLongClick != null) { + { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClick(tokenRowUM) + } + } else { + null + } + + when { + onClick == null && onLongClick == null -> this + onClick == null && onLongClick != null -> combinedClickable(onClick = {}, onLongClick = onHapticLongClick) + onClick != null && onLongClick == null -> combinedClickable(onClick = { onClick(tokenRowUM) }) + onClick != null && onLongClick != null -> { + combinedClickable(onClick = { onClick(tokenRowUM) }, onLongClick = onHapticLongClick) + } + else -> this + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemTokenRow_Preview( + @PreviewParameter(TangemTokenRowPreviewProvider::class) tokenRowUM: TangemTokenRowUM, +) { + TangemThemePreviewRedesign { + TangemTokenRow( + tokenRowUM = tokenRowUM, + isBalanceHidden = false, + reorderableTokenListState = null, + modifier = Modifier.background(TangemTheme.colors2.surface.level1), + ) + } +} + +private class TangemTokenRowPreviewProvider : CollectionPreviewParameterProvider( + collection = listOf( + TangemTokenRowPreviewData.defaultState, + TangemTokenRowPreviewData.defaultEllipsisState, + TangemTokenRowPreviewData.tokenState, + TangemTokenRowPreviewData.customTokenState, + TangemTokenRowPreviewData.draggableState, + TangemTokenRowPreviewData.draggableStateV2, + TangemTokenRowPreviewData.loadingState, + TangemTokenRowPreviewData.unreachableState, + TangemTokenRowPreviewData.accountState, + TangemTokenRowPreviewData.accountLetterState, + TangemTokenRowPreviewData.accountEllipsisState, + TangemTokenRowPreviewData.promoBannerState, + ), +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRowUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRowUM.kt new file mode 100644 index 0000000000..8ef54a567b --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRowUM.kt @@ -0,0 +1,172 @@ +package com.tangem.core.ui.ds.row.token + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.ds.badge.TangemBadgeUM +import com.tangem.core.ui.extensions.ColorReference2 +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.TangemTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +sealed class TangemTokenRowUM { + + /** Unique id */ + abstract val id: String + + /** Token icon state */ + abstract val iconState: CurrencyIconState + + /** Token title UM (in one row with [topEndContentUM]) */ + abstract val titleUM: TitleUM + + /** Token subtitle UM (in one row with [bottomEndContentUM]) */ + abstract val subtitleUM: SubtitleUM + + /** Top end content UM (e.i. fiat amount in one row with [titleUM]) */ + abstract val topEndContentUM: EndContentUM + + /** Bottom end content UM (e.i. crypto amount in one row with [subtitleUM]) */ + abstract val bottomEndContentUM: EndContentUM + + /** Token row tail UM */ + abstract val tailUM: TailUM + + /** Promo banner UM */ + abstract val promoBannerUM: PromoBannerUM + + /** Callback which will be called when an item is clicked */ + abstract val onItemClick: ((TangemTokenRowUM) -> Unit)? + + /** Callback which will be called when an item is long clicked */ + abstract val onItemLongClick: ((TangemTokenRowUM) -> Unit)? + + /** + * Content state of [TangemTokenRowUM] + */ + data class Content( + override val id: String, + override val iconState: CurrencyIconState, + override val titleUM: TitleUM, + override val subtitleUM: SubtitleUM, + override val topEndContentUM: EndContentUM, + override val bottomEndContentUM: EndContentUM, + override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty, + override val tailUM: TailUM = TailUM.Empty, + override val onItemClick: ((TangemTokenRowUM) -> Unit)?, + override val onItemLongClick: ((TangemTokenRowUM) -> Unit)?, + ) : TangemTokenRowUM() + + /** + * Loading state of [TangemTokenRowUM] + */ + data class Loading( + override val id: String, + override val iconState: CurrencyIconState, + override val titleUM: TitleUM = TitleUM.Loading, + override val subtitleUM: SubtitleUM = SubtitleUM.Loading, + ) : TangemTokenRowUM() { + override val topEndContentUM: EndContentUM = EndContentUM.Loading + override val bottomEndContentUM: EndContentUM = EndContentUM.Loading + override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty + override val tailUM: TailUM = TailUM.Empty + override val onItemClick: ((TangemTokenRowUM) -> Unit)? = null + override val onItemLongClick: ((TangemTokenRowUM) -> Unit)? = null + } + + /** + * Actionable state of [TangemTokenRowUM] + */ + data class Actionable( + override val id: String, + override val iconState: CurrencyIconState, + override val titleUM: TitleUM, + override val subtitleUM: SubtitleUM, + override val tailUM: TailUM, + override val onItemClick: ((TangemTokenRowUM) -> Unit)?, + override val onItemLongClick: ((TangemTokenRowUM) -> Unit)?, + override val topEndContentUM: EndContentUM = EndContentUM.Empty, + override val bottomEndContentUM: EndContentUM = EndContentUM.Empty, + ) : TangemTokenRowUM() { + override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty + } + + @Immutable + sealed class TitleUM { + + data class Content( + val text: TextReference, + val hasPending: Boolean = false, + val isAvailable: Boolean = true, + val badge: TangemBadgeUM? = null, + val onBadgeClick: (() -> Unit)? = null, + ) : TitleUM() + + data object Loading : TitleUM() + + data object Empty : TitleUM() + } + + @Immutable + sealed class SubtitleUM { + + data class Content( + val text: TextReference, + val isAvailable: Boolean = true, + val isFlickering: Boolean = false, + val icons: ImmutableList = persistentListOf(), + val priceChangeUM: PriceChangeState = PriceChangeState.Unknown, + val badge: TangemBadgeUM? = null, + ) : SubtitleUM() + + data object Loading : SubtitleUM() + + data object Empty : SubtitleUM() + } + + @Immutable + sealed class EndContentUM { + + data class Content( + val text: TextReference, + val isAvailable: Boolean = true, + val isFlickering: Boolean = false, + val icons: ImmutableList = persistentListOf(), + val priceChangeUM: PriceChangeState = PriceChangeState.Unknown, + ) : EndContentUM() + + data object Loading : EndContentUM() + + data object Empty : EndContentUM() + } + + @Immutable + sealed class PromoBannerUM { + data class Content( + val title: TextReference, + val onPromoBannerClick: () -> Unit, + val onCloseClick: () -> Unit, + val onPromoShown: () -> Unit = {}, + ) : PromoBannerUM() + + data object Empty : PromoBannerUM() + } + + @Immutable + sealed class TailUM { + data class Text( + val text: TextReference, + ) : TailUM() + + data object Draggable : TailUM() + + data object Empty : TailUM() + } + + data class IconUM( + val iconRes: Int, + val tintReference: ColorReference2 = ColorReference2 { TangemTheme.colors2.graphic.neutral.primary }, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt new file mode 100644 index 0000000000..aee2125611 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt @@ -0,0 +1,266 @@ +package com.tangem.core.ui.ds.row.token.internal + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import com.tangem.core.ui.R +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.styledResourceReference +import com.tangem.core.ui.extensions.styledStringReference +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.utils.StringsSigns +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +internal object TangemTokenRowPreviewData { + + private val priceChangeState: PriceChangeState.Content + get() = PriceChangeState.Content( + type = PriceChangeType.UP, + valueInPercent = "2.45%", + ) + + val promoBannerUM: TangemTokenRowUM.PromoBannerUM.Content + get() = TangemTokenRowUM.PromoBannerUM.Content( + title = stringReference("Earn yield by supplying your crypto assets"), + onPromoBannerClick = {}, + onPromoShown = {}, + onCloseClick = {}, + ) + + val titleUM: TangemTokenRowUM.TitleUM.Content + get() = TangemTokenRowUM.TitleUM.Content( + text = stringReference(value = "Polygon"), + hasPending = true, + ) + + val subtitleUM: TangemTokenRowUM.SubtitleUM.Content + get() = TangemTokenRowUM.SubtitleUM.Content( + text = stringReference(value = "$ 0.6631"), + priceChangeUM = priceChangeState, + ) + + val topEndContentUM: TangemTokenRowUM.EndContentUM.Content + get() = TangemTokenRowUM.EndContentUM.Content( + text = combinedReference( + stringReference("$ 500"), + styledStringReference(".17", { + SpanStyle( + color = TangemTheme.colors2.text.neutral.secondary, + fontWeight = TangemTheme.typography2.bodyRegular16.fontWeight, + ) + }), + ), + ) + + val bottomEndContentUM: TangemTokenRowUM.EndContentUM.Content + get() = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("Title"), + ) + + private val accountResIcon: CurrencyIconState.CryptoPortfolio.Icon + get() = CurrencyIconState.CryptoPortfolio.Icon( + resId = R.drawable.ic_rounded_star_24, + color = Color.Blue, + isGrayscale = false, + ) + private val accountLetterIcon: CurrencyIconState.CryptoPortfolio.Letter + get() = CurrencyIconState.CryptoPortfolio.Letter( + char = stringReference("A"), + color = Color.Blue, + isGrayscale = false, + ) + + private val coinIconState + get() = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_polygon_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ) + + private val tokenIconState + get() = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = R.drawable.img_polygon_22, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ) + + private val customTokenIconState + get() = CurrencyIconState.CustomTokenIcon( + tint = TangemColorPalette.Black, + background = TangemColorPalette.Meadow, + topBadgeIconResId = R.drawable.img_polygon_22, + isGrayscale = false, + ) + + val defaultState: TangemTokenRowUM.Content + get() = TangemTokenRowUM.Content( + id = UUID.randomUUID().toString(), + iconState = coinIconState, + titleUM = titleUM, + subtitleUM = subtitleUM, + topEndContentUM = topEndContentUM, + bottomEndContentUM = bottomEndContentUM, + promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty, + tailUM = TangemTokenRowUM.TailUM.Empty, + onItemClick = {}, + onItemLongClick = {}, + ) + + val defaultEllipsisState: TangemTokenRowUM.Content + get() = defaultState.copy( + titleUM = titleUM.copy(text = stringReference("Polygon Polygon Polygon Polygon")), + subtitleUM = subtitleUM, + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = combinedReference( + stringReference("$ 500"), + styledStringReference(".11232131237", { + SpanStyle( + color = TangemTheme.colors2.text.neutral.secondary, + fontWeight = TangemTheme.typography2.bodyRegular16.fontWeight, + ) + }), + ), + icons = persistentListOf( + TangemTokenRowUM.IconUM(R.drawable.ic_staking_mini_10), + TangemTokenRowUM.IconUM(R.drawable.ic_attention_12), + TangemTokenRowUM.IconUM(R.drawable.ic_error_sync_24), + ), + ), + bottomEndContentUM = bottomEndContentUM, + promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty, + tailUM = TangemTokenRowUM.TailUM.Empty, + onItemClick = {}, + onItemLongClick = {}, + ) + + val tokenState: TangemTokenRowUM.Content + get() = TangemTokenRowUM.Content( + id = UUID.randomUUID().toString(), + iconState = tokenIconState, + titleUM = titleUM, + subtitleUM = subtitleUM, + topEndContentUM = topEndContentUM, + bottomEndContentUM = bottomEndContentUM, + promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty, + tailUM = TangemTokenRowUM.TailUM.Empty, + onItemClick = {}, + onItemLongClick = {}, + ) + + val customTokenState: TangemTokenRowUM.Content + get() = TangemTokenRowUM.Content( + id = UUID.randomUUID().toString(), + iconState = customTokenIconState, + titleUM = titleUM, + subtitleUM = subtitleUM, + topEndContentUM = topEndContentUM, + bottomEndContentUM = bottomEndContentUM, + promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty, + tailUM = TangemTokenRowUM.TailUM.Empty, + onItemClick = {}, + onItemLongClick = {}, + ) + + val draggableState: TangemTokenRowUM.Actionable + get() = TangemTokenRowUM.Actionable( + id = UUID.randomUUID().toString(), + iconState = coinIconState, + titleUM = titleUM, + subtitleUM = subtitleUM, + tailUM = TangemTokenRowUM.TailUM.Draggable, + onItemClick = {}, + onItemLongClick = {}, + ) + + val draggableStateV2: TangemTokenRowUM.Actionable + get() = TangemTokenRowUM.Actionable( + id = UUID.randomUUID().toString(), + iconState = coinIconState, + titleUM = titleUM, + subtitleUM = subtitleUM, + topEndContentUM = topEndContentUM, + bottomEndContentUM = bottomEndContentUM, + tailUM = TangemTokenRowUM.TailUM.Draggable, + onItemClick = {}, + onItemLongClick = {}, + ) + + val loadingState: TangemTokenRowUM.Loading + get() = TangemTokenRowUM.Loading( + id = UUID.randomUUID().toString(), + iconState = coinIconState, + titleUM = TangemTokenRowUM.TitleUM.Loading, + subtitleUM = TangemTokenRowUM.SubtitleUM.Loading, + ) + + val unreachableState: TangemTokenRowUM.Content + get() = defaultState.copy( + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference(StringsSigns.DASH_SIGN), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = styledResourceReference( + id = R.string.common_unreachable, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.status.attention) }, + ), + ), + ) + + val accountState: TangemTokenRowUM.Content + get() = TangemTokenRowUM.Content( + id = UUID.randomUUID().toString(), + iconState = accountResIcon, + titleUM = TangemTokenRowUM.TitleUM.Content( + text = stringReference(value = "Portfolio"), + ), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = stringReference("24 tokens"), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("22,129.65 $"), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("+ $ 1,245.32"), + priceChangeUM = priceChangeState, + ), + onItemClick = {}, + onItemLongClick = {}, + ) + + val accountLetterState: TangemTokenRowUM.Content + get() = accountState.copy( + iconState = accountLetterIcon, + ) + + val accountEllipsisState: TangemTokenRowUM.Content + get() = accountState.copy( + titleUM = TangemTokenRowUM.TitleUM.Content( + text = stringReference(value = "Portfolio Portfolio Portfolio Portfolio"), + ), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = stringReference("24 tokens 24 tokens 24 tokens 24 tokens"), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("22,129.6129387147653025 $"), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("+ $ 1,245.31093284302752"), + priceChangeUM = priceChangeState, + ), + ) + + val promoBannerState: TangemTokenRowUM.Content + get() = defaultState.copy( + promoBannerUM = promoBannerUM, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndBottomContent.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndBottomContent.kt new file mode 100644 index 0000000000..a4bdecca54 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndBottomContent.kt @@ -0,0 +1,100 @@ +package com.tangem.core.ui.ds.row.token.internal + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun TokenRowEndBottomContent( + endContentUM: TangemTokenRowUM.EndContentUM, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + when (endContentUM) { + is TangemTokenRowUM.EndContentUM.Content -> Content( + modifier = modifier, + endContentUM = endContentUM, + isBalanceHidden = isBalanceHidden, + ) + TangemTokenRowUM.EndContentUM.Empty -> Unit + TangemTokenRowUM.EndContentUM.Loading -> TextShimmer( + style = TangemTheme.typography2.captionSemibold12, + modifier = modifier.width(TangemTheme.dimens2.x10), + radius = TangemTheme.dimens2.x25, + ) + } +} + +@Composable +private fun Content( + endContentUM: TangemTokenRowUM.EndContentUM.Content, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = endContentUM.text.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography2.captionSemibold12.applyBladeBrush( + isEnabled = endContentUM.isFlickering, + textColor = if (endContentUM.isAvailable) { + TangemTheme.colors2.text.neutral.secondary + } else { + TangemTheme.colors2.text.status.disabled + }, + ), + ) + + when (endContentUM.priceChangeUM) { + is PriceChangeState.Content -> TokenRowPriceChangeContent( + priceChangeState = endContentUM.priceChangeUM, + isFlickering = endContentUM.isFlickering, + isAvailable = endContentUM.isAvailable, + ) + PriceChangeState.Unknown -> Unit + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenRowEndBottomContent_Preview( + @PreviewParameter(TokenRowEndBottomContentPreviewProvider::class) params: TangemTokenRowUM.EndContentUM, +) { + TangemThemePreviewRedesign { + TokenRowEndBottomContent( + endContentUM = params, + isBalanceHidden = false, + ) + } +} + +private class TokenRowEndBottomContentPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemTokenRowPreviewData.bottomEndContentUM, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndTopContent.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndTopContent.kt new file mode 100644 index 0000000000..131497424f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndTopContent.kt @@ -0,0 +1,115 @@ +package com.tangem.core.ui.ds.row.token.internal + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +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.util.fastForEach +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun TokenRowEndTopContent( + endContentUM: TangemTokenRowUM.EndContentUM, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + when (endContentUM) { + is TangemTokenRowUM.EndContentUM.Content -> Content( + modifier = modifier, + endContentUM = endContentUM, + isBalanceHidden = isBalanceHidden, + ) + TangemTokenRowUM.EndContentUM.Empty -> Unit + TangemTokenRowUM.EndContentUM.Loading -> TextShimmer( + style = TangemTheme.typography2.bodySemibold16, + modifier = modifier.width(TangemTheme.dimens2.x18), + radius = TangemTheme.dimens2.x25, + ) + } +} + +@Composable +private fun Content( + endContentUM: TangemTokenRowUM.EndContentUM.Content, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedVisibility( + visible = endContentUM.icons.isNotEmpty(), + ) { + Row( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x1), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + endContentUM.icons.fastForEach { icon -> + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x3), + painter = rememberVectorPainter(image = ImageVector.vectorResource(icon.iconRes)), + tint = icon.tintReference(), + contentDescription = null, + ) + } + } + } + + Text( + modifier = Modifier, + text = endContentUM.text.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography2.bodySemibold16.applyBladeBrush( + isEnabled = endContentUM.isFlickering, + textColor = if (endContentUM.isAvailable) { + TangemTheme.colors2.text.neutral.primary + } else { + TangemTheme.colors2.text.status.disabled + }, + ), + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenRowEndTopContent_Preview( + @PreviewParameter(TokenRowEndContentPreviewProvider::class) params: TangemTokenRowUM.EndContentUM, +) { + TangemThemePreviewRedesign { + TokenRowEndTopContent( + endContentUM = params, + isBalanceHidden = false, + ) + } +} + +private class TokenRowEndContentPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemTokenRowPreviewData.topEndContentUM, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt new file mode 100644 index 0000000000..ef635a0a8e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt @@ -0,0 +1,71 @@ +package com.tangem.core.ui.ds.row.token.internal + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import com.tangem.core.ui.R +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun RowScope.TokenRowPriceChangeContent( + priceChangeState: PriceChangeState.Content, + isFlickering: Boolean, + isAvailable: Boolean = true, +) { + AnimatedContent( + targetState = priceChangeState.type, + label = "Update the price change's arrow", + modifier = Modifier.padding(start = TangemTheme.dimens2.x1), + ) { animatedType -> + Icon( + painter = rememberVectorPainter( + ImageVector.vectorResource( + when (animatedType) { + PriceChangeType.UP -> R.drawable.ic_up_dynamic_24 + PriceChangeType.DOWN -> R.drawable.ic_down_dynamic_24 + PriceChangeType.NEUTRAL -> R.drawable.ic_static_dynamic_24 + }, + ), + ), + tint = when (animatedType) { + PriceChangeType.UP -> TangemTheme.colors2.graphic.status.accent + PriceChangeType.DOWN -> TangemTheme.colors2.graphic.status.warning + PriceChangeType.NEUTRAL -> TangemTheme.colors2.graphic.neutral.secondary + }, + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens2.x3), + ) + } + + AnimatedContent( + targetState = priceChangeState.valueInPercent, + label = "Update the price text", + modifier = Modifier.padding(start = TangemTheme.dimens2.x0_5), + ) { animatedText -> + Text( + text = animatedText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography2.captionSemibold12.applyBladeBrush( + isEnabled = isFlickering, + textColor = if (isAvailable) { + TangemTheme.colors2.text.neutral.secondary + } else { + TangemTheme.colors2.text.status.disabled + }, + ), + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPromoBanner.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPromoBanner.kt new file mode 100644 index 0000000000..93845dea18 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPromoBanner.kt @@ -0,0 +1,107 @@ +package com.tangem.core.ui.ds.row.token.internal + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun TokenRowPromoBanner(promoBannerUM: TangemTokenRowUM.PromoBannerUM, modifier: Modifier = Modifier) { + when (promoBannerUM) { + is TangemTokenRowUM.PromoBannerUM.Content -> TokenRowPromoBanner( + promoBannerUM = promoBannerUM, + modifier = modifier, + ) + TangemTokenRowUM.PromoBannerUM.Empty -> Unit + } +} + +@Composable +internal fun TokenRowPromoBanner(promoBannerUM: TangemTokenRowUM.PromoBannerUM.Content, modifier: Modifier = Modifier) { + LaunchedEffect(promoBannerUM) { + promoBannerUM.onPromoShown() + } + val bgColor = TangemTheme.colors.control.default + Column(modifier = modifier) { + Row( + modifier = Modifier + .background(color = bgColor, shape = RoundedCornerShape(TangemTheme.dimens2.x4)) + .clickable(onClick = promoBannerUM.onPromoBannerClick) + .padding(horizontal = TangemTheme.dimens2.x3, vertical = TangemTheme.dimens2.x2) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_analytics_up_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + modifier = Modifier + .padding(end = TangemTheme.dimens2.x2) + .size(TangemTheme.dimens2.x4), + ) + Text( + text = promoBannerUM.title.resolveReference(), + style = TangemTheme.typography2.captionSemibold12, + color = TangemTheme.colors2.text.neutral.secondary, + modifier = Modifier + .weight(1f) + .padding(end = TangemTheme.dimens2.x2), + ) + Icon( + painter = painterResource(id = R.drawable.ic_close_24), + contentDescription = null, + tint = TangemTheme.colors2.text.neutral.secondary, + modifier = Modifier + .size(TangemTheme.dimens2.x4) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false), + onClick = { promoBannerUM.onCloseClick() }, + ), + ) + } + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_rectangle_bottom), + contentDescription = null, + tint = bgColor, + modifier = Modifier + .size(width = TangemTheme.dimens2.x3, height = TangemTheme.dimens2.x2), + ) + } + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_TokenRowPromoBanner() { + TangemThemePreviewRedesign { + TokenRowPromoBanner( + promoBannerUM = TangemTokenRowPreviewData.promoBannerUM, + modifier = Modifier.fillMaxWidth(), + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowSubtitle.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowSubtitle.kt new file mode 100644 index 0000000000..c0d32baca0 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowSubtitle.kt @@ -0,0 +1,100 @@ +package com.tangem.core.ui.ds.row.token.internal + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.ds.badge.TangemBadge +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun TokenRowSubtitle(subtitleUM: TangemTokenRowUM.SubtitleUM, modifier: Modifier = Modifier) { + when (subtitleUM) { + is TangemTokenRowUM.SubtitleUM.Content -> SubtitleContent( + subtitleUM = subtitleUM, + modifier = modifier, + ) + TangemTokenRowUM.SubtitleUM.Loading -> TextShimmer( + style = TangemTheme.typography2.captionSemibold12, + modifier = modifier.width(TangemTheme.dimens2.x8), + radius = TangemTheme.dimens2.x25, + ) + TangemTokenRowUM.SubtitleUM.Empty -> Unit + } +} + +@Composable +private fun SubtitleContent(subtitleUM: TangemTokenRowUM.SubtitleUM.Content, modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + AnimatedVisibility( + visible = subtitleUM.badge != null, + ) { + val wrappedBadge = remember(this) { requireNotNull(subtitleUM.badge) } + TangemBadge(wrappedBadge) + } + + Text( + text = subtitleUM.text.resolveAnnotatedReference(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography2.captionSemibold12.applyBladeBrush( + isEnabled = subtitleUM.isFlickering, + textColor = if (subtitleUM.isAvailable) { + TangemTheme.colors2.text.neutral.secondary + } else { + TangemTheme.colors2.text.status.disabled + }, + ), + ) + + when (subtitleUM.priceChangeUM) { + is PriceChangeState.Content -> TokenRowPriceChangeContent( + priceChangeState = subtitleUM.priceChangeUM, + isFlickering = subtitleUM.isFlickering, + isAvailable = subtitleUM.isAvailable, + ) + PriceChangeState.Unknown -> Unit + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenRowSubtitle_Preview( + @PreviewParameter(TokenRowSubtitlePreviewProvider::class) params: TangemTokenRowUM.SubtitleUM, +) { + TangemThemePreviewRedesign { + TokenRowSubtitle( + subtitleUM = params, + ) + } +} + +private class TokenRowSubtitlePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemTokenRowPreviewData.subtitleUM, + TangemTokenRowUM.SubtitleUM.Loading, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowTail.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowTail.kt new file mode 100644 index 0000000000..f3416f8a29 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowTail.kt @@ -0,0 +1,81 @@ +package com.tangem.core.ui.ds.row.token.internal + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +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 +internal fun TokenRowTail( + tailUM: TangemTokenRowUM.TailUM, + reorderableTokenListState: ReorderableLazyListState?, + modifier: Modifier = Modifier, +) { + AnimatedContent( + targetState = tailUM, + label = "Update non content fiat block", + modifier = modifier, + contentKey = { it::class.java }, + ) { animatedState -> + val innerModifier = Modifier.padding(start = TangemTheme.dimens2.x2) + when (animatedState) { + TangemTokenRowUM.TailUM.Empty -> Unit + is TangemTokenRowUM.TailUM.Draggable -> DraggableImage( + reorderableTokenListState = reorderableTokenListState, + modifier = innerModifier, + ) + is TangemTokenRowUM.TailUM.Text -> ContentText(text = animatedState.text, modifier = innerModifier) + } + } +} + +@Composable +private fun DraggableImage(reorderableTokenListState: ReorderableLazyListState?, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(size = TangemTheme.dimens2.x6) + .then( + other = if (reorderableTokenListState != null) { + Modifier.detectReorder(reorderableTokenListState) + } else { + Modifier + }, + ) + .testTag(OrganizeTokensScreenTestTags.DRAGGABLE_IMAGE), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_drag_24), + tint = TangemTheme.colors2.graphic.neutral.tertiaryConstant, + contentDescription = null, + ) + } +} + +@Composable +private fun ContentText(text: TextReference, modifier: Modifier = Modifier) { + Text( + text = text.resolveAnnotatedReference(), + color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography2.captionRegular12, + modifier = modifier, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowTitle.kt new file mode 100644 index 0000000000..ad8368ab4d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowTitle.kt @@ -0,0 +1,101 @@ +package com.tangem.core.ui.ds.row.token.internal + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.ds.badge.TangemBadge +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.conditional +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun TokenRowTitle(titleUM: TangemTokenRowUM.TitleUM, modifier: Modifier = Modifier) { + when (titleUM) { + is TangemTokenRowUM.TitleUM.Content -> ContentTitle(titleUM = titleUM, modifier = modifier) + TangemTokenRowUM.TitleUM.Loading -> TextShimmer( + style = TangemTheme.typography2.bodySemibold16, + modifier = modifier.width(TangemTheme.dimens2.x18), + radius = TangemTheme.dimens2.x25, + ) + TangemTokenRowUM.TitleUM.Empty -> Unit + } +} + +@Composable +private fun ContentTitle(titleUM: TangemTokenRowUM.TitleUM.Content, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens2.x4), + verticalAlignment = Alignment.CenterVertically, + ) { + /* + * If currency name has a long width, then it will completely displace the image. + * So we need to use [weight] to avoid displacement. + */ + Text( + text = titleUM.text.resolveReference(), + modifier = Modifier.weight(weight = 1f, fill = false), + color = if (titleUM.isAvailable) { + TangemTheme.colors2.text.neutral.primary + } else { + TangemTheme.colors2.text.status.disabled + }, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = TangemTheme.typography2.bodySemibold16, + ) + + AnimatedVisibility( + visible = titleUM.hasPending, + modifier = Modifier.align(alignment = Alignment.CenterVertically), + ) { + Image( + painter = painterResource(id = R.drawable.img_loader_15), + contentDescription = null, + ) + } + + AnimatedVisibility( + visible = titleUM.badge != null, + modifier = Modifier.conditional( + condition = titleUM.onBadgeClick != null, + modifier = { clickable(onClick = requireNotNull(titleUM.onBadgeClick)) }, + ), + ) { + val wrappedBadge = remember(this) { requireNotNull(titleUM.badge) } + TangemBadge(wrappedBadge) + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenRowTitle_Preview() { + TangemThemePreviewRedesign { + TokenRowTitle( + titleUM = TangemTokenRowPreviewData.titleUM, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt index 604a8a16e1..79c329a101 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt @@ -2,6 +2,8 @@ package com.tangem.core.ui.extensions import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color /** @@ -11,7 +13,7 @@ import androidx.compose.ui.graphics.Color * * @property value color provider from theme */ -@Deprecated("Use TextReference with applied SpanStyleReference for colored text.") +@Deprecated("Use TextReference with applied SpanStyleReference for colored text or ColorReference2 for color.") @Immutable data class ColorReference(val value: @Composable () -> Color) @@ -31,4 +33,17 @@ fun themedColor(value: @Composable () -> Color): ColorReference { @Composable fun ColorReference.resolveReference(): Color { return value() +} + +/** + * Utility functional interface for keeping themed [Color] reference from app theme. + * It is necessary to use [Stable] annotation for runtime stability. + */ +@Stable +@FunctionalInterface +fun interface ColorReference2 { + + @ReadOnlyComposable + @Composable + operator fun invoke(): Color } \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_down_dynamic_24.xml b/core/ui/src/main/res/drawable/ic_down_dynamic_24.xml new file mode 100644 index 0000000000..01b2208a14 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_down_dynamic_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_drag_24.xml b/core/ui/src/main/res/drawable/ic_drag_24.xml index 9246315ec7..3cea032612 100644 --- a/core/ui/src/main/res/drawable/ic_drag_24.xml +++ b/core/ui/src/main/res/drawable/ic_drag_24.xml @@ -4,7 +4,9 @@ android:viewportWidth="24" android:viewportHeight="24"> + android:pathData="M5,8H19M5,16H19" + android:strokeWidth="2" + android:fillColor="#00000000" + android:strokeColor="#000000" + android:strokeLineCap="round"/> diff --git a/core/ui/src/main/res/drawable/ic_static_dynamic_24.xml b/core/ui/src/main/res/drawable/ic_static_dynamic_24.xml new file mode 100644 index 0000000000..ee4903259a --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_static_dynamic_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_up_dynamic_24.xml b/core/ui/src/main/res/drawable/ic_up_dynamic_24.xml new file mode 100644 index 0000000000..4f63f4b68f --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_up_dynamic_24.xml @@ -0,0 +1,9 @@ + + + From 6f959bcd3f214cc215848144eead50d983ffdedc Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 14 Jan 2026 13:29:32 +0100 Subject: [PATCH 096/172] Updated on 2026-08-14 --- .../core/analytics/models/AnalyticsParam.kt | 1 + .../models/OneTimePerSessionEvent.kt | 22 ++ .../com/tangem/core/analytics/Analytics.kt | 7 + .../data/news/DefaultNewsErrorResolver.kt | 22 ++ .../com/tangem/data/news/di/NewsDataModule.kt | 7 + .../news/repository/DefaultNewsRepository.kt | 9 +- .../tangem/domain/models/news/NewsError.kt | 20 +- .../tangem/domain/models/news/TrendingNews.kt | 2 +- .../tangem/domain/news/NewsErrorResolver.kt | 8 + features/feed/impl/build.gradle.kts | 1 + .../feed/model/feed/FeedComponentModel.kt | 288 +++++++++--------- .../feed/analytics/FeedAnalyticsEvent.kt | 94 ++++++ .../feed/state/FeedMarketsBatchFlowManager.kt | 18 +- .../model/feed/state/FeedStateController.kt | 70 +++++ .../transformers/FeedListUMTransformer.kt | 7 + .../UpdateGlobalFeedStateTransformer.kt | 76 +++++ .../UpdateMarketChartsTransformer.kt | 102 +++++++ .../UpdateTrendingNewsStateTransformer.kt} | 66 ++-- .../tangem/features/feed/ui/feed/FeedList.kt | 25 +- .../preview/FeedListPreviewDataProvider.kt | 2 + .../features/feed/ui/feed/state/FeedListUM.kt | 6 +- 21 files changed, 641 insertions(+), 212 deletions(-) create mode 100644 core/analytics/models/src/main/java/com/tangem/core/analytics/models/OneTimePerSessionEvent.kt create mode 100644 data/news/src/main/java/com/tangem/data/news/DefaultNewsErrorResolver.kt create mode 100644 domain/news/src/main/java/com/tangem/domain/news/NewsErrorResolver.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/FeedListUMTransformer.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateMarketChartsTransformer.kt rename features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/{TrendingNewsStateFactory.kt => transformers/UpdateTrendingNewsStateTransformer.kt} (54%) diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index 9488b9c840..fae4659e84 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -78,6 +78,7 @@ sealed class AnalyticsParam { data object Onboarding : ScreensSources("Onboarding") data object LongTap : ScreensSources("Long Tap") data object Markets : ScreensSources("Markets") + data object MarketPulse : ScreensSources("Market Pulse") data object TangemPay : ScreensSources("Tangem Pay") data object WalletSettings : ScreensSources("Wallet Settings") data object Upgrade : ScreensSources("Upgrade") diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/OneTimePerSessionEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/OneTimePerSessionEvent.kt new file mode 100644 index 0000000000..a94df878eb --- /dev/null +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/OneTimePerSessionEvent.kt @@ -0,0 +1,22 @@ +package com.tangem.core.analytics.models + +/** + * Marker interface for analytics events that should be sent only once per session. + * + * Events implementing this interface will be tracked by their [oneTimeEventId] to ensure + * they are not sent multiple times during the same application session. Once an event + * with a specific [oneTimeEventId] has been sent, subsequent attempts to send an event + * with the same ID will be ignored. + * + * @see Analytics.send + */ +interface OneTimePerSessionEvent { + /** + * Unique identifier for the one-time event. + * + * This ID is used to track whether the event has already been sent in the current session. + * Events with the same [oneTimeEventId] will only be sent once, even if they are + * different instances of the same event class. + */ + val oneTimeEventId: String +} \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt b/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt index 9703947954..c5630c4906 100644 --- a/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt +++ b/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt @@ -6,6 +6,7 @@ import com.tangem.common.extensions.toHexString import com.tangem.core.analytics.api.* import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.ExceptionAnalyticsEvent +import com.tangem.core.analytics.models.OneTimePerSessionEvent import com.tangem.utils.coroutines.FeatureCoroutineExceptionHandler import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex @@ -31,6 +32,7 @@ object Analytics : GlobalAnalyticsEventHandler { private val handlers = mutableMapOf() private val paramsInterceptors = ConcurrentHashMap() + private val oneEventsPerSession = ConcurrentHashMap() private val analyticsFilters = mutableSetOf() private val analyticsMutex = Mutex() @@ -85,6 +87,11 @@ object Analytics : GlobalAnalyticsEventHandler { override fun send(event: AnalyticsEvent) { analyticsScope.launch { + if (event is OneTimePerSessionEvent && + oneEventsPerSession.putIfAbsent(event.oneTimeEventId, true) != null + ) { + return@launch + } event.params = applyParamsInterceptors(event) val eventFilter = analyticsFilters.firstOrNull { it.canBeAppliedTo(event) } diff --git a/data/news/src/main/java/com/tangem/data/news/DefaultNewsErrorResolver.kt b/data/news/src/main/java/com/tangem/data/news/DefaultNewsErrorResolver.kt new file mode 100644 index 0000000000..3d35ed1f4a --- /dev/null +++ b/data/news/src/main/java/com/tangem/data/news/DefaultNewsErrorResolver.kt @@ -0,0 +1,22 @@ +package com.tangem.data.news + +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.domain.models.news.NewsError +import com.tangem.domain.news.NewsErrorResolver + +internal class DefaultNewsErrorResolver : NewsErrorResolver { + + override fun resolve(throwable: Throwable?): NewsError { + return when (throwable) { + is ApiResponseError.HttpException -> { + NewsError.HttpError( + code = throwable.code.numericCode, + message = throwable.message.orEmpty(), + ) + } + else -> { + NewsError.NotHttpError + } + } + } +} \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt b/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt index cb558a2283..90851234d3 100644 --- a/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt +++ b/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt @@ -1,10 +1,12 @@ package com.tangem.data.news.di +import com.tangem.data.news.DefaultNewsErrorResolver import com.tangem.data.news.repository.DefaultNewsRepository import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.local.news.details.NewsDetailsStore import com.tangem.datasource.local.news.trending.TrendingNewsStore import com.tangem.datasource.local.news.viewed.NewsViewedStore +import com.tangem.domain.news.NewsErrorResolver import com.tangem.domain.news.repository.NewsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -25,6 +27,7 @@ internal object NewsDataModule { newsDetailsStore: NewsDetailsStore, trendingNewsStore: TrendingNewsStore, newsViewedStore: NewsViewedStore, + newsErrorResolver: NewsErrorResolver, ): NewsRepository { return DefaultNewsRepository( newsApi = newsApi, @@ -32,6 +35,10 @@ internal object NewsDataModule { newsDetailsStore = newsDetailsStore, trendingNewsStore = trendingNewsStore, newsViewedStore = newsViewedStore, + newsErrorResolver = newsErrorResolver, ) } + + @Provides + fun provideNewsErrorResolver(): NewsErrorResolver = DefaultNewsErrorResolver() } \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt index f503535500..8466016a58 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -9,6 +9,7 @@ import com.tangem.datasource.local.news.details.NewsDetailsStore import com.tangem.datasource.local.news.trending.TrendingNewsStore import com.tangem.datasource.local.news.viewed.NewsViewedStore import com.tangem.domain.models.news.* +import com.tangem.domain.news.NewsErrorResolver import com.tangem.domain.news.model.NewsListBatchFlow import com.tangem.domain.news.model.NewsListBatchingContext import com.tangem.domain.news.model.NewsListConfig @@ -32,6 +33,7 @@ internal class DefaultNewsRepository( private val newsDetailsStore: NewsDetailsStore, private val trendingNewsStore: TrendingNewsStore, private val newsViewedStore: NewsViewedStore, + private val newsErrorResolver: NewsErrorResolver, ) : NewsRepository { override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow { @@ -176,12 +178,7 @@ internal class DefaultNewsRepository( trendingNewsStore.clear() trendingNewsStore.store( key = TRENDING_NEWS_KEY, - value = TrendingNews.Error( - NewsError.Unknown( - message = apiResponse.cause.message, - code = null, - ), - ), + value = TrendingNews.Error(error = newsErrorResolver.resolve(apiResponse.cause.cause)), ) } is ApiResponse.Success -> { diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/news/NewsError.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/NewsError.kt index 5055eacbbf..f92b62ca6f 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/news/NewsError.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/NewsError.kt @@ -1,19 +1,11 @@ package com.tangem.domain.models.news -import kotlinx.serialization.Serializable +sealed interface NewsError { -@Serializable -sealed class NewsError { - abstract val message: String? - abstract val code: Int? + data class HttpError( + val code: Int, + val message: String, + ) : NewsError - data class ArticleNotFound( - override val message: String?, - override val code: Int?, - ) : NewsError() - - data class Unknown( - override val message: String?, - override val code: Int?, - ) : NewsError() + data object NotHttpError : NewsError } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/news/TrendingNews.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/TrendingNews.kt index d3b0bbdf87..ec4a742686 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/news/TrendingNews.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/TrendingNews.kt @@ -12,5 +12,5 @@ sealed interface TrendingNews { data class Data(val articles: List) : TrendingNews @Serializable - data class Error(val throwable: NewsError) : TrendingNews + data class Error(val error: NewsError) : TrendingNews } \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/NewsErrorResolver.kt b/domain/news/src/main/java/com/tangem/domain/news/NewsErrorResolver.kt new file mode 100644 index 0000000000..d4ece74780 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/NewsErrorResolver.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.news + +import com.tangem.domain.models.news.NewsError + +interface NewsErrorResolver { + + fun resolve(throwable: Throwable?): NewsError +} \ No newline at end of file diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts index ed4e7f4ba6..e16f11c113 100644 --- a/features/feed/impl/build.gradle.kts +++ b/features/feed/impl/build.gradle.kts @@ -90,6 +90,7 @@ dependencies { implementation(projects.core.analytics.models) implementation(projects.core.navigation) implementation(projects.core.utils) + implementation(projects.core.datasource) /* Common */ implementation(projects.common.ui) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index f9c544b6e0..c8d8412ae3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -2,6 +2,9 @@ package com.tangem.features.feed.model.feed import androidx.compose.runtime.Stable import arrow.core.getOrElse +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -16,13 +19,16 @@ import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.impl.R +import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent import com.tangem.features.feed.model.feed.state.FeedMarketsBatchFlowManager -import com.tangem.features.feed.model.feed.state.TrendingNewsStateFactory +import com.tangem.features.feed.model.feed.state.FeedStateController +import com.tangem.features.feed.model.feed.state.transformers.UpdateGlobalFeedStateTransformer +import com.tangem.features.feed.model.feed.state.transformers.UpdateMarketChartsTransformer +import com.tangem.features.feed.model.feed.state.transformers.UpdateTrendingNewsStateTransformer import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.feed.state.* import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentHashMap import kotlinx.coroutines.Job @@ -32,18 +38,19 @@ import kotlinx.coroutines.launch import org.joda.time.DateTime import org.joda.time.DateTimeZone import javax.inject.Inject -import kotlin.collections.all -import kotlin.collections.map @Stable @ModelScoped +@Suppress("LongParameterList") internal class FeedComponentModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val fetchTrendingNewsUseCase: FetchTrendingNewsUseCase, private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, paramsContainer: ParamsContainer, + private val stateController: FeedStateController, ) : Model() { private val params = paramsContainer.require() @@ -66,23 +73,12 @@ internal class FeedComponentModel @Inject constructor( ) internal val state: StateFlow - field = MutableStateFlow(initialState()) - - private val trendingNewsStateFactory by lazy(LazyThreadSafetyMode.NONE) { - TrendingNewsStateFactory( - currentStateProvider = Provider { state.value }, - onStateUpdate = { newState -> state.update { newState } }, - onRetryClicked = { - modelScope.launch(dispatchers.default) { - fetchTrendingNewsUseCase.invoke() - } - }, - ) - } + get() = stateController.uiState val isVisibleOnScreen = MutableStateFlow(false) init { + initializeState() updateCallbacks() modelScope.launch(dispatchers.default) { @@ -96,10 +92,50 @@ internal class FeedComponentModel @Inject constructor( flow3 = marketsBatchFlowManager.errorStatesByOrder, flow4 = manageTrendingNewsUseCase.observeTrendingNews(), ) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult -> - updateMarketCharts(itemsByOrder, loadingStatesByOrder, errorStatesByOrder) - trendingNewsStateFactory.updateTrendingNewsState(result = trendingNewsResult) - updateGlobalState() - val currentSortType = state.value.marketChartConfig.currentSortByType + val globalStateTransformer = UpdateGlobalFeedStateTransformer( + loadingStatesByOrder = loadingStatesByOrder, + errorStatesByOrder = errorStatesByOrder, + trendingNewsResult = trendingNewsResult, + onRetryClicked = { + modelScope.launch(dispatchers.default) { + stateController.update { currentState -> + currentState.copy(globalState = GlobalFeedState.Loading) + } + fetchTrendingNewsUseCase.invoke() + marketsBatchFlowManager.reloadAll() + } + }, + analyticsEventHandler = analyticsEventHandler, + ) + + val currentState = stateController.value + val newGlobalState = globalStateTransformer.transform(currentState) + val shouldSkipIndividualStates = newGlobalState.globalState !is GlobalFeedState.Content + + stateController.update(globalStateTransformer) + + if (!shouldSkipIndividualStates) { + stateController.updateAll( + UpdateMarketChartsTransformer( + itemsByOrder = itemsByOrder, + loadingStatesByOrder = loadingStatesByOrder, + errorStatesByOrder = errorStatesByOrder, + onReloadAll = { marketsBatchFlowManager.reloadAll() }, + analyticsEventHandler = analyticsEventHandler, + ), + UpdateTrendingNewsStateTransformer( + result = trendingNewsResult, + onRetryClicked = { + modelScope.launch(dispatchers.default) { + fetchTrendingNewsUseCase.invoke() + } + }, + analyticsEventHandler = analyticsEventHandler, + ), + ) + } + + val currentSortType = stateController.value.marketChartConfig.currentSortByType val items = itemsByOrder[currentSortType] val isLoading = loadingStatesByOrder[currentSortType] == true if (items != null && items.isNotEmpty() && !isLoading) { @@ -127,6 +163,10 @@ internal class FeedComponentModel @Inject constructor( } } + private fun initializeState() { + stateController.update { initialState() } + } + private fun initialState(): FeedListUM { return FeedListUM( currentDate = getCurrentDate(), @@ -141,6 +181,12 @@ internal class FeedComponentModel @Inject constructor( onOpenAllNews = {}, onMarketItemClick = {}, onSortTypeClick = {}, + onSliderScroll = { + analyticsEventHandler.send(FeedAnalyticsEvent.NewsCarouselScrolled()) + }, + onSliderEndReached = { + analyticsEventHandler.send(FeedAnalyticsEvent.NewsCarouselEndReached()) + }, ), news = NewsUM( content = persistentListOf(), @@ -165,101 +211,8 @@ internal class FeedComponentModel @Inject constructor( return DateTimeFormatters.formatDate(formatter = DateTimeFormatters.dateDMMM, date = localDate) } - private fun updateMarketCharts( - itemsByOrder: Map>, - loadingStatesByOrder: Map, - errorStatesByOrder: Map, - ) { - state.update { currentState -> - val newMarketCharts = buildMap { - SortByTypeUM.entries.forEach { sortByType -> - val items = itemsByOrder[sortByType] ?: persistentListOf() - val isLoading = loadingStatesByOrder[sortByType] == true - val hasError = errorStatesByOrder[sortByType] == true - - when { - hasError -> { - put( - sortByType, - MarketChartUM.LoadingError( - onRetryClicked = { - marketsBatchFlowManager.reloadAll() - }, - ), - ) - } - isLoading -> { - put(sortByType, MarketChartUM.Loading) - } - items.isEmpty() -> { - put( - sortByType, - MarketChartUM.LoadingError( - onRetryClicked = { - marketsBatchFlowManager.reloadAll() - }, - ), - ) - } - else -> { - put( - sortByType, - MarketChartUM.Content( - items = items, - sortChartConfig = SortChartConfigUM( - sortByType = sortByType, - isSelected = sortByType == currentState.marketChartConfig.currentSortByType, - ), - ), - ) - } - } - } - }.toPersistentHashMap() - - currentState.copy( - marketChartConfig = currentState.marketChartConfig.copy( - marketCharts = newMarketCharts, - ), - ) - } - } - - private fun updateGlobalState() { - state.update { currentState -> - val newsState = currentState.news - val marketCharts = currentState.marketChartConfig.marketCharts - - val isNewsLoading = newsState.newsUMState == NewsUMState.LOADING - val areAllChartsLoading = marketCharts.values.all { it is MarketChartUM.Loading } - - val isNewsError = newsState.newsUMState == NewsUMState.ERROR - val areAllChartsError = marketCharts.values.all { it is MarketChartUM.LoadingError } - - val newGlobalState = when { - isNewsLoading && areAllChartsLoading -> GlobalFeedState.Loading - isNewsError && areAllChartsError -> GlobalFeedState.Error( - onRetryClicked = { - modelScope.launch(dispatchers.default) { - fetchTrendingNewsUseCase.invoke() - marketsBatchFlowManager.reloadAll() - } - }, - ) - else -> GlobalFeedState.Content - } - - val currentGlobalState = currentState.globalState - if (currentGlobalState::class != newGlobalState::class) { - currentState.copy(globalState = newGlobalState) - } else { - currentState - } - } - } - - private fun onSortTypeClick(sortByType: SortByTypeUM) { - state.update { currentState -> + private fun handleSortTypeClicked(sortByType: SortByTypeUM) { + stateController.update { currentState -> val updatedCharts = currentState.marketChartConfig.marketCharts.mapValues { (chartSortType, chart) -> when (chart) { is MarketChartUM.Content -> { @@ -297,44 +250,85 @@ internal class FeedComponentModel @Inject constructor( } private fun updateCallbacks() { - state.update { feedListUM -> + stateController.update { feedListUM -> feedListUM.copy( feedListCallbacks = feedListUM.feedListCallbacks.copy( - onSortTypeClick = ::onSortTypeClick, - onMarketItemClick = { item -> - val tokenMarket = marketsBatchFlowManager.getTokenMarketById(item.id) - if (tokenMarket != null) { - params.feedClickIntents.onMarketItemClick( - token = tokenMarket.toSerializableParam(), - appCurrency = currentAppCurrency.value, - ) - } - }, - onMarketOpenClick = { sortBy -> - params.feedClickIntents.onMarketOpenClick(sortBy) - }, - onArticleClick = { articleId -> - val trendingArticleId = state.value.trendingArticle?.id - params.feedClickIntents.onArticleClick( - articleId = articleId, - preselectedArticlesId = listOfNotNull(trendingArticleId) + - when (state.value.news.newsUMState) { - NewsUMState.CONTENT -> state.value.news.content.map { it.id } - NewsUMState.LOADING, - NewsUMState.ERROR, - -> emptyList() - }, - paginationConfig = null, - ) - }, - onOpenAllNews = { - params.feedClickIntents.onOpenAllNews() - }, + onSortTypeClick = ::handleSortTypeClicked, + onMarketItemClick = ::handleMarketItemClicked, + onMarketOpenClick = ::handleMarketOpenClicked, + onArticleClick = ::handleArticleClicked, + onOpenAllNews = ::handleOpenAllNews, ), ) } } + private fun handleMarketItemClicked(item: MarketsListItemUM) { + val tokenMarket = marketsBatchFlowManager.getTokenMarketById(item.id) + if (tokenMarket != null) { + params.feedClickIntents.onMarketItemClick( + token = tokenMarket.toSerializableParam(), + appCurrency = currentAppCurrency.value, + ) + } + } + + private fun handleMarketOpenClicked(sortBy: SortByTypeUM) { + when (sortBy) { + SortByTypeUM.Rating -> { + analyticsEventHandler.send( + FeedAnalyticsEvent.TokenListOpened( + AnalyticsParam.ScreensSources.Markets, + ), + ) + } + SortByTypeUM.Trending, + SortByTypeUM.ExperiencedBuyers, + SortByTypeUM.TopGainers, + SortByTypeUM.TopLosers, + SortByTypeUM.Staking, + SortByTypeUM.YieldSupply, + -> { + analyticsEventHandler.send( + FeedAnalyticsEvent.TokenListOpened( + AnalyticsParam.ScreensSources.MarketPulse, + ), + ) + } + } + params.feedClickIntents.onMarketOpenClick(sortBy) + } + + private fun handleArticleClicked(articleId: Int) { + val currentState = stateController.value + val trendingArticleId = currentState.trendingArticle?.id + if (articleId == trendingArticleId) { + analyticsEventHandler.send( + event = FeedAnalyticsEvent.NewsCarouselTrendingClicked(newsId = articleId), + ) + } + params.feedClickIntents.onArticleClick( + articleId = articleId, + preselectedArticlesId = listOfNotNull(trendingArticleId) + + when (currentState.news.newsUMState) { + NewsUMState.CONTENT -> currentState.news.content.map { it.id } + NewsUMState.LOADING, + NewsUMState.ERROR, + -> emptyList() + }, + paginationConfig = null, + ) + } + + private fun handleOpenAllNews(isFromCarouselButton: Boolean) { + if (isFromCarouselButton) { + analyticsEventHandler.send(FeedAnalyticsEvent.NewsCarouselAllNewsButton()) + } else { + analyticsEventHandler.send(FeedAnalyticsEvent.NewsListOpened()) + } + params.feedClickIntents.onOpenAllNews() + } + private fun SortByTypeUM.toOrder(): TokenMarketListConfig.Order { return when (this) { SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt new file mode 100644 index 0000000000..c61c143dd9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt @@ -0,0 +1,94 @@ +package com.tangem.features.feed.model.feed.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE +import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE +import com.tangem.core.analytics.models.OneTimePerSessionEvent + +internal sealed class FeedAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Markets", event = event, params = params) { + + data class TokenListOpened( + private val screensSources: AnalyticsParam.ScreensSources, + ) : FeedAnalyticsEvent( + event = "Token List Opened", + params = mapOf( + SOURCE to screensSources.value, + ), + ) + + class NewsListOpened : FeedAnalyticsEvent( + event = "News List Opened", + params = mapOf( + SOURCE to AnalyticsParam.ScreensSources.Markets.value, + ), + ) + + data class NewsCarouselTrendingClicked(private val newsId: Int) : FeedAnalyticsEvent( + event = "News Carousel Trending Clicked", + params = mapOf( + "News id" to newsId.toString(), + ), + ), OneTimePerSessionEvent { + override val oneTimeEventId: String = event + } + + class NewsCarouselAllNewsButton : FeedAnalyticsEvent( + event = "News Carousel All News button", + ), OneTimePerSessionEvent { + override val oneTimeEventId: String = event + } + + class NewsCarouselEndReached : FeedAnalyticsEvent( + event = "News Carousel End Reached", + ), OneTimePerSessionEvent { + override val oneTimeEventId: String = event + } + + class NewsCarouselScrolled : FeedAnalyticsEvent( + event = "News Carousel Scrolled", + ), OneTimePerSessionEvent { + override val oneTimeEventId: String = event + } + + data class MarketsLoadError( + private val code: Int?, + private val message: String, + ) : FeedAnalyticsEvent( + event = "Markets Load Error", + params = mapOf( + ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(), + ERROR_MESSAGE to message, + ), + ) + + data class NewsLoadError( + private val code: Int?, + private val message: String, + ) : FeedAnalyticsEvent( + event = "News Load Error", + params = mapOf( + ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(), + ERROR_MESSAGE to message, + ), + ) + + data class AllWidgetsLoadError( + private val code: Int?, + private val message: String, + ) : FeedAnalyticsEvent( + event = "All Widgets Load Error", + params = mapOf( + ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(), + ERROR_MESSAGE to message, + ), + ) + + private companion object { + const val IS_NOT_HTTP_ERROR = "Is not http error" + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt index 026527248e..ac4f510e63 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt @@ -66,15 +66,15 @@ internal class FeedMarketsBatchFlowManager( initialValue = emptyMap(), ) - val errorStatesByOrder: StateFlow> = + val errorStatesByOrder: StateFlow> = combine( TokenMarketListConfig.Order.entries.mapNotNull { order -> - managersByOrder[order]?.hasError?.map { hasError -> order to hasError } + managersByOrder[order]?.error?.map { error -> order to error } }, ) { errorStatesList -> - errorStatesList.associate { (order, hasError) -> + errorStatesList.associate { (order, error) -> val sortByType = order.toSortByTypeUM() - sortByType to hasError + sortByType to error } }.stateIn( scope = modelScope, @@ -194,19 +194,19 @@ internal class FeedMarketsBatchFlowManager( initialValue = false, ) - val hasError = batchFlow.state + val error = batchFlow.state .map { state -> when (val status = state.status) { - is PaginationStatus.InitialLoadingError -> true - is PaginationStatus.Paginating -> status.lastResult is BatchFetchResult.Error - else -> false + is PaginationStatus.InitialLoadingError -> status.throwable + is PaginationStatus.Paginating -> (status.lastResult as? BatchFetchResult.Error)?.throwable + else -> null } } .distinctUntilChanged() .stateIn( scope = modelScope, started = Eagerly, - initialValue = false, + initialValue = null, ) val onLastBatchLoadedSuccess = batchFlow.state diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt new file mode 100644 index 0000000000..161b83222b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt @@ -0,0 +1,70 @@ +package com.tangem.features.feed.model.feed.state + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.features.feed.model.feed.state.transformers.FeedListUMTransformer +import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.ui.feed.state.* +import kotlinx.collections.immutable.persistentHashMapOf +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class FeedStateController @Inject constructor() { + + private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) + + val uiState: StateFlow get() = mutableUiState.asStateFlow() + + val value: FeedListUM get() = uiState.value + + fun update(function: (FeedListUM) -> FeedListUM) { + mutableUiState.update(function = function) + } + + fun update(transformer: FeedListUMTransformer) { + mutableUiState.update(function = transformer::transform) + } + + fun updateAll(vararg transformers: FeedListUMTransformer) { + transformers.forEach { mutableUiState.update(function = it::transform) } + } + + fun clear() { + mutableUiState.update { getInitialState() } + } + + private fun getInitialState(): FeedListUM { + return FeedListUM( + currentDate = "", + feedListSearchBar = FeedListSearchBar( + placeholderText = com.tangem.core.ui.extensions.TextReference.EMPTY, + onBarClick = {}, + ), + feedListCallbacks = FeedListCallbacks( + onSearchClick = {}, + onMarketOpenClick = {}, + onArticleClick = {}, + onOpenAllNews = {}, + onMarketItemClick = {}, + onSortTypeClick = {}, + onSliderScroll = {}, + onSliderEndReached = {}, + ), + news = NewsUM( + content = persistentListOf(), + onRetryClicked = {}, + newsUMState = NewsUMState.LOADING, + ), + trendingArticle = null, + marketChartConfig = MarketChartConfig( + marketCharts = persistentHashMapOf(), + currentSortByType = SortByTypeUM.Trending, + ), + globalState = GlobalFeedState.Loading, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/FeedListUMTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/FeedListUMTransformer.kt new file mode 100644 index 0000000000..953e0af7d6 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/FeedListUMTransformer.kt @@ -0,0 +1,7 @@ +package com.tangem.features.feed.model.feed.state.transformers + +import com.tangem.features.feed.ui.feed.state.FeedListUM + +internal interface FeedListUMTransformer { + fun transform(prevState: FeedListUM): FeedListUM +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt new file mode 100644 index 0000000000..ee8c1226fe --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt @@ -0,0 +1,76 @@ +package com.tangem.features.feed.model.feed.state.transformers + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.domain.models.news.TrendingNews +import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent +import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.ui.feed.state.FeedListUM +import com.tangem.features.feed.ui.feed.state.GlobalFeedState +import com.tangem.features.feed.ui.feed.state.NewsUMState + +internal class UpdateGlobalFeedStateTransformer( + private val loadingStatesByOrder: Map, + private val errorStatesByOrder: Map, + private val trendingNewsResult: TrendingNews, + private val onRetryClicked: () -> Unit, + private val analyticsEventHandler: AnalyticsEventHandler, +) : FeedListUMTransformer { + + @Suppress("CyclomaticComplexMethod") + override fun transform(prevState: FeedListUM): FeedListUM { + val previousGlobalState = prevState.globalState + + val isNewsLoading = prevState.news.newsUMState == NewsUMState.LOADING + val isNewsError = trendingNewsResult is TrendingNews.Error || + prevState.news.newsUMState == NewsUMState.ERROR + + val hasLoadingInCharts = loadingStatesByOrder.values.any { it } + val hasErrorInCharts = errorStatesByOrder.values.any { it != null } + val hasAllErrorInCharts = errorStatesByOrder.values.isNotEmpty() && errorStatesByOrder.values.all { it != null } + + val areAllChartsLoading = loadingStatesByOrder.values.isNotEmpty() && loadingStatesByOrder.values.all { it } + val areAllChartsError = hasAllErrorInCharts || hasErrorInCharts && hasLoadingInCharts + + val newGlobalState = when { + isNewsLoading && areAllChartsError -> GlobalFeedState.Loading + isNewsLoading && areAllChartsLoading -> GlobalFeedState.Loading + isNewsError && areAllChartsLoading -> GlobalFeedState.Loading + isNewsError && areAllChartsError -> { + if (previousGlobalState !is GlobalFeedState.Error) { + sendErrorAnalytics(errorStatesByOrder) + } + GlobalFeedState.Error( + onRetryClicked = onRetryClicked, + ) + } + else -> GlobalFeedState.Content + } + + return prevState.copy( + globalState = newGlobalState, + news = prevState.news.copy( + newsUMState = when { + isNewsError -> NewsUMState.ERROR + else -> NewsUMState.CONTENT + }, + ), + ) + } + + private fun sendErrorAnalytics(errorStatesByOrder: Map) { + val firstError: Throwable? = errorStatesByOrder.values.firstNotNullOfOrNull { it } + firstError?.let { error -> + val (code, message) = when (error) { + is ApiResponseError.HttpException -> error.code.numericCode to error.message + else -> null to "" + } + analyticsEventHandler.send( + FeedAnalyticsEvent.AllWidgetsLoadError( + code = code, + message = message.orEmpty(), + ), + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateMarketChartsTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateMarketChartsTransformer.kt new file mode 100644 index 0000000000..d556835e7a --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateMarketChartsTransformer.kt @@ -0,0 +1,102 @@ +package com.tangem.features.feed.model.feed.state.transformers + +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent +import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.ui.feed.state.FeedListUM +import com.tangem.features.feed.ui.feed.state.MarketChartUM +import com.tangem.features.feed.ui.feed.state.SortChartConfigUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentHashMap + +internal class UpdateMarketChartsTransformer( + private val itemsByOrder: Map>, + private val loadingStatesByOrder: Map, + private val errorStatesByOrder: Map, + private val onReloadAll: () -> Unit, + private val analyticsEventHandler: AnalyticsEventHandler, +) : FeedListUMTransformer { + + override fun transform(prevState: FeedListUM): FeedListUM { + val newMarketCharts = buildMap { + SortByTypeUM.entries.forEach { sortByType -> + val items = itemsByOrder[sortByType] ?: persistentListOf() + val isLoading = loadingStatesByOrder[sortByType] == true + val hasError = errorStatesByOrder[sortByType] != null + + when { + hasError -> { + put( + sortByType, + MarketChartUM.LoadingError( + onRetryClicked = onReloadAll, + ), + ) + } + isLoading -> { + put(sortByType, MarketChartUM.Loading) + } + items.isEmpty() -> { + put( + sortByType, + MarketChartUM.LoadingError( + onRetryClicked = onReloadAll, + ), + ) + } + else -> { + put( + sortByType, + MarketChartUM.Content( + items = items, + sortChartConfig = SortChartConfigUM( + sortByType = sortByType, + isSelected = sortByType == prevState.marketChartConfig.currentSortByType, + ), + ), + ) + } + } + } + }.toPersistentHashMap() + + val wasAnyErrorBefore = prevState.marketChartConfig.marketCharts.values.any { + it is MarketChartUM.LoadingError + } + val isAnyErrorNow = newMarketCharts.values.any { it is MarketChartUM.LoadingError } + + if (!wasAnyErrorBefore && isAnyErrorNow) { + sendErrorAnalytics(errorStatesByOrder, itemsByOrder) + } + + return prevState.copy( + marketChartConfig = prevState.marketChartConfig.copy( + marketCharts = newMarketCharts, + ), + ) + } + + private fun sendErrorAnalytics( + errorStatesByOrder: Map, + itemsByOrder: Map>, + ) { + val firstError = errorStatesByOrder.values.firstNotNullOfOrNull { it } + if (firstError == null && !itemsByOrder.values.any { it.isEmpty() }) { + return + } + val (code, message) = if (firstError is ApiResponseError.HttpException) { + firstError.code.numericCode to firstError.message.orEmpty() + } else { + null to "" + } + analyticsEventHandler.send( + FeedAnalyticsEvent.MarketsLoadError( + code = code, + message = message, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/TrendingNewsStateFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateTrendingNewsStateTransformer.kt similarity index 54% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/TrendingNewsStateFactory.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateTrendingNewsStateTransformer.kt index a132fd706f..5b19b9c5a9 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/TrendingNewsStateFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateTrendingNewsStateTransformer.kt @@ -1,8 +1,11 @@ -package com.tangem.features.feed.model.feed.state +package com.tangem.features.feed.model.feed.state.transformers +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.models.news.NewsError import com.tangem.domain.models.news.ShortArticle import com.tangem.domain.models.news.TrendingNews import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter +import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent import com.tangem.features.feed.ui.feed.state.FeedListUM import com.tangem.features.feed.ui.feed.state.NewsUM import com.tangem.features.feed.ui.feed.state.NewsUMState @@ -10,21 +13,32 @@ import com.tangem.utils.Provider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList -internal class TrendingNewsStateFactory( - private val currentStateProvider: Provider, - private val onStateUpdate: (FeedListUM) -> Unit, +internal class UpdateTrendingNewsStateTransformer( + private val result: TrendingNews, private val onRetryClicked: () -> Unit, -) { + private val analyticsEventHandler: AnalyticsEventHandler, +) : FeedListUMTransformer { - fun updateTrendingNewsState(result: TrendingNews) { - val currentState = currentStateProvider() - when (result) { - is TrendingNews.Data -> handleDataState(currentState, result.articles) - is TrendingNews.Error -> handleErrorState(currentState) + override fun transform(prevState: FeedListUM): FeedListUM { + return when (result) { + is TrendingNews.Data -> handleDataState(prevState, result.articles) + is TrendingNews.Error -> { + val (code, message) = when (val error = result.error) { + is NewsError.HttpError -> error.code to error.message + NewsError.NotHttpError -> null to "" + } + analyticsEventHandler.send( + FeedAnalyticsEvent.NewsLoadError( + code = code, + message = message, + ), + ) + handleErrorState(prevState) + } } } - private fun handleDataState(currentState: FeedListUM, articles: List) { + private fun handleDataState(currentState: FeedListUM, articles: List): FeedListUM { val (trendingArticle, commonArticles) = separateTrendingAndCommonArticles(articles) val commonArticlesUM = getShortArticleConfigConverter(isTrending = false) .convert(commonArticles) @@ -40,26 +54,22 @@ internal class TrendingNewsStateFactory( ) } - onStateUpdate( - currentState.copy( - trendingArticle = trendingArticle?.let { article -> - getShortArticleConfigConverter(isTrending = true) - .convert(listOf(article)) - }?.firstOrNull(), - news = updatedNews, - ), + return currentState.copy( + trendingArticle = trendingArticle?.let { article -> + getShortArticleConfigConverter(isTrending = true) + .convert(listOf(article)) + }?.firstOrNull(), + news = updatedNews, ) } - private fun handleErrorState(currentState: FeedListUM) { - onStateUpdate( - currentState.copy( - trendingArticle = null, - news = NewsUM( - content = persistentListOf(), - onRetryClicked = onRetryClicked, - newsUMState = NewsUMState.ERROR, - ), + private fun handleErrorState(currentState: FeedListUM): FeedListUM { + return currentState.copy( + trendingArticle = null, + news = NewsUM( + content = persistentListOf(), + onRetryClicked = onRetryClicked, + newsUMState = NewsUMState.ERROR, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index e402b48dff..f25acdab2d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onFirstVisible import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -329,7 +330,7 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, ) } }, - onSeeAllClick = feedListCallbacks.onOpenAllNews, + onSeeAllClick = { feedListCallbacks.onOpenAllNews(false) }, ) SpacerH(12.dp) @@ -356,23 +357,36 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, items( items = news.content, key = ArticleConfigUM::id, + contentType = { "article" }, ) { article -> + val articleModifier = if (news.content.indexOf(article) == FOURTH_ITEM_INDEX) { + Modifier.onFirstVisible( + minFractionVisible = 0.5f, + callback = feedListCallbacks.onSliderScroll, + ) + } else { + Modifier + } ArticleCard( articleConfigUM = article, onArticleClick = { feedListCallbacks.onArticleClick(article.id) }, - modifier = Modifier + modifier = articleModifier .heightIn(min = 164.dp) .width(216.dp), colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), ) } - item { + item(contentType = "show_more") { ShowMoreArticlesCard( modifier = Modifier .width(216.dp) - .heightIn(min = 164.dp), - onClick = feedListCallbacks.onOpenAllNews, + .heightIn(min = 164.dp) + .onFirstVisible( + minFractionVisible = 0.5f, + callback = feedListCallbacks.onSliderEndReached, + ), + onClick = { feedListCallbacks.onOpenAllNews(true) }, ) } } @@ -520,6 +534,7 @@ private fun NewsErrorBlock(onRetryClick: () -> Unit) { } private const val DEFAULT_CHART_SIZE_IN_MARKET = 5 +private const val FOURTH_ITEM_INDEX = 3 private const val GRADIENT_START = 0f private const val GRADIENT_END = 0.5f private val LinearGradientFirstPart = Color(0xFF635EEC) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt index 565f19a7bf..62d38e2004 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt @@ -32,6 +32,8 @@ internal object FeedListPreviewDataProvider { onOpenAllNews = {}, onMarketItemClick = {}, onSortTypeClick = {}, + onSliderScroll = {}, + onSliderEndReached = {}, ), news = NewsUM( content = articles.filter { it.isTrending.not() }.toImmutableList(), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt index 2602fb2395..3d51a5cb48 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt @@ -23,9 +23,11 @@ internal data class FeedListCallbacks( val onSearchClick: () -> Unit, val onMarketOpenClick: (sortBy: SortByTypeUM) -> Unit, val onArticleClick: (id: Int) -> Unit, - val onOpenAllNews: () -> Unit, + val onOpenAllNews: (isFromCarouselButton: Boolean) -> Unit, val onMarketItemClick: (MarketsListItemUM) -> Unit, - val onSortTypeClick: (SortByTypeUM) -> Unit, + val onSortTypeClick: (sortBy: SortByTypeUM) -> Unit, + val onSliderScroll: () -> Unit, + val onSliderEndReached: () -> Unit, ) internal data class FeedListSearchBar( From 8fe9bb6d1c8bd30f769086bbc9397c1a37484af6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 14 Jan 2026 17:14:50 +0300 Subject: [PATCH 097/172] Updated on 2026-08-14 --- .../com/tangem/screens/SendAddressPageObject.kt | 3 +-- .../SendSelectNetworkFeeBottomSheetPageObject.kt | 12 ++++++++++++ .../tests/send/feeScreen/SendFeeScreenTest.kt | 16 +++++++++++----- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt index d51d071203..0458682ee0 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt @@ -40,8 +40,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider } val addressTextFieldHint: KNode = child { - hasParent(withTestTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD)) - useUnmergedTree = true + hasTestTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD) } fun recipientNetworkCaution(network: String): KNode = child { diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendSelectNetworkFeeBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendSelectNetworkFeeBottomSheetPageObject.kt index 68d545c608..80e61ca2c0 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendSelectNetworkFeeBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendSelectNetworkFeeBottomSheetPageObject.kt @@ -22,6 +22,12 @@ class SendSelectNetworkFeeBottomSheetPageObject(semanticsProvider: SemanticsNode useUnmergedTree = true } + val chooseSpeedTitle: KNode = child { + hasTestTag(BaseBottomSheetTestTags.TITLE) + hasText(getResourceString(R.string.fee_selector_choose_speed_title)) + useUnmergedTree = true + } + fun regularFeeSelectorItem(title: String): KNode = child { hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_FEE_ITEM) hasAnyDescendant(withText(title)) @@ -96,6 +102,12 @@ class SendSelectNetworkFeeBottomSheetPageObject(semanticsProvider: SemanticsNode hasText(getResourceString(R.string.common_done)) useUnmergedTree = true } + + val confirmButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_confirm)) + useUnmergedTree = true + } } internal fun BaseTestCase.onSendSelectNetworkFeeBottomSheet(function: SendSelectNetworkFeeBottomSheetPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt index e3e66cb763..3f6c0ea10f 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt @@ -172,6 +172,9 @@ class SendFeeScreenTest : BaseTestCase() { step("Assert 'Fee selector' bottom sheet title is displayed") { onSendSelectNetworkFeeBottomSheet { title.assertIsDisplayed() } } + step("Click on '$marketSelectorItem' selector item") { + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSelectorItem).performClick() } + } step("Assert '$fastSelectorItem' selector item is displayed") { onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).assertIsDisplayed() } } @@ -261,6 +264,9 @@ class SendFeeScreenTest : BaseTestCase() { step("Click on 'Done' button") { onSendSelectNetworkFeeBottomSheet { doneButton.performClick() } } + step("Click on 'Confirm' button") { + onSendSelectNetworkFeeBottomSheet { confirmButton.performClick() } + } step("Assert fee block is displayed with new fee amount: '$newFeeAmount'") { checkNetworkFeeBlock(currentFeeAmount = newFeeAmount, withFeeSelector = true) } @@ -323,8 +329,8 @@ class SendFeeScreenTest : BaseTestCase() { step("Click on fee selector icon") { onSendConfirmScreen { feeSelectorIcon.performClick() } } - step("Assert 'Fee selector' bottom sheet title is displayed") { - onSendSelectNetworkFeeBottomSheet { title.assertIsDisplayed() } + step("Assert 'Choose speed' bottom sheet title is displayed") { + onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsDisplayed() } } step("Assert '$fastSelectorItem' selector item is displayed") { onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).assertIsDisplayed() } @@ -386,7 +392,7 @@ class SendFeeScreenTest : BaseTestCase() { val tokenName = "VeThor" val mockState = "Vechain" val tokenAmount = "0.1" - val feeAmount = "<$0.01" + val feeAmount = "~$0.01" val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market) val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast) val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow) @@ -421,8 +427,8 @@ class SendFeeScreenTest : BaseTestCase() { step("Click on fee selector icon") { onSendConfirmScreen { feeSelectorIcon.performClick() } } - step("Assert 'Fee selector' bottom sheet title is displayed") { - onSendSelectNetworkFeeBottomSheet { title.assertIsDisplayed() } + step("Assert 'Choose speed' bottom sheet title is displayed") { + onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsDisplayed() } } step("Assert '$fastSelectorItem' selector item is displayed") { onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).assertIsDisplayed() } From 3c6699674bd6dfe28a4309e28ea2163277c0c556 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 14 Jan 2026 19:25:38 +0100 Subject: [PATCH 098/172] Updated on 2026-08-14 --- .../features/feed/components/DefaultFeedEntryComponent.kt | 2 +- .../feature/wallet/presentation/wallet/ui/WalletScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 a4f6f6d824..444d17fe6b 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 @@ -158,7 +158,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( override fun Content(modifier: Modifier) { val background = TangemTheme.colors.background.tertiary CompositionLocalProvider( - LocalMainBottomSheetColor provides remember { mutableStateOf(background) }, + LocalMainBottomSheetColor provides remember(background) { mutableStateOf(background) }, ) { val bottomSheetState = remember { derivedStateOf { BottomSheetState.EXPANDED } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 1f2e8441c2..ce1ed559fc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -409,7 +409,7 @@ private inline fun BaseScaffoldWithMarkets( } CompositionLocalProvider( - LocalMainBottomSheetColor provides remember { mutableStateOf(background) }, + LocalMainBottomSheetColor provides remember(background) { mutableStateOf(background) }, ) { val backgroundColor = LocalMainBottomSheetColor.current var isSearchFieldFocused by remember { mutableStateOf(false) } From 58e8470996bae23e4db3a6c1d49360205101d67c Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 15 Jan 2026 06:50:44 +0300 Subject: [PATCH 099/172] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index f77f7250bc..a9daf843b3 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-1361" +tangemBlockchainSdk = "develop-1363" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 2a5fca2b176cba6d073b924e40f70a85421c2d25 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 15 Jan 2026 08:36:57 +0300 Subject: [PATCH 100/172] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index a9daf843b3..49a960701f 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-1363" +tangemBlockchainSdk = "develop-1364" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 417b2f0906cc64e90ed183587434bd2d1b227ff0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 15 Jan 2026 12:01:25 +0500 Subject: [PATCH 101/172] Updated on 2026-08-14 --- .../tangem/tap/di/domain/NewsDomainModule.kt | 5 ++ .../core/analytics/models/AnalyticsParam.kt | 4 ++ .../tangem/datasource/di/NewsStoreModule.kt | 8 +++ .../local/news/liked/DefaultNewsLikedStore.kt | 32 +++++++++++ .../local/news/liked/NewsLikedStore.kt | 24 ++++++++ .../com/tangem/core/ui/components/Buttons.kt | 4 ++ .../com/tangem/data/news/di/NewsDataModule.kt | 3 + .../news/repository/DefaultNewsRepository.kt | 36 ++++++++++-- .../tangem/data/news/repository/NewsMapper.kt | 3 +- .../domain/models/news/DetailedArticle.kt | 2 + .../domain/news/repository/NewsRepository.kt | 6 ++ .../news/usecase/ToggleArticleLikedUseCase.kt | 13 +++++ .../components/DefaultFeedEntryComponent.kt | 55 ++++++++++++++++--- .../feed/components/FeedEntryChildFactory.kt | 2 + .../DefaultMarketsTokenDetailsComponent.kt | 2 + .../details/DefaultNewsDetailsComponent.kt | 1 + .../feed/model/feed/FeedComponentModel.kt | 2 + .../feed/model/feed/FeedModelClickIntents.kt | 5 +- .../analytics/MarketDetailsAnalyticsEvent.kt | 3 +- .../model/news/details/NewsDetailsModel.kt | 41 ++++++++++++-- .../analytics/NewsDetailsAnalyticsEvent.kt | 44 +++++++++++++++ .../details/converter/NewsDetailsConverter.kt | 6 +- .../components/TokenMarketDetailsBody.kt | 2 +- .../ui/news/details/NewsDetailsContent.kt | 29 +++++++--- .../news/details/state/MockArticlesFactory.kt | 10 ++++ .../ui/news/details/state/NewsDetailsUM.kt | 3 +- 26 files changed, 313 insertions(+), 32 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/news/liked/DefaultNewsLikedStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/news/liked/NewsLikedStore.kt create mode 100644 domain/news/src/main/java/com/tangem/domain/news/usecase/ToggleArticleLikedUseCase.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/analytics/NewsDetailsAnalyticsEvent.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt index a49eceb64c..d3e9da60e7 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt @@ -41,6 +41,11 @@ internal object NewsDomainModule { return MarkArticleAsViewedUseCase(repository) } + @Provides + fun provideToggleArticleLikedUseCase(repository: NewsRepository): ToggleArticleLikedUseCase { + return ToggleArticleLikedUseCase(repository) + } + @Provides fun provideGetNewsUseCase(repository: NewsRepository): GetNewsUseCase { return GetNewsUseCase(repository) diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index fae4659e84..cf64bb4d14 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -77,6 +77,7 @@ sealed class AnalyticsParam { data object Backup : ScreensSources("Backup") data object Onboarding : ScreensSources("Onboarding") data object LongTap : ScreensSources("Long Tap") + data object Market : ScreensSources("Market") data object Markets : ScreensSources("Markets") data object MarketPulse : ScreensSources("Market Pulse") data object TangemPay : ScreensSources("Tangem Pay") @@ -87,6 +88,9 @@ sealed class AnalyticsParam { data object CreateWalletIntro : ScreensSources("Create Wallet Intro") data object AddNewWallet : ScreensSources("Add New Wallet") data object CreateWallet : ScreensSources("Create Wallet") + data object NewsList : ScreensSources("News List") + data object NewsLink : ScreensSources("News Link") + data object NewsPage : ScreensSources("News Page") } sealed class TxSentFrom(val value: String) { diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt index 33bd6b280a..22b046ee0c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt @@ -3,6 +3,8 @@ package com.tangem.datasource.di import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.news.details.DefaultNewsDetailsStore import com.tangem.datasource.local.news.details.NewsDetailsStore +import com.tangem.datasource.local.news.liked.DefaultNewsLikedStore +import com.tangem.datasource.local.news.liked.NewsLikedStore import com.tangem.datasource.local.news.trending.DefaultTrendingNewsStore import com.tangem.datasource.local.news.trending.TrendingNewsStore import com.tangem.datasource.local.news.viewed.DefaultNewsViewedStore @@ -34,4 +36,10 @@ internal object NewsStoreModule { fun provideNewsViewedStore(): NewsViewedStore { return DefaultNewsViewedStore(store = RuntimeSharedStore()) } + + @Provides + @Singleton + fun provideNewsLikedStore(): NewsLikedStore { + return DefaultNewsLikedStore(store = RuntimeSharedStore()) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/liked/DefaultNewsLikedStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/liked/DefaultNewsLikedStore.kt new file mode 100644 index 0000000000..cd135334e9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/liked/DefaultNewsLikedStore.kt @@ -0,0 +1,32 @@ +package com.tangem.datasource.local.news.liked + +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onStart + +private typealias NewsLikedCache = Map + +internal class DefaultNewsLikedStore( + private val store: RuntimeSharedStore, +) : NewsLikedStore { + + override fun getAll(): Flow> { + return store.get().onStart { emit(emptyMap()) } + } + + override suspend fun getSync(): Map { + return store.getSyncOrNull().orEmpty() + } + + override suspend fun updateLiked(articleIds: Collection, liked: Boolean) { + if (articleIds.isEmpty()) return + + store.update(emptyMap()) { current -> + val updated = current.toMutableMap() + articleIds.forEach { id -> + updated[id] = liked + } + updated + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/liked/NewsLikedStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/liked/NewsLikedStore.kt new file mode 100644 index 0000000000..6decede719 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/liked/NewsLikedStore.kt @@ -0,0 +1,24 @@ +package com.tangem.datasource.local.news.liked + +import kotlinx.coroutines.flow.Flow + +/** + * Store for news liked flags (runtime only). + */ +interface NewsLikedStore { + + /** + * Observes all liked flags. + */ + fun getAll(): Flow> + + /** + * Gets liked flags synchronously (returns empty map if no data). + */ + suspend fun getSync(): Map + + /** + * Updates liked flags for provided article ids. + */ + suspend fun updateLiked(articleIds: Collection, liked: Boolean) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt index 00b71108b4..8c5d8daa49 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt @@ -170,6 +170,8 @@ fun PrimaryButtonIconStart( modifier: Modifier = Modifier, showProgress: Boolean = false, enabled: Boolean = true, + size: TangemButtonSize = TangemButtonSize.Default, + shape: Shape = size.toShape(), ) { TangemButton( modifier = modifier, @@ -180,6 +182,8 @@ fun PrimaryButtonIconStart( enabled = enabled, showProgress = showProgress, textStyle = TangemTheme.typography.subtitle1, + size = size, + shape = shape, ) } // endregion PrimaryButton diff --git a/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt b/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt index 90851234d3..530884bfcf 100644 --- a/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt +++ b/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt @@ -4,6 +4,7 @@ import com.tangem.data.news.DefaultNewsErrorResolver import com.tangem.data.news.repository.DefaultNewsRepository import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.local.news.details.NewsDetailsStore +import com.tangem.datasource.local.news.liked.NewsLikedStore import com.tangem.datasource.local.news.trending.TrendingNewsStore import com.tangem.datasource.local.news.viewed.NewsViewedStore import com.tangem.domain.news.NewsErrorResolver @@ -27,6 +28,7 @@ internal object NewsDataModule { newsDetailsStore: NewsDetailsStore, trendingNewsStore: TrendingNewsStore, newsViewedStore: NewsViewedStore, + newsLikedStore: NewsLikedStore, newsErrorResolver: NewsErrorResolver, ): NewsRepository { return DefaultNewsRepository( @@ -35,6 +37,7 @@ internal object NewsDataModule { newsDetailsStore = newsDetailsStore, trendingNewsStore = trendingNewsStore, newsViewedStore = newsViewedStore, + newsLikedStore = newsLikedStore, newsErrorResolver = newsErrorResolver, ) } diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt index 8466016a58..9fbf09d3f3 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -6,9 +6,13 @@ import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.api.news.models.response.NewsTrendingResponse import com.tangem.datasource.local.news.details.NewsDetailsStore +import com.tangem.datasource.local.news.liked.NewsLikedStore import com.tangem.datasource.local.news.trending.TrendingNewsStore import com.tangem.datasource.local.news.viewed.NewsViewedStore -import com.tangem.domain.models.news.* +import com.tangem.domain.models.news.ArticleCategory +import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.models.news.TrendingNews import com.tangem.domain.news.NewsErrorResolver import com.tangem.domain.news.model.NewsListBatchFlow import com.tangem.domain.news.model.NewsListBatchingContext @@ -27,12 +31,14 @@ import timber.log.Timber * Implementation of [NewsRepository]. [REDACTED_AUTHOR] */ +@Suppress("LongParameterList") internal class DefaultNewsRepository( private val newsApi: NewsApi, private val dispatchers: CoroutineDispatcherProvider, private val newsDetailsStore: NewsDetailsStore, private val trendingNewsStore: TrendingNewsStore, private val newsViewedStore: NewsViewedStore, + private val newsLikedStore: NewsLikedStore, private val newsErrorResolver: NewsErrorResolver, ) : NewsRepository { @@ -71,9 +77,17 @@ internal class DefaultNewsRepository( } override fun observeDetailedArticles(): Flow> { - return newsDetailsStore.getAll().map { articles -> - articles.associateBy(DetailedArticle::id) - } + return newsDetailsStore.getAll() + .combine(newsLikedStore.getAll()) { articles, likedFlags -> + articles.map { article -> + article.copy( + isLiked = isNewsLiked(article.id), + ) + } + } + .map { articles -> + articles.associateBy(DetailedArticle::id) + } } override suspend fun fetchDetailedArticles(newsIds: Collection, language: String?) { @@ -118,6 +132,16 @@ internal class DefaultNewsRepository( newsViewedStore.updateViewed(articleIds, viewed) } + private suspend fun isNewsLiked(articleId: Int): Boolean { + return newsLikedStore.getSync()[articleId] == true + } + + override suspend fun toggleNewsLiked(articleId: Int): Boolean = withContext(dispatchers.io) { + val isNewsLikedValue = !isNewsLiked(articleId) + newsLikedStore.updateLiked(listOf(articleId), isNewsLikedValue) + isNewsLikedValue + } + private fun updateViewedStatusForNewsBatch( newsBatchFlow: NewsListBatchFlow, scope: CoroutineScope, @@ -148,7 +172,9 @@ internal class DefaultNewsRepository( async { newsApi.getNewsDetails(newsId = newsId, language = language) .getOrThrow() - .toDomainDetailedArticle() + .toDomainDetailedArticle( + isLiked = isNewsLiked(newsId), + ) } }.awaitAll() } diff --git a/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt b/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt index 7f63a42814..50a571f289 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt @@ -6,7 +6,7 @@ import com.tangem.datasource.api.news.models.response.NewsOriginalArticleDto import com.tangem.datasource.api.news.models.response.NewsRelatedTokenDto import com.tangem.domain.models.news.* -internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle { +internal fun NewsDetailsResponse.toDomainDetailedArticle(isLiked: Boolean): DetailedArticle { return DetailedArticle( id = id, createdAt = createdAt, @@ -20,6 +20,7 @@ internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle { shortContent = shortContent, content = content, originalArticles = originalArticles.map { it.toDomainOriginalArticle() }, + isLiked = isLiked, ) } diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/news/DetailedArticle.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/DetailedArticle.kt index ea41bfe17f..94cc02be56 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/news/DetailedArticle.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/DetailedArticle.kt @@ -18,6 +18,7 @@ import kotlinx.serialization.Serializable * @param shortContent - short description * @param content - main text of the article * @param originalArticles - original articles, which were base to build detailed article. + * @param isLiked - flag indicating whether the news is liked or not */ @Serializable data class DetailedArticle( @@ -33,4 +34,5 @@ data class DetailedArticle( val shortContent: String, val content: String, val originalArticles: List, + val isLiked: Boolean, ) \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt index 4522fe3a7c..cf906001f2 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt @@ -61,4 +61,10 @@ interface NewsRepository { * Updates viewed flag for provided news articles (applies to both regular and trending news). */ suspend fun updateNewsViewed(articleIds: Collection, viewed: Boolean) + + /** + * Toggle news liked state + * @return an actual liked state of the article + */ + suspend fun toggleNewsLiked(articleId: Int): Boolean } \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ToggleArticleLikedUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ToggleArticleLikedUseCase.kt new file mode 100644 index 0000000000..90cac29915 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ToggleArticleLikedUseCase.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.news.usecase + +import com.tangem.domain.news.repository.NewsRepository + +class ToggleArticleLikedUseCase(private val repository: NewsRepository) { + + /** + * Toggle an article liked state. + */ + suspend fun toggleLiked(articleId: Int): Boolean { + return repository.toggleNewsLiked(articleId) + } +} \ No newline at end of file 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 444d17fe6b..0be2031e7d 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 @@ -10,6 +10,7 @@ import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.value.Value +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel @@ -51,7 +52,12 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( ) private val clickIntents = object : FeedEntryClickIntents { - override fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency) { + override fun onMarketItemClick( + token: TokenMarketParams, + appCurrency: AppCurrency, + source: String, + newsId: Int?, + ) { innerRouter.push( route = FeedEntryChildFactory.Child.TokenDetails( params = DefaultMarketsTokenDetailsComponent.Params( @@ -60,11 +66,16 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( shouldShowPortfolio = true, analyticsParams = DefaultMarketsTokenDetailsComponent.AnalyticsParams( blockchain = null, - source = "Market", + newsId = newsId, + source = source, ), onBackClicked = { onChildBack() }, onArticleClick = { articleId, preselectedArticlesId -> - onArticleClick(articleId, preselectedArticlesId) + onArticleClick( + articleId = articleId, + preselectedArticlesId = preselectedArticlesId, + screenSource = AnalyticsParam.ScreensSources.Markets, + ) }, ), ), @@ -76,7 +87,13 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( route = FeedEntryChildFactory.Child.TokenList( params = DefaultMarketsTokenListComponent.Params( onBackClicked = { onChildBack() }, - onTokenClick = { token, currency -> onMarketItemClick(token, currency) }, + onTokenClick = { token, currency -> + onMarketItemClick( + token = token, + appCurrency = currency, + source = AnalyticsParam.ScreensSources.Market.value, + ) + }, preselectedSortType = sortBy ?: SortByTypeUM.Rating, shouldAlwaysShowSearchBar = sortBy == null, ), @@ -87,16 +104,25 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( override fun onArticleClick( articleId: Int, preselectedArticlesId: List, + screenSource: AnalyticsParam.ScreensSources, paginationConfig: NewsListConfig?, ) { innerRouter.push( FeedEntryChildFactory.Child.NewsDetails( params = DefaultNewsDetailsComponent.Params( + screenSource = screenSource.value, articleId = articleId, onBackClicked = { onChildBack() }, preselectedArticlesId = preselectedArticlesId, paginationConfig = paginationConfig, - onTokenClick = { token, currency -> onMarketItemClick(token, currency) }, + onTokenClick = { token, currency -> + onMarketItemClick( + token = token, + appCurrency = currency, + source = AnalyticsParam.ScreensSources.NewsPage.value, + newsId = articleId, + ) + }, ), ), ) @@ -201,6 +227,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( clickIntents.onArticleClick( articleId = articleId, preselectedArticlesId = preselectedArticlesId, + screenSource = AnalyticsParam.ScreensSources.Token, paginationConfig = null, ) }, @@ -209,17 +236,31 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList( DefaultMarketsTokenListComponent.Params( onBackClicked = { router.pop() }, - onTokenClick = { token, currency -> clickIntents.onMarketItemClick(token, currency) }, + onTokenClick = { token, currency -> + clickIntents.onMarketItemClick( + token = token, + appCurrency = currency, + source = AnalyticsParam.ScreensSources.Market.value, + ) + }, preselectedSortType = SortByTypeUM.Rating, shouldAlwaysShowSearchBar = false, ), ) is FeedEntryRoute.NewsDetail -> FeedEntryChildFactory.Child.NewsDetails( DefaultNewsDetailsComponent.Params( + screenSource = AnalyticsParam.ScreensSources.NewsLink.value, articleId = entryRoute.articleId, onBackClicked = { router.pop() }, preselectedArticlesId = entryRoute.preselectedArticlesId, - onTokenClick = { token, currency -> clickIntents.onMarketItemClick(token, currency) }, + onTokenClick = { token, currency -> + clickIntents.onMarketItemClick( + token = token, + appCurrency = currency, + source = AnalyticsParam.ScreensSources.NewsPage.value, + newsId = entryRoute.articleId, + ) + }, ), ) null -> FeedEntryChildFactory.Child.Feed diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index 8bf90f863d..0168a3c88a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -2,6 +2,7 @@ package com.tangem.features.feed.components import androidx.compose.runtime.Immutable import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.navigation.Route import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent @@ -82,6 +83,7 @@ internal class FeedEntryChildFactory @Inject constructor( feedEntryClickIntents.onArticleClick( articleId = currentArticle, preselectedArticlesId = prefetchedArticles, + screenSource = AnalyticsParam.ScreensSources.NewsList, paginationConfig = paginationConfig, ) }, 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 31352a5aef..2a561c6ba5 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 @@ -78,6 +78,7 @@ internal class DefaultMarketsTokenDetailsComponent( ).screenOpened( blockchain = analyticsParams.blockchain, source = analyticsParams.source, + newsId = analyticsParams.newsId, ), ) } @@ -138,5 +139,6 @@ internal class DefaultMarketsTokenDetailsComponent( data class AnalyticsParams( val blockchain: String?, val source: String, + val newsId: Int? = null, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt index c663891a5e..38bef9331e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt @@ -60,6 +60,7 @@ internal class DefaultNewsDetailsComponent( @Serializable data class Params( + val screenSource: String, val articleId: Int, val onBackClicked: () -> Unit, val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index c8d8412ae3..d2690e25b3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -269,6 +269,7 @@ internal class FeedComponentModel @Inject constructor( params.feedClickIntents.onMarketItemClick( token = tokenMarket.toSerializableParam(), appCurrency = currentAppCurrency.value, + source = AnalyticsParam.ScreensSources.Market.value, ) } } @@ -317,6 +318,7 @@ internal class FeedComponentModel @Inject constructor( -> emptyList() }, paginationConfig = null, + screenSource = AnalyticsParam.ScreensSources.Markets, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt index d2fa25d6e6..d3ade33796 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt @@ -1,5 +1,6 @@ package com.tangem.features.feed.model.feed +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.news.model.NewsListConfig @@ -9,18 +10,20 @@ import com.tangem.features.feed.model.market.list.state.SortByTypeUM * Callback interface for feed model navigation actions. */ internal interface FeedModelClickIntents { - fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency) + fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency, source: String, newsId: Int? = null) fun onMarketOpenClick(sortBy: SortByTypeUM?) /** * Method to open article of news from different places. * @param articleId - id of article, which should be opened. * @param preselectedArticlesId - ids of articles, which were prefetched before opening news details. + * @param screenSource - source of screen, from which article was opened. * @param paginationConfig - config of current pagination from news list. Must be null, when open from other places. */ fun onArticleClick( articleId: Int, preselectedArticlesId: List = emptyList(), + screenSource: AnalyticsParam.ScreensSources, paginationConfig: NewsListConfig? = null, ) fun onOpenAllNews() diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/analytics/MarketDetailsAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/analytics/MarketDetailsAnalyticsEvent.kt index dee935f3d9..05b527c4ff 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/analytics/MarketDetailsAnalyticsEvent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/analytics/MarketDetailsAnalyticsEvent.kt @@ -12,12 +12,13 @@ internal class MarketDetailsAnalyticsEvent( data class EventBuilder( val token: TokenMarketParams, ) { - fun screenOpened(blockchain: String?, source: String) = MarketDetailsAnalyticsEvent( + fun screenOpened(blockchain: String?, source: String, newsId: Int? = null) = MarketDetailsAnalyticsEvent( event = "Token Chart Screen Opened", params = buildMap { put("Token", token.symbol) blockchain?.let { put("blockchain", it) } put("Source", source) + newsId?.let { put("News Id", it.toString()) } }, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt index ab257ebb6b..5dba83b983 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.feed.model.news.details import androidx.compose.runtime.Stable import arrow.core.getOrElse +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -11,14 +12,17 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.GetTokenMarketInfoUseCase import com.tangem.domain.markets.GetTokenPriceChartUseCase +import com.tangem.domain.models.news.OriginalArticle import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase import com.tangem.domain.news.usecase.MarkArticleAsViewedUseCase import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase +import com.tangem.domain.news.usecase.ToggleArticleLikedUseCase import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent -import com.tangem.features.feed.model.news.details.factory.NewsDetailsIndexManager +import com.tangem.features.feed.model.news.details.analytics.NewsDetailsAnalyticsEvent import com.tangem.features.feed.model.news.details.converter.NewsDetailsConverter -import com.tangem.features.feed.model.news.details.loader.NewsRelatedTokensLoader +import com.tangem.features.feed.model.news.details.factory.NewsDetailsIndexManager import com.tangem.features.feed.model.news.details.factory.NewsDetailsStateFactory +import com.tangem.features.feed.model.news.details.loader.NewsRelatedTokensLoader import com.tangem.features.feed.ui.news.details.state.ArticleUM import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM @@ -45,13 +49,15 @@ internal class NewsDetailsModel @Inject constructor( private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, private val markArticleAsViewedUseCase: MarkArticleAsViewedUseCase, + private val toggleArticleLikedUseCase: ToggleArticleLikedUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, paramsContainer: ParamsContainer, ) : Model() { private val params = paramsContainer.require() private val currentLanguage = Locale.getDefault().language - private val newsDetailsConverter = NewsDetailsConverter(onSourceClick = urlOpener::openUrl) + private val newsDetailsConverter = NewsDetailsConverter(onSourceClick = ::onOriginalArticleClick) private val paginationManager: NewsDetailsPaginationManager? = params.paginationConfig?.let { config -> NewsDetailsPaginationManager( @@ -90,7 +96,7 @@ internal class NewsDetailsModel @Inject constructor( articles = persistentListOf(), selectedArticleIndex = -1, onShareClick = {}, - onLikeClick = { /* [REDACTED_TODO_COMMENT] */ }, + onLikeClick = ::onLikeClick, onBackClick = params.onBackClicked, onArticleIndexChanged = ::onArticleIndexChanged, ), @@ -108,9 +114,36 @@ internal class NewsDetailsModel @Inject constructor( val state: StateFlow = _state.asStateFlow() init { + trackScreenOpened() loadNews() } + private fun trackScreenOpened() { + analyticsEventHandler.send( + event = NewsDetailsAnalyticsEvent.NewsArticleOpened( + source = params.screenSource, + newsId = params.articleId, + ), + ) + } + + private fun onLikeClick(articleId: Int) { + modelScope.launch { + toggleArticleLikedUseCase.toggleLiked(articleId) + analyticsEventHandler.send(NewsDetailsAnalyticsEvent.NewsLikeClicked(articleId)) + } + } + + private fun onOriginalArticleClick(originalArticle: OriginalArticle) { + analyticsEventHandler.send( + NewsDetailsAnalyticsEvent.RelatedNewsClicked( + newsId = params.articleId, + relatedNewsId = originalArticle.id, + ), + ) + urlOpener.openUrl(originalArticle.url) + } + private fun onArticleIndexChanged(newIndex: Int) { stateFactory.updateSelectedArticleIndex(newIndex) val currentArticle = when (state.value.articlesStateUM) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/analytics/NewsDetailsAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/analytics/NewsDetailsAnalyticsEvent.kt new file mode 100644 index 0000000000..73b92d193e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/analytics/NewsDetailsAnalyticsEvent.kt @@ -0,0 +1,44 @@ +package com.tangem.features.feed.model.news.details.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.OneTimePerSessionEvent + +internal sealed class NewsDetailsAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Markets", event = event, params = params) { + + data class NewsArticleOpened( + private val source: String, + private val newsId: Int, + ) : NewsDetailsAnalyticsEvent( + event = "News Article Opened", + params = mapOf( + AnalyticsParam.SOURCE to source, + "News Id" to newsId.toString(), + ), + ) + + data class RelatedNewsClicked( + private val newsId: Int, + private val relatedNewsId: Int, + ) : NewsDetailsAnalyticsEvent( + event = "Related News Clicked", + params = mapOf( + "News Id" to newsId.toString(), + "Related News Id" to relatedNewsId.toString(), + ), + ) + + data class NewsLikeClicked( + private val newsId: Int, + ) : NewsDetailsAnalyticsEvent( + event = "News Like Clicked", + params = mapOf( + "News Id" to newsId.toString(), + ), + ), OneTimePerSessionEvent { + override val oneTimeEventId: String = event + newsId + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt index a877901aa0..95fd1b69b0 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt @@ -11,6 +11,7 @@ import com.tangem.core.ui.utils.getFormattedDate import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.models.news.OriginalArticle import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.news.details.state.ArticleUM import com.tangem.features.feed.ui.news.details.state.Source @@ -23,7 +24,7 @@ import org.joda.time.DateTime @Stable internal class NewsDetailsConverter( - private val onSourceClick: (String) -> Unit, + private val onSourceClick: (OriginalArticle) -> Unit, ) : Converter { override fun convert(value: DetailedArticle): ArticleUM { @@ -38,6 +39,7 @@ internal class NewsDetailsConverter( sources = buildSources(value), newsUrl = value.newsUrl, relatedTokens = value.relatedTokens.toImmutableList(), + isLiked = value.isLiked, ) } @@ -66,7 +68,7 @@ internal class NewsDetailsConverter( source = Source(id = originalArticle.source.id, name = originalArticle.source.name), publishedAt = mapFormattedDate(originalArticle.publishedAt), url = originalArticle.url, - onClick = { onSourceClick(originalArticle.url) }, + onClick = { onSourceClick(originalArticle) }, imageUrl = originalArticle.imageUrl, ) }.toImmutableList() diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt index 0d47363ec9..764fcff180 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -220,7 +220,7 @@ private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { ) { Text( modifier = Modifier.padding(start = 16.dp), - text = "Related news", + text = stringResourceSafe(R.string.news_related_news), style = TangemTheme.typography.h3, color = TangemTheme.colors.text.primary1, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt index 8010cc035f..f3ab2009a2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt @@ -112,10 +112,11 @@ private fun Content(state: NewsDetailsUM, background: Color) { state = pagerState, modifier = Modifier.fillMaxSize(), ) { page -> + val article = state.articles[page] ArticleDetail( - article = state.articles[page], + article = article, modifier = Modifier.fillMaxSize(), - onLikeClick = state.onLikeClick, + onLikeClick = { state.onLikeClick(article.id) }, relatedTokensUM = state.relatedTokensUM, ) } @@ -176,13 +177,23 @@ private fun ArticleDetail( SpacerH(24.dp) - SecondaryButtonIconStart( - modifier = Modifier.padding(horizontal = 16.dp), - iconResId = R.drawable.ic_heart_20, - text = stringResourceSafe(R.string.news_like), - size = TangemButtonSize.RoundedAction, - onClick = onLikeClick, - ) + if (article.isLiked) { + PrimaryButtonIconStart( + modifier = Modifier.padding(horizontal = 16.dp), + iconResId = R.drawable.ic_heart_20, + text = stringResourceSafe(R.string.news_like), + size = TangemButtonSize.RoundedAction, + onClick = { onLikeClick() }, + ) + } else { + SecondaryButtonIconStart( + modifier = Modifier.padding(horizontal = 16.dp), + iconResId = R.drawable.ic_heart_20, + text = stringResourceSafe(R.string.news_like), + size = TangemButtonSize.RoundedAction, + onClick = { onLikeClick() }, + ) + } RelatedTokensBlock( relatedTokensUM = relatedTokensUM, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt index 4720658f0a..7f051d52fc 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt @@ -48,6 +48,7 @@ internal object MockArticlesFactory { ).toPersistentList(), newsUrl = "", relatedTokens = persistentListOf(), + isLiked = true, ), ArticleUM( id = 2, @@ -76,6 +77,7 @@ internal object MockArticlesFactory { ).toPersistentList(), newsUrl = "", relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 3, @@ -104,6 +106,7 @@ internal object MockArticlesFactory { ).toPersistentList(), newsUrl = "", relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 4, @@ -132,6 +135,7 @@ internal object MockArticlesFactory { ).toPersistentList(), newsUrl = "", relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 5, @@ -146,6 +150,7 @@ internal object MockArticlesFactory { sources = persistentListOf(), newsUrl = "", relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 6, @@ -161,6 +166,7 @@ internal object MockArticlesFactory { sources = persistentListOf(), newsUrl = "", relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 7, @@ -175,6 +181,7 @@ internal object MockArticlesFactory { sources = persistentListOf(), newsUrl = "", relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 8, @@ -189,6 +196,7 @@ internal object MockArticlesFactory { sources = persistentListOf(), newsUrl = "", relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 9, @@ -204,6 +212,7 @@ internal object MockArticlesFactory { sources = persistentListOf(), newsUrl = "", relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 10, @@ -219,6 +228,7 @@ internal object MockArticlesFactory { sources = persistentListOf(), newsUrl = "", relatedTokens = persistentListOf(), + isLiked = false, ), ).toPersistentList() } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt index f8bcdf5f7d..a3402431c2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt @@ -12,7 +12,7 @@ internal data class NewsDetailsUM( val articles: ImmutableList, val selectedArticleIndex: Int, val onShareClick: () -> Unit, - val onLikeClick: () -> Unit, + val onLikeClick: (articleId: Int) -> Unit, val onBackClick: () -> Unit, val onArticleIndexChanged: (Int) -> Unit, val relatedTokensUM: RelatedTokensUM = RelatedTokensUM.Loading, @@ -36,6 +36,7 @@ internal data class ArticleUM( val sources: ImmutableList, val newsUrl: String, val relatedTokens: ImmutableList, + val isLiked: Boolean, ) internal data class SourceUM( From 8541119c1a18809ee563a83fca32338aab95fc80 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 15 Jan 2026 08:35:25 +0100 Subject: [PATCH 102/172] Updated on 2026-08-14 --- .../feed/model/feed/state/FeedMarketsBatchFlowManager.kt | 8 +++++++- .../com/tangem/features/feed/ui/feed/state/FeedListUM.kt | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt index ac4f510e63..5f229e09b8 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt @@ -30,7 +30,13 @@ internal class FeedMarketsBatchFlowManager( private val modelScope: CoroutineScope, private val dispatchers: CoroutineDispatcherProvider, ) { - private val managersByOrder = TokenMarketListConfig.Order.entries.associateWith { order -> + private val managersByOrder = listOf( + TokenMarketListConfig.Order.ByRating, + TokenMarketListConfig.Order.Trending, + TokenMarketListConfig.Order.Buyers, + TokenMarketListConfig.Order.TopGainers, + TokenMarketListConfig.Order.TopLosers, + ).associateWith { order -> createManagerForOrder(order) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt index 3d51a5cb48..05db893d32 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt @@ -7,7 +7,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.features.feed.model.market.list.state.SortByTypeUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.persistentListOf internal data class FeedListUM( val currentDate: String, @@ -51,7 +51,12 @@ internal data class MarketChartConfig( val marketCharts: ImmutableMap, val currentSortByType: SortByTypeUM = SortByTypeUM.TopGainers, ) { - fun getFilterPreset() = SortByTypeUM.entries.filter { it != SortByTypeUM.Rating }.toPersistentList() + fun getFilterPreset() = persistentListOf( + SortByTypeUM.Trending, + SortByTypeUM.ExperiencedBuyers, + SortByTypeUM.TopGainers, + SortByTypeUM.TopLosers, + ) } @Immutable From 7703758e5681486961fd00d64a5865d911b66272 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 13 Jan 2026 15:41:45 +0300 Subject: [PATCH 103/172] Updated on 2026-08-14 --- .../feature/swap/ui/SwapSelectTokenScreen.kt | 176 +++++++++++------- .../ui/market/SwapMarketsListLazyColumn.kt | 94 ++++++++++ .../preview/SwapSelectTokenPreviewProvider.kt | 155 +++++++++++++++ 3 files changed, 362 insertions(+), 63 deletions(-) create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt index 6b0fa946f4..bd038744a8 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt @@ -7,10 +7,16 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -20,13 +26,16 @@ import androidx.compose.ui.semantics.semantics 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 com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH32 import com.tangem.core.ui.components.SpacerW2 import com.tangem.core.ui.components.appbar.ExpandableSearchView +import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.currency.icon.CurrencyIcon -import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.tokenlist.PortfolioListItem import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem import com.tangem.core.ui.components.tokenlist.TokenListItem @@ -38,12 +47,18 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BuyTokenScreenTestTags import com.tangem.core.ui.utils.lazyListItemPosition import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.TokenBalanceData import com.tangem.feature.swap.models.TokenListUMData import com.tangem.feature.swap.models.TokenToSelectState +import com.tangem.feature.swap.models.isEmptyState +import com.tangem.feature.swap.models.isNotFoundState +import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.presentation.R +import com.tangem.feature.swap.ui.market.swapMarketsListItems +import com.tangem.feature.swap.ui.preview.SwapSelectTokenPreviewProvider import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.persistentListOf + +private const val LOAD_MORE_BUFFER = 25 @Composable internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) { @@ -56,17 +71,9 @@ internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () content = { padding -> val modifier = Modifier.padding(padding) when { - state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && - state.tokensListData.tokensList.isEmpty() && state.isAfterSearch -> { - TokensNotFound(modifier) - } - state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && - state.tokensListData.tokensList.isEmpty() && !state.isAfterSearch -> { - EmptyTokensList(modifier) - } - else -> { - ListOfTokens(state = state, modifier = modifier) - } + state.isNotFoundState -> TokensNotFound(modifier) + state.isEmptyState -> EmptyTokensList(modifier) + else -> ListOfTokens(state = state, modifier = modifier) } }, topBar = { @@ -135,19 +142,24 @@ private fun TokensNotFound(modifier: Modifier = Modifier) { @Composable private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) { val screenBackgroundColor = TangemTheme.colors.background.secondary + val lazyListState = rememberLazyListState() + LazyColumn( modifier = modifier .background(color = screenBackgroundColor) .fillMaxSize() .imePadding(), horizontalAlignment = Alignment.CenterHorizontally, + state = lazyListState, ) { when (val list = state.tokensListData) { - is TokenListUMData.AccountList -> list.tokensList.forEach { item -> - portfolioTokensList( - portfolio = item, - isBalanceHidden = state.isBalanceHidden, - ) + is TokenListUMData.AccountList -> { + list.tokensList.forEach { item -> + portfolioTokensList( + portfolio = item, + isBalanceHidden = state.isBalanceHidden, + ) + } } is TokenListUMData.TokenList -> { tokensList( @@ -158,13 +170,56 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = TokenListUMData.EmptyList -> Unit } - tokensToSelectItems(state.availableTokens, state.onTokenSelected) + if (state.availableTokens.isNotEmpty()) { + tokensToSelectItems(state.availableTokens, state.onTokenSelected) + } + if (state.unavailableTokens.isNotEmpty()) { + item { SpacerH12() } + tokensToSelectItems(state.unavailableTokens, state.onTokenSelected) + } - item { SpacerH12() } + if (state.availableTokens.isNotEmpty() || state.unavailableTokens.isNotEmpty()) { + item { SpacerH32() } + } else { + item { SpacerH12() } + } - tokensToSelectItems(state.unavailableTokens, state.onTokenSelected) + state.marketsState?.let(::swapMarketsListItems) + } - item { SpacerH12() } + (state.marketsState as? SwapMarketState.Content)?.let { marketState -> + VisibleItemsTracker( + lazyListState = lazyListState, + marketState = marketState, + ) + + InfiniteListHandler( + listState = lazyListState, + buffer = LOAD_MORE_BUFFER, + triggerLoadMoreCheckOnItemsCountChange = true, + onLoadMore = remember(state) { + { + state.marketsState.loadMore() + true + } + }, + ) + } +} + +@Composable +private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapMarketState.Content) { + val visibleItems by remember { + derivedStateOf { + lazyListState.layoutInfo.visibleItemsInfo + .mapNotNull { itemInfo -> + marketState.items.find { it.getComposeKey() == itemInfo.key }?.id + } + } + } + + LaunchedEffect(visibleItems) { + marketState.visibleIdsChanged(visibleItems) } } @@ -387,50 +442,45 @@ private fun TokenItem( } } -private val token = TokenToSelectState.TokenToSelect( - tokenIcon = CurrencyIconState.CoinIcon( - url = "", - fallbackResId = 0, - isGrayscale = false, - shouldShowCustomBadge = false, - ), - id = "", - name = "Optimistic Ethereum (ETH)", - symbol = "USDC", - addedTokenBalanceData = TokenBalanceData( - amount = "15 000 $", - amountEquivalent = "15 000 USDT", - isBalanceHidden = false, - ), -) - -private val title = TokenToSelectState.Title( - title = stringReference("MY TOKENS"), -) - -@Preview -@Composable -private fun TokenScreenPreview() { - TangemThemePreview { - SwapSelectTokenScreen( - state = SwapSelectTokenStateHolder( - availableTokens = listOf(title, token, token, token).toImmutableList(), - unavailableTokens = listOf(title, token, token, token).toImmutableList(), - tokensListData = TokenListUMData.EmptyList, - isAfterSearch = false, - isBalanceHidden = false, - onSearchEntered = {}, - onTokenSelected = {}, - ), - onBack = {}, - ) - } +private class SwapSelectTokenScreenPreviewProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + // Content state with tokens and markets + SwapSelectTokenPreviewProvider().provideSwapSelectTokenState(), + // Empty state + SwapSelectTokenStateHolder( + availableTokens = persistentListOf(), + unavailableTokens = persistentListOf(), + tokensListData = TokenListUMData.EmptyList, + marketsState = null, + isAfterSearch = false, + isBalanceHidden = false, + onSearchEntered = {}, + onTokenSelected = {}, + ), + // Not found state + SwapSelectTokenStateHolder( + availableTokens = persistentListOf(), + unavailableTokens = persistentListOf(), + tokensListData = TokenListUMData.EmptyList, + marketsState = null, + isAfterSearch = true, + isBalanceHidden = false, + onSearchEntered = {}, + onTokenSelected = {}, + ), + ) } @Preview @Composable -private fun EmptyTokensListPreview() { +private fun TokenScreenPreview( + @PreviewParameter(SwapSelectTokenScreenPreviewProvider::class) + state: SwapSelectTokenStateHolder, +) { TangemThemePreview { - EmptyTokensList() + SwapSelectTokenScreen( + state = state, + onBack = {}, + ) } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt new file mode 100644 index 0000000000..0e7fb5e9bb --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt @@ -0,0 +1,94 @@ +package com.tangem.feature.swap.ui.market + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.tangem.common.ui.markets.MarketsListItem +import com.tangem.common.ui.markets.MarketsListItemPlaceholder +import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.models.market.state.SwapMarketState +import com.tangem.feature.swap.presentation.R + +internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) { + item { + Text( + text = stringResourceSafe(R.string.markets_common_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.fillMaxWidth().padding(horizontal = TangemTheme.dimens.spacing16), + ) + } + when (state) { + is SwapMarketState.Loading -> { + items(count = 100, key = { "market_placeholder_$it" }) { + MarketsListItemPlaceholder() + } + } + is SwapMarketState.LoadingError -> { + item(key = "market_loading_error") { + LoadingErrorItem( + modifier = Modifier.fillParentMaxWidth(), + onTryAgain = state.onRetryClicked, + ) + } + } + SwapMarketState.SearchNothingFound -> { + item(key = "market_not_found") { + SearchNothingFoundText( + modifier = Modifier.fillParentMaxWidth(), + ) + } + } + is SwapMarketState.Content -> { + itemsIndexed( + items = state.items, + key = { _, item -> item.getComposeKey() }, + ) { index, item -> + MarketsListItem( + model = item, + onClick = { state.onItemClick(item) }, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + backgroundColor = TangemTheme.colors.background.action, + ), + ) + } + } + } +} + +@Composable +private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData(onRetryClick = onTryAgain) + } +} + +@Composable +private fun SearchNothingFoundText(modifier: Modifier = Modifier) { + Box( + modifier = modifier.padding(TangemTheme.dimens.spacing16), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResourceSafe(R.string.markets_search_token_no_result_title), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt new file mode 100644 index 0000000000..a0c8c9a1b1 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt @@ -0,0 +1,155 @@ +package com.tangem.feature.swap.ui.preview + +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.feature.swap.models.SwapSelectTokenStateHolder +import com.tangem.feature.swap.models.TokenBalanceData +import com.tangem.feature.swap.models.TokenListUMData +import com.tangem.feature.swap.models.TokenToSelectState +import com.tangem.feature.swap.models.market.state.SwapMarketState +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal class SwapSelectTokenPreviewProvider { + + fun provideSwapSelectTokenState(): SwapSelectTokenStateHolder { + return SwapSelectTokenStateHolder( + availableTokens = listOf(previewTitle, previewToken, previewToken, previewToken).toImmutableList(), + unavailableTokens = listOf(previewTitle, previewToken, previewToken, previewToken).toImmutableList(), + tokensListData = TokenListUMData.EmptyList, + isAfterSearch = false, + isBalanceHidden = false, + onSearchEntered = {}, + onTokenSelected = {}, + marketsState = createPreviewMarketsState(), + ) + } + + private fun createPreviewMarketsState() = SwapMarketState.Content( + items = createPreviewMarketItems(), + loadMore = { }, + onItemClick = { }, + visibleIdsChanged = { }, + ) + + private fun createPreviewMarketItems() = listOf( + createMarketItem( + id = "1", + iconUrl = "", + ratingPosition = "10", + marketCap = "$6.233 B", + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "2", + ratingPosition = "10", + marketCap = "$6.233 B", + trendType = PriceChangeType.NEUTRAL, + chartData = null, + ), + createMarketItem( + id = "3", + name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin", + ratingPosition = "10", + marketCap = "$6.23348172384781234 B", + trendType = PriceChangeType.DOWN, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "4", + ratingPosition = "10", + marketCap = null, + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "5", + ratingPosition = null, + marketCap = "$6.233 B", + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "6", + ratingPosition = null, + marketCap = null, + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + ).toImmutableList() + + private fun createMarketItem( + id: String, + name: String = "Bitcoin", + iconUrl: String? = null, + ratingPosition: String?, + marketCap: String?, + trendType: PriceChangeType, + chartData: MarketChartRawData?, + ) = MarketsListItemUM( + id = CryptoCurrency.RawID(id), + name = name, + currencySymbol = "BTC", + iconUrl = iconUrl, + ratingPosition = ratingPosition, + marketCap = marketCap, + price = MarketsListItemUM.Price(text = "31 285.72$"), + trendPercentText = "12.43%", + trendType = trendType, + chartData = chartData, + isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), + updateTimestamp = 0, + ) + + companion object { + private const val CHART_VALUE_1 = 0.4 + private const val CHART_VALUE_2 = 0.2 + private const val CHART_VALUE_3 = 0.1 + private const val CHART_VALUE_4 = 2.0 + private const val CHART_VALUE_5 = 5.0 + private const val CHART_VALUE_6 = 3.0 + + private val PREVIEW_CHART_DATA = MarketChartRawData( + y = persistentListOf( + CHART_VALUE_1, + CHART_VALUE_2, + CHART_VALUE_1, + CHART_VALUE_3, + CHART_VALUE_1, + CHART_VALUE_4, + CHART_VALUE_5, + CHART_VALUE_3, + CHART_VALUE_4, + CHART_VALUE_4, + CHART_VALUE_6, + ), + ) + + private val previewToken = TokenToSelectState.TokenToSelect( + tokenIcon = CurrencyIconState.CoinIcon( + url = "", + fallbackResId = 0, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + id = "", + name = "Optimistic Ethereum (ETH)", + symbol = "USDC", + addedTokenBalanceData = TokenBalanceData( + amount = "15 000 $", + amountEquivalent = "15 000 USDT", + isBalanceHidden = false, + ), + ) + + private val previewTitle = TokenToSelectState.Title( + title = stringReference("MY TOKENS"), + ) + } +} \ No newline at end of file From 65840babb151ff9577e3f84bbcb8d5bdf49801cc Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 15 Jan 2026 12:44:45 +0200 Subject: [PATCH 104/172] Updated on 2026-08-14 --- .../domain/staking/analytics/StakingAnalyticsEvent.kt | 5 +++++ .../impl/presentation/model/StakingClickIntents.kt | 2 ++ .../staking/impl/presentation/model/StakingModel.kt | 8 ++++++++ .../presentation/state/stub/StakingClickIntentsStub.kt | 2 ++ .../notifications/AddStakingNotificationsTransformer.kt | 5 ++++- 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt index e2950b602d..6784b226dd 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt @@ -167,6 +167,11 @@ sealed class StakingAnalyticsEvent( event = "Button - Activate", params = mapOf(AnalyticsParam.TOKEN_PARAM to token), ) + + data class NotEnoughFee(val token: String) : StakingAnalyticsEvent( + event = "Notice - Not Enough Fee", + params = mapOf(AnalyticsParam.TOKEN_PARAM to token), + ) } enum class StakeScreenSource { diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt index 7f0264f917..66b3ccedc6 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt @@ -75,6 +75,8 @@ internal interface StakingClickIntents : AmountScreenClickIntents { fun onActivateTonAccountNotificationShow() + fun onNotEnoughFeeNotificationShow() + fun onActivateTonAccountClick() fun onAmountReduceByFeeClick(reduceAmount: BigDecimal, notification: Class) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index 88e9a090ad..bac16c9edc 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -1120,6 +1120,14 @@ internal class StakingModel @Inject constructor( ) } + override fun onNotEnoughFeeNotificationShow() { + analyticsEventHandler.send( + StakingAnalyticsEvent.NotEnoughFee( + token = cryptoCurrencyStatus.currency.symbol, + ), + ) + } + override fun onActivateTonAccountClick() { analyticsEventHandler.send( StakingAnalyticsEvent.ButtonActivate( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt index 1584adb3a1..46ef8238ce 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt @@ -83,6 +83,8 @@ internal object StakingClickIntentsStub : StakingClickIntents { override fun onActivateTonAccountNotificationShow() {} + override fun onNotEnoughFeeNotificationShow() {} + override fun onActivateTonAccountClick() {} override fun onAmountReduceByFeeClick(reduceAmount: BigDecimal, notification: Class) {} 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 636649a4ad..6477acf20c 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 @@ -226,6 +226,7 @@ internal class AddStakingNotificationsTransformer( isSubtractionAvailable = isSubtractAvailable, cryptoCurrencyStatus = cryptoCurrencyStatus, onClick = prevState.clickIntents::openTokenDetails, + onNotEnoughFeeNotificationShow = prevState.clickIntents::onNotEnoughFeeNotificationShow, ) addTonExtraFeeErrorNotification() addExceedsBalanceNotification( @@ -233,7 +234,7 @@ internal class AddStakingNotificationsTransformer( cryptoCurrencyStatus = cryptoCurrencyStatus, shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(network.backendId), onClick = prevState.clickIntents::openTokenDetails, - onAnalyticsEvent = { /* no-op */ }, + onAnalyticsEvent = { prevState.clickIntents.onNotEnoughFeeNotificationShow() }, ) if (!BlockchainUtils.isCardano(network.rawId)) { addDustWarningNotification( @@ -298,12 +299,14 @@ internal class AddStakingNotificationsTransformer( isSubtractionAvailable: Boolean, cryptoCurrencyStatus: CryptoCurrencyStatus, onClick: (CryptoCurrency) -> Unit, + onNotEnoughFeeNotificationShow: () -> Unit, ) { val balance = cryptoCurrencyStatus.value.amount.orZero() if (!isSubtractionAvailable) return val showNotification = sendingAmount + feeAmount > balance if (showNotification) { + onNotEnoughFeeNotificationShow() val notification = if (actionType is StakingActionCommonType.Enter) { NotificationUM.Error.TotalExceedsBalance } else { From fef3d9fa5e29c6fae8d2b787c7f881a4cbff3aa8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 15 Jan 2026 13:22:08 +0100 Subject: [PATCH 105/172] Updated on 2026-08-14 --- .../feed/model/news/list/NewsListModel.kt | 18 +++++++--- .../list/analytics/NewsListAnalyticsEvent.kt | 35 +++++++++++++++++++ .../statemanager/NewsListBatchFlowManager.kt | 13 +++++-- .../list/statemanager/NewsListStateManager.kt | 33 ++++++++++++----- .../tangem/features/feed/ui/feed/FeedList.kt | 11 +++--- 5 files changed, 90 insertions(+), 20 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/analytics/NewsListAnalyticsEvent.kt diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt index 1acfe71d32..9e6153728b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt @@ -1,5 +1,6 @@ package com.tangem.features.feed.model.news.list +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -8,6 +9,7 @@ import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase import com.tangem.features.feed.components.news.list.DefaultNewsListComponent +import com.tangem.features.feed.model.news.list.analytics.NewsListAnalyticsEvent import com.tangem.features.feed.model.news.list.statemanager.NewsListStateManager import com.tangem.features.feed.model.news.list.loader.NewsCategoriesLoader import com.tangem.features.feed.model.news.list.statemanager.NewsListBatchFlowManager @@ -31,6 +33,7 @@ internal class NewsListModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val getNewsCategoriesUseCase: GetNewsCategoriesUseCase, private val getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, paramsContainer: ParamsContainer, ) : Model() { @@ -58,6 +61,10 @@ internal class NewsListModel @Inject constructor( ) } + private val newsListStateManager by lazy(LazyThreadSafetyMode.NONE) { + NewsListStateManager(analyticsEventHandler) + } + private val _state = MutableStateFlow( NewsListUM( selectedCategoryId = DEFAULT_ALL_NEWS_CATEGORIES_ID, @@ -95,12 +102,12 @@ internal class NewsListModel @Inject constructor( modelScope.launch(dispatchers.default) { combine( batchFlowManager.uiItems, - batchFlowManager.isInInitialLoadingErrorState, + batchFlowManager.initialLoadingError, batchFlowManager.paginationStatus, - ) { articles, isError, paginationStatus -> - NewsListStateManager.calculateState( + ) { articles, loadingError, paginationStatus -> + newsListStateManager.calculateState( articles = articles, - isError = isError, + error = loadingError, paginationStatus = paginationStatus, onRetryClick = { loadCategories() @@ -125,6 +132,9 @@ internal class NewsListModel @Inject constructor( } else { categoryId } + if (newCategoryId != DEFAULT_ALL_NEWS_CATEGORIES_ID) { + analyticsEventHandler.send(NewsListAnalyticsEvent.NewsCategoriesClick(newCategoryId)) + } selectedCategoryId.value = newCategoryId _state.update { currentState -> currentState.copy( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/analytics/NewsListAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/analytics/NewsListAnalyticsEvent.kt new file mode 100644 index 0000000000..efb0ae3ea6 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/analytics/NewsListAnalyticsEvent.kt @@ -0,0 +1,35 @@ +package com.tangem.features.feed.model.news.list.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE + +internal sealed class NewsListAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Markets", event = event, params = params) { + + data class NewsListLoadError( + private val code: Int?, + private val message: String, + ) : NewsListAnalyticsEvent( + event = "News List Load Error", + params = mapOf( + ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(), + ERROR_MESSAGE to message, + ), + ) + + data class NewsCategoriesClick( + private val categoryId: Int, + ) : NewsListAnalyticsEvent( + event = "News Categories Selected", + params = mapOf( + "Selected Categories" to categoryId.toString(), + ), + ) + + private companion object { + const val IS_NOT_HTTP_ERROR = "Is not http error" + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt index 86687df7a8..fa5cff5b5e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt @@ -69,13 +69,20 @@ internal open class NewsListBatchFlowManager( initialValue = persistentListOf(), ) - val isInInitialLoadingErrorState = batchFlow.state - .map { it.status is PaginationStatus.InitialLoadingError } + val initialLoadingError: StateFlow = batchFlow.state + .map { state -> + val status = state.status + if (status is PaginationStatus.InitialLoadingError) { + status.throwable + } else { + null + } + } .distinctUntilChanged() .stateIn( scope = modelScope, started = SharingStarted.Eagerly, - initialValue = false, + initialValue = null, ) val paginationStatus: StateFlow>> = batchFlow.state diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListStateManager.kt index c4e30a7ff1..e16a20e9f2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListStateManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListStateManager.kt @@ -1,22 +1,23 @@ package com.tangem.features.feed.model.news.list.statemanager import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.features.feed.model.news.list.analytics.NewsListAnalyticsEvent import com.tangem.features.feed.ui.news.list.state.NewsListState import com.tangem.pagination.PaginationStatus import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -/** - * A pure function for calculating the state of the news list. - * Extracted from NewsListModel to simplify testing and improve readability. - */ -internal object NewsListStateManager { +internal class NewsListStateManager( + private val analyticsEventHandler: AnalyticsEventHandler, +) { /** * Calculates the state of the news list based on the current data. * * @param articles the current list of articles - * @param isError flag indicating an initial loading error + * @param error flag indicating an initial loading error * @param paginationStatus the current pagination status * @param onRetryClick handler for the retry loading action * @param onLoadMore handler for loading the next page @@ -24,13 +25,16 @@ internal object NewsListStateManager { */ fun calculateState( articles: ImmutableList, - isError: Boolean, + error: Throwable?, paginationStatus: PaginationStatus<*>, onRetryClick: () -> Unit, onLoadMore: () -> Unit, ): Pair> { + if (error != null) { + sendErrorAnalytics(error) + } return when { - isError -> NewsListState.LoadingError(onRetryClick) to persistentListOf() + error != null -> NewsListState.LoadingError(onRetryClick) to persistentListOf() paginationStatus is PaginationStatus.InitialLoading && articles.isEmpty() -> { NewsListState.Loading to persistentListOf() } @@ -38,4 +42,17 @@ internal object NewsListStateManager { else -> NewsListState.Content(loadMore = onLoadMore) to articles } } + + private fun sendErrorAnalytics(throwable: Throwable) { + val (code, message) = when (throwable) { + is ApiResponseError.HttpException -> throwable.code.numericCode to throwable.message + else -> null to "" + } + analyticsEventHandler.send( + NewsListAnalyticsEvent.NewsListLoadError( + code = code, + message = message.orEmpty(), + ), + ) + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index f25acdab2d..4a5825783f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -354,12 +355,12 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, horizontalArrangement = Arrangement.spacedBy(12.dp), state = listState, ) { - items( + itemsIndexed( items = news.content, - key = ArticleConfigUM::id, - contentType = { "article" }, - ) { article -> - val articleModifier = if (news.content.indexOf(article) == FOURTH_ITEM_INDEX) { + key = { _, article -> article.id }, + contentType = { _, _ -> "article" }, + ) { index, article -> + val articleModifier = if (index == FOURTH_ITEM_INDEX) { Modifier.onFirstVisible( minFractionVisible = 0.5f, callback = feedListCallbacks.onSliderScroll, From 9632ea76725018bf1ef2edd26bc7f98c764eaa9c Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 15 Jan 2026 13:23:01 +0100 Subject: [PATCH 106/172] Updated on 2026-08-14 --- .../feature/wallet/presentation/wallet/ui/WalletScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index ce1ed559fc..22f5a87af7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -492,7 +492,7 @@ private inline fun BaseScaffoldWithMarkets( color = if (state.showMarketsOnboarding) { Color.Black.copy(alpha = .65f) } else { - BottomSheetDefaults.ScrimColor + Color.Black.copy(alpha = .40f) }, visible = bottomSheetState.targetValue == TangemSheetValue.Expanded || state.showMarketsOnboarding, From fa0c415aefae4d9966a80b17c66d286f88bf36e6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 15 Jan 2026 17:24:02 +0500 Subject: [PATCH 107/172] Updated on 2026-08-14 --- .../core/ui/ds/checkbox/TangemCheckbox.kt | 127 ++++++++++++++++++ .../main/res/drawable/ic_check_default_24.xml | 12 ++ 2 files changed, 139 insertions(+) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/checkbox/TangemCheckbox.kt create mode 100644 core/ui/src/main/res/drawable/ic_check_default_24.xml diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/checkbox/TangemCheckbox.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/checkbox/TangemCheckbox.kt new file mode 100644 index 0000000000..9abdda6653 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/checkbox/TangemCheckbox.kt @@ -0,0 +1,127 @@ +package com.tangem.core.ui.ds.checkbox + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.R +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * Tangem checkbox component. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8437-82586&m=dev) + * + * @param isChecked Boolean value representing the current state of the checkbox. + * @param onCheckedChange Lambda function to be invoked when the checkbox state changes. + * @param modifier Modifier to be applied to the badge. + * @param isRounded Boolean value to determine if the checkbox has rounded corners or is circular. + * @param isEnabled Boolean value to determine if the checkbox is enabled or disabled. + */ +@Composable +fun TangemCheckbox( + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + isRounded: Boolean = true, + isEnabled: Boolean = true, +) { + val shape = if (isRounded) { + RoundedCornerShape(TangemTheme.dimens2.x1) + } else { + CircleShape + } + Box( + modifier = modifier + .size(TangemTheme.dimens2.x5) + .then( + if (isChecked) { + Modifier.background( + color = TangemTheme.colors2.border.status.accent, + shape = shape, + ) + } else { + Modifier.border( + color = TangemTheme.colors2.border.neutral.secondary, + shape = shape, + width = 1.dp, + ) + }, + ) + .clip(shape = shape) + .toggleable( + value = isChecked, + onValueChange = onCheckedChange, + enabled = isEnabled, + role = Role.Checkbox, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + ), + ) { + if (isChecked) { + Icon( + painter = rememberVectorPainter(ImageVector.vectorResource(R.drawable.ic_check_default_24)), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + modifier = Modifier + .align(Alignment.Center) + .size(TangemTheme.dimens2.x4), + ) + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemCheckbox_Preview() { + val previewData = mutableListOf( + TangemCheckboxPreviewData(isChecked = false, isRounded = true), + TangemCheckboxPreviewData(isChecked = false, isRounded = false), + TangemCheckboxPreviewData(isChecked = true, isRounded = true), + TangemCheckboxPreviewData(isChecked = true, isRounded = false), + ) + TangemThemePreviewRedesign { + Row( + modifier = Modifier.background(TangemTheme.colors2.surface.level1), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x6), + ) { + previewData.fastForEach { data -> + TangemCheckbox( + isChecked = data.isChecked, + isRounded = data.isRounded, + onCheckedChange = { }, + modifier = Modifier, + ) + } + } + } +} + +private data class TangemCheckboxPreviewData( + val isChecked: Boolean, + val isRounded: Boolean, +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_check_default_24.xml b/core/ui/src/main/res/drawable/ic_check_default_24.xml new file mode 100644 index 0000000000..4177ae9043 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_check_default_24.xml @@ -0,0 +1,12 @@ + + + From 3ee2426a9c6088bca675b7af4ecf0c9f310d2ea8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 15 Jan 2026 17:24:36 +0500 Subject: [PATCH 108/172] Updated on 2026-08-14 --- .../core/ui/ds/tabs/TangemSegmentedPicker.kt | 271 ++++++++++++++++++ .../ui/ds/tabs/TangemSegmentedPickerUM.kt | 32 +++ .../com/tangem/core/ui/ds/tabs/TangemTab.kt | 105 +++++++ .../com/tangem/core/ui/res/TangemColors2.kt | 39 +++ .../tangem/core/ui/res/TangemThemeRedesign.kt | 20 ++ 5 files changed, 467 insertions(+) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPickerUM.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt new file mode 100644 index 0000000000..fc0a59340b --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt @@ -0,0 +1,271 @@ +package com.tangem.core.ui.ds.tabs + +import android.content.res.Configuration +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +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.IntOffset +import androidx.compose.ui.unit.coerceAtLeast +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.conditional +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.utils.extensions.indexOfFirstOrNull +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +/** + * Tangem segmented picker component. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8452-16487&m=dev) + * + * @param tangemSegmentedPickerUM Model representing the segmented picker. + * @param modifier Modifier to be applied to the segmented picker. + * @param onClick Lambda function to be invoked when a segment is clicked. + */ +@Composable +fun TangemSegmentedPicker( + tangemSegmentedPickerUM: TangemSegmentedPickerUM, + modifier: Modifier = Modifier, + onClick: (TangemSegmentUM) -> Unit, +) { + TangemSegmentedPicker( + items = tangemSegmentedPickerUM.items, + modifier = modifier, + initialSelectedItem = tangemSegmentedPickerUM.initialSelectedItem, + hasSeparator = tangemSegmentedPickerUM.hasSeparator, + isFixed = tangemSegmentedPickerUM.isFixed, + isAltSurface = tangemSegmentedPickerUM.isAltSurface, + onClick = onClick, + ) +} + +/** + * Tangem segmented picker component. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8452-16487&m=dev) + * + * @param items List of TangemSegmentUM representing the segments in the picker. + * @param modifier Modifier to be applied to the segmented picker. + * @param initialSelectedItem Optional TangemSegmentUM representing the initially selected segment. + * @param hasSeparator Boolean indicating whether there is a separator between segments. + * @param isFixed Boolean indicating whether the picker has a fixed width. + * @param isAltSurface Boolean indicating whether to use an alternative surface style. + * @param onClick Lambda function to be invoked when a segment is clicked. + */ +@Composable +fun TangemSegmentedPicker( + items: ImmutableList, + modifier: Modifier = Modifier, + initialSelectedItem: TangemSegmentUM? = null, + hasSeparator: Boolean = false, + isFixed: Boolean = false, + isAltSurface: Boolean = false, + onClick: (TangemSegmentUM) -> Unit, +) { + if (items.isEmpty() || items.size == 1) return + + val density = LocalDensity.current + + val itemsWidths = remember { mutableStateListOf(*Array(items.size) { 0.dp }) } + val selectedIndex = remember { mutableStateOf(items.indexOfFirstOrNull { it == initialSelectedItem } ?: 0) } + val segmentHeight = remember { mutableStateOf(0.dp) } + + val shape = RoundedCornerShape(TangemTheme.dimens2.x25) + val spacing = if (hasSeparator) { + TangemTheme.dimens2.x4 + } else { + TangemTheme.dimens2.x1 + } + + Box( + modifier = modifier + .clip(shape) + .background( + color = if (isAltSurface) { + TangemTheme.colors2.tabs.backgroundQuaternary + } else { + TangemTheme.colors2.tabs.backgroundSecondary + }, + ) + .padding(TangemTheme.dimens2.x0_5), + ) { + SegmentSelection( + itemsWidths = itemsWidths, + selectedIndex = selectedIndex.value, + segmentHeight = segmentHeight.value, + spacing = spacing, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing), + ) { + items.fastForEachIndexed { index, item -> + Segment( + item = item, + index = index, + isFixed = isFixed, + selectedIndex = selectedIndex, + onClick = { onClick(item) }, + modifier = Modifier + .onGloballyPositioned { + with(density) { + itemsWidths[index] = it.size.width.toDp() + segmentHeight.value = it.size.height.toDp().coerceAtLeast(segmentHeight.value) + } + }, + ) + } + } + } +} + +@Composable +private fun SegmentSelection(itemsWidths: SnapshotStateList, selectedIndex: Int, segmentHeight: Dp, spacing: Dp) { + val indicatorOffset by animateDpAsState( + targetValue = itemsWidths.take(selectedIndex).fold(0.dp, Dp::plus) + spacing * selectedIndex, + animationSpec = tween(durationMillis = 300), + label = "indicatorOffset", + ) + + val indicatorWidth by animateDpAsState( + targetValue = itemsWidths[selectedIndex], + animationSpec = tween(durationMillis = 300), + label = "indicatorWidth", + ) + + Box( + modifier = Modifier + .offset { + IntOffset(x = indicatorOffset.roundToPx(), y = 0) + } + .size(width = indicatorWidth, height = segmentHeight) + .shadow( + elevation = TangemTheme.dimens2.x1, + shape = RoundedCornerShape(TangemTheme.dimens2.x25), + ) + .background( + color = TangemTheme.colors2.tabs.backgroundTertiary, + ), + ) +} + +@Composable +private fun RowScope.Segment( + item: TangemSegmentUM, + index: Int, + isFixed: Boolean, + selectedIndex: MutableState, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .conditional(isFixed) { + weight(1f) + } + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + selectedIndex.value = index + onClick() + }, + ) { + Text( + text = item.title.resolveReference(), + style = TangemTheme.typography2.bodySemibold15, + color = if (selectedIndex.value == index) { + TangemTheme.colors2.tabs.textTertiary + } else { + TangemTheme.colors2.tabs.textSecondary + }, + maxLines = 1, + modifier = Modifier + .align(Alignment.Center) + .padding( + horizontal = TangemTheme.dimens2.x3, + vertical = TangemTheme.dimens2.x2, + ), + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemSegmentedPicker_Preview(@PreviewParameter(PreviewProvider::class) params: TangemSegmentedPickerUM) { + var selectedItem by remember { mutableStateOf(params.items.first()) } + TangemThemePreviewRedesign { + Box( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level1) + .padding(TangemTheme.dimens2.x4), + ) { + TangemSegmentedPicker( + isFixed = params.isFixed, + hasSeparator = params.hasSeparator, + isAltSurface = params.isAltSurface, + items = params.items, + initialSelectedItem = params.items.last(), + onClick = { selectedItem = it }, + ) + } + } +} + +private class PreviewProvider : PreviewParameterProvider { + private val items = persistentListOf( + TangemSegmentUM(id = "1", title = TextReference.Str("Segment")), + TangemSegmentUM(id = "2", title = TextReference.Str("Segment Two")), + TangemSegmentUM(id = "3", title = TextReference.Str("Seg 3")), + ) + override val values: Sequence + get() = sequenceOf( + TangemSegmentedPickerUM( + items = items, + hasSeparator = false, + isFixed = false, + isAltSurface = false, + ), + TangemSegmentedPickerUM( + items = items, + hasSeparator = true, + isFixed = false, + isAltSurface = true, + ), + TangemSegmentedPickerUM( + items = items, + hasSeparator = false, + isFixed = true, + isAltSurface = false, + ), + TangemSegmentedPickerUM( + items = items, + hasSeparator = true, + isFixed = true, + isAltSurface = true, + ), + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPickerUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPickerUM.kt new file mode 100644 index 0000000000..4bc47ab4df --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPickerUM.kt @@ -0,0 +1,32 @@ +package com.tangem.core.ui.ds.tabs + +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +/** + * Model for Tangem segmented picker component. + * + * @param items List of TangemSegmentUM representing the segments in the picker. + * @param initialSelectedItem Optional TangemSegmentUM representing the initially selected segment. + * @param hasSeparator Boolean indicating whether there is a separator between segments. + * @param isFixed Boolean indicating whether the picker has a fixed width. + * @param isAltSurface Boolean indicating whether to use an alternative surface style. + */ +data class TangemSegmentedPickerUM( + val items: ImmutableList, + val initialSelectedItem: TangemSegmentUM? = null, + val hasSeparator: Boolean = false, + val isFixed: Boolean = false, + val isAltSurface: Boolean = false, +) + +/** + * Model for a single segment in the Tangem segmented picker. + * + * @param id String representing the unique identifier of the segment. + * @param title TextReference representing the title of the segment. + */ +data class TangemSegmentUM( + val id: String, + val title: TextReference, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt new file mode 100644 index 0000000000..67e1989fae --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt @@ -0,0 +1,105 @@ +package com.tangem.core.ui.ds.tabs + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * Tangem tab component. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8448-74386&m=dev) + * + * @param text TextReference representing the text to be displayed on the tab. + * @param isChecked Boolean value representing the current state of the tab. + * @param onCheckedChange Lambda function to be invoked when the tab state changes. + * @param modifier Modifier to be applied to the tab. + * @param isEnabled Boolean value to determine if the tab is enabled or disabled. + */ +@Composable +fun TangemTab( + text: TextReference, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + val shape = RoundedCornerShape(TangemTheme.dimens2.x25) + val backgroundColor = if (isChecked) { + TangemTheme.colors2.tabs.backgroundPrimary + } else { + TangemTheme.colors2.tabs.textPrimary + } + val textColor = if (isChecked) { + TangemTheme.colors2.tabs.textPrimary + } else { + TangemTheme.colors2.tabs.textSecondary + } + Box( + modifier = modifier + .background( + color = backgroundColor, + shape = shape, + ) + .clip(shape = shape) + .padding(all = TangemTheme.dimens2.x3) + .toggleable( + value = isChecked, + onValueChange = onCheckedChange, + enabled = isEnabled, + role = Role.Checkbox, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + ), + ) { + Text( + text = text.resolveReference(), + style = TangemTheme.typography2.bodySemibold16, + color = textColor, + maxLines = 1, + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemTab_Preview() { + TangemThemePreviewRedesign { + Column( + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x6), + modifier = Modifier.background(TangemTheme.colors2.surface.level1), + ) { + TangemTab( + text = stringReference("Tab Title"), + isChecked = true, + onCheckedChange = {}, + ) + TangemTab( + text = stringReference("Tab Title"), + isChecked = false, + onCheckedChange = {}, + ) + } + } +} + +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt index 7ba4a56c58..c3d2a23e2a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt @@ -1,4 +1,5 @@ @file:Suppress("LongParameterList") + package com.tangem.core.ui.res import androidx.compose.runtime.Stable @@ -20,6 +21,7 @@ class TangemColors2 internal constructor( val fill: Fill, val skeleton: Skeleton, val markers: Markers, + val tabs: Tabs, ) { @Stable @@ -524,6 +526,42 @@ class TangemColors2 internal constructor( } } + @Stable + class Tabs internal constructor( + textPrimary: Color, + textSecondary: Color, + textTertiary: Color, + backgroundPrimary: Color, + backgroundSecondary: Color, + backgroundTertiary: Color, + backgroundQuaternary: Color, + ) { + var textPrimary by mutableStateOf(textPrimary) + private set + var textSecondary by mutableStateOf(textSecondary) + private set + var textTertiary by mutableStateOf(textTertiary) + private set + var backgroundPrimary by mutableStateOf(backgroundPrimary) + private set + var backgroundSecondary by mutableStateOf(backgroundSecondary) + private set + var backgroundTertiary by mutableStateOf(backgroundTertiary) + private set + var backgroundQuaternary by mutableStateOf(backgroundQuaternary) + private set + + fun update(other: Tabs) { + textPrimary = other.textPrimary + textSecondary = other.textSecondary + textTertiary = other.textTertiary + backgroundPrimary = other.backgroundPrimary + backgroundSecondary = other.backgroundSecondary + backgroundTertiary = other.backgroundTertiary + backgroundQuaternary = other.backgroundQuaternary + } + } + fun update(other: TangemColors2) { text.update(other.text) graphic.update(other.graphic) @@ -536,5 +574,6 @@ class TangemColors2 internal constructor( fill.update(other.fill) skeleton.update(other.skeleton) markers.update(other.markers) + tabs.update(other.tabs) } } \ No newline at end of file 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 ce425e93f7..8862de2500 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 @@ -156,6 +156,15 @@ private fun lightThemeColors2(): TangemColors2 { borderTintedBlue = TangemColorPalette.Azure.copy(alpha = 0.1f), borderTintedRed = TangemColorPalette.Amaranth.copy(alpha = 0.1f), ) + val tabs = TangemColors2.Tabs( + textPrimary = TangemColorPalette.Light2, + textSecondary = TangemColorPalette.Dark4, + textTertiary = TangemColorPalette.Black, + backgroundPrimary = TangemColorPalette.Dark6, + backgroundSecondary = TangemColorPalette.Dark_10, + backgroundTertiary = TangemColorPalette.White, + backgroundQuaternary = TangemColorPalette.Dark_20, + ) return TangemColors2( text = text, graphic = graphic, @@ -168,6 +177,7 @@ private fun lightThemeColors2(): TangemColors2 { field = field, skeleton = skeleton, markers = markers, + tabs = tabs, ) } @@ -296,6 +306,15 @@ private fun darkThemeColors2(): TangemColors2 { borderTintedBlue = TangemColorPalette.Azure.copy(alpha = 0.1f), borderTintedRed = TangemColorPalette.Amaranth.copy(alpha = 0.1f), ) + val tabs = TangemColors2.Tabs( + textPrimary = TangemColorPalette.Dark4, + textSecondary = TangemColorPalette.Light5, + textTertiary = TangemColorPalette.White, + backgroundPrimary = TangemColorPalette.Light1, + backgroundSecondary = TangemColorPalette.Light_10, + backgroundTertiary = TangemColorPalette.Light_10, + backgroundQuaternary = TangemColorPalette.Light_10, + ) return TangemColors2( text = text, graphic = graphic, @@ -308,5 +327,6 @@ private fun darkThemeColors2(): TangemColors2 { field = field, skeleton = skeleton, markers = markers, + tabs = tabs, ) } \ No newline at end of file From 22a32ec8f132387b42619d57a3642458dd0d88ea Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 15 Jan 2026 15:08:33 +0100 Subject: [PATCH 109/172] Updated on 2026-08-14 --- .../data/news/repository/DefaultNewsRepository.kt | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt index 9fbf09d3f3..9a60bf6729 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -2,6 +2,7 @@ package com.tangem.data.news.repository import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.fold import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.api.news.models.response.NewsTrendingResponse @@ -55,16 +56,24 @@ internal class DefaultNewsRepository( override fun getNews(config: NewsListConfig, limit: Int): Flow> { return flow { - val response = newsApi.getNews( + val items = newsApi.getNews( page = FIRST_PAGE, limit = limit, language = config.language, snapshot = config.snapshot, tokenIds = config.tokenIds.takeIf { it.isNotEmpty() }, categoryIds = config.categoryIds.takeIf { it.isNotEmpty() }, - ).getOrThrow() + ).fold( + onSuccess = { response -> + response.items + }, + onError = { error -> + Timber.e(error, "Failed to get list of news") + emptyList() + }, + ) - val shortArticles = response.items.map { it.toDomainShortArticle() } + val shortArticles = items.map { it.toDomainShortArticle() } emit(shortArticles) } .flowOn(dispatchers.io) From 99cdc003c88561f6f7de953f89dbfac00a7ff721 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 15 Jan 2026 20:15:37 +0300 Subject: [PATCH 110/172] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- .../DefaultGaslessTransactionRepository.kt | 3 - .../IsAmountSubtractAvailableUseCase.kt | 11 +- .../domain/transaction/error/GetFeeError.kt | 1 + .../gasless/EstimateFeeForGaslessTxUseCase.kt | 2 +- .../gasless/EstimateFeeForTokenUseCase.kt | 111 ++++++++++-------- .../gasless/GetAvailableFeeTokensUseCase.kt | 2 +- .../gasless/GetFeeForGaslessUseCase.kt | 2 +- .../usecase/gasless/GetFeeForTokenUseCase.kt | 87 ++++++++------ .../usecase/gasless/TokenFeeCalculator.kt | 12 +- .../usecase/gasless/TokenFeeCalculatorTest.kt | 8 +- .../v2/send/confirm/model/SendConfirmModel.kt | 16 +-- .../notifications/model/NotificationsModel.kt | 4 +- .../impl/presentation/model/StakingModel.kt | 6 +- .../confirm/model/SendWithSwapConfirmModel.kt | 8 +- gradle/tangem_dependencies.toml | 2 +- 16 files changed, 156 insertions(+), 121 deletions(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 5ba0959d72..098662beb5 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 5ba0959d72f41e49a22f792e9a3d53e5e24e7713 +Subproject commit 098662beb5b0123b11f5ee4873d4bd667ac93c73 diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt index 9587374de8..3e75ce78f5 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -116,9 +116,6 @@ class DefaultGaslessTransactionRepository( } private companion object { - - const val TOKEN_RECEIVER_ADDRESS = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" - val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000") } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt index bdbc0507df..2ff85923ae 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt @@ -1,6 +1,7 @@ package com.tangem.domain.tokens import arrow.core.Either +import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.FeePaidCurrency @@ -16,9 +17,15 @@ class IsAmountSubtractAvailableUseCase( suspend operator fun invoke( userWalletId: UserWalletId, currency: CryptoCurrency, - isGaslessEthTx: Boolean = false, + maybeGaslessFee: Pair? = null, ): Either = Either.catch { - if (isGaslessEthTx) return@catch true + val maybeTokenCurrency = currency as? CryptoCurrency.Token + if (maybeGaslessFee != null && + maybeGaslessFee.second is Fee.Ethereum.TokenCurrency && + maybeGaslessFee.first.contractAddress.equals(maybeTokenCurrency?.contractAddress, true) + ) { + return@catch true + } when (val feeCurrency = currenciesRepository.getFeePaidCurrency(userWalletId, currency.network)) { is FeePaidCurrency.Coin -> currency is CryptoCurrency.Coin is FeePaidCurrency.SameCurrency -> true diff --git a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt index 6bfb7e06d4..1e8b733d5c 100644 --- a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt +++ b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt @@ -14,5 +14,6 @@ sealed class GetFeeError { data object NetworkIsNotSupported : GaslessError() data object NoSupportedTokensFound : GaslessError() data object NotEnoughFunds : GaslessError() + data class DataError(val cause: Throwable?) : GaslessError() } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt index 53856d8603..3d375e367d 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt @@ -89,7 +89,7 @@ class EstimateFeeForGaslessTxUseCase( ) }, catch = { - raise(GetFeeError.DataError(it)) + raise(GaslessError.DataError(it)) }, ) } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt index d41eb24d0c..a3165f0349 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt @@ -2,6 +2,7 @@ package com.tangem.domain.transaction.usecase.gasless import arrow.core.Either import arrow.core.raise.Raise +import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.common.transaction.Fee @@ -17,6 +18,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.GetFeeError.GaslessError import com.tangem.domain.transaction.error.mapToFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.raiseIllegalStateError @@ -45,62 +47,69 @@ class EstimateFeeForTokenUseCase( amount: BigDecimal, ): Either { return either { - val token = tokenCurrencyStatus.currency - if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) { - raise(GetFeeError.GaslessError.NetworkIsNotSupported) - } + catch( + block = { + val token = tokenCurrencyStatus.currency + if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) { + raise(GetFeeError.GaslessError.NetworkIsNotSupported) + } - val amountData = amount.convertToSdkAmount(tokenCurrencyStatus) - val result = if (userWallet is UserWallet.Cold && - demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) - ) { - demoTransactionSender(userWallet, token).estimateFee( - amount = amountData, - destination = "", - ) - } else { - walletManagersFacade.estimateFee( - amount = amountData, - userWalletId = userWallet.walletId, - network = token.network, - ) - } + val amountData = amount.convertToSdkAmount(tokenCurrencyStatus) + val result = if (userWallet is UserWallet.Cold && + demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) + ) { + demoTransactionSender(userWallet, token).estimateFee( + amount = amountData, + destination = "", + ) + } else { + walletManagersFacade.estimateFee( + amount = amountData, + userWalletId = userWallet.walletId, + network = token.network, + ) + } - val initialTxFee = when (result) { - is Result.Success -> result.data - is Result.Failure -> raise(result.mapToFeeError()) - null -> raise(GetFeeError.UnknownError) - } + val initialTxFee = when (result) { + is Result.Success -> result.data + is Result.Failure -> raise(result.mapToFeeError()) + null -> raise(GetFeeError.UnknownError) + } - val initialFeeEth = initialTxFee.normal as? Fee.Ethereum - ?: raiseIllegalStateError( - error = "only Fee.Ethereum supported, but was different", - ) + val initialFeeEth = initialTxFee.normal as? Fee.Ethereum + ?: raiseIllegalStateError( + error = "only Fee.Ethereum supported, but was different", + ) - val nativeCurrency = currenciesRepository.getNetworkCoin( - userWalletId = userWallet.walletId, - networkId = token.network.id, - derivationPath = token.network.derivationPath, + val nativeCurrency = currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = token.network.id, + derivationPath = token.network.derivationPath, + ) + + val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + ).getOrNull()?.filter { + it.currency.network.id == token.network.id + } ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + + val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find { + it.currency.id == nativeCurrency.id + } ?: raiseIllegalStateError("native currency not found for network ${token.network.id}") + + val walletManager = prepareWalletManager(userWallet, token.network) + + tokenFeeCalculator.calculateTokenFee( + walletManager = walletManager, + tokenForPayFeeStatus = tokenCurrencyStatus, + nativeCurrencyStatus = nativeCurrencyStatus, + initialFee = initialFeeEth, + ).bind() + }, + catch = { + raise(GaslessError.DataError(it)) + }, ) - - val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( - userWallet.walletId, - ).getOrNull()?.filter { - it.currency.network.id == token.network.id - } ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") - - val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find { - it.currency.id == nativeCurrency.id - } ?: raiseIllegalStateError("native currency not found for network ${token.network.id}") - - val walletManager = prepareWalletManager(userWallet, token.network) - - tokenFeeCalculator.calculateTokenFee( - walletManager = walletManager, - tokenForPayFeeStatus = tokenCurrencyStatus, - nativeCurrencyStatus = nativeCurrencyStatus, - initialFee = initialFeeEth, - ).bind() } } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt index cf57b14f21..96435ad705 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -53,7 +53,7 @@ class GetAvailableFeeTokensUseCase( } }, catch = { - raise(GetFeeError.DataError(it)) + raise(GetFeeError.GaslessError.DataError(it)) }, ) } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt index 9beebc0a2f..1bb6b4074c 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt @@ -86,7 +86,7 @@ class GetFeeForGaslessUseCase( ) }, catch = { - raise(GetFeeError.DataError(it)) + raise(GaslessError.DataError(it)) }, ) } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt index 4e4efd2880..f20c162b52 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt @@ -2,6 +2,7 @@ package com.tangem.domain.transaction.usecase.gasless import arrow.core.Either import arrow.core.raise.Raise +import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.common.TransactionData @@ -15,6 +16,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.GetFeeError.GaslessError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.raiseIllegalStateError import com.tangem.domain.walletmanager.WalletManagersFacade @@ -40,50 +42,57 @@ class GetFeeForTokenUseCase( transactionData: TransactionData, ): Either { return either { - if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) { - raise(GetFeeError.GaslessError.NetworkIsNotSupported) - } + catch( + block = { + if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) { + raise(GaslessError.NetworkIsNotSupported) + } - val walletManager = prepareWalletManager(userWallet, token.network) + val walletManager = prepareWalletManager(userWallet, token.network) - val initialTxFee = tokenFeeCalculator.calculateInitialFee( - userWallet = userWallet, - network = token.network, - walletManager = walletManager, - transactionData = transactionData, - ).bind() + val initialTxFee = tokenFeeCalculator.calculateInitialFee( + userWallet = userWallet, + network = token.network, + walletManager = walletManager, + transactionData = transactionData, + ).bind() - val initialFeeEth = initialTxFee.normal as? Fee.Ethereum - ?: raiseIllegalStateError( - error = "only Fee.Ethereum supported, but was different", - ) + val initialFeeEth = initialTxFee.normal as? Fee.Ethereum + ?: raiseIllegalStateError( + error = "only Fee.Ethereum supported, but was different", + ) - val nativeCurrency = currenciesRepository.getNetworkCoin( - userWalletId = userWallet.walletId, - networkId = token.network.id, - derivationPath = token.network.derivationPath, + val nativeCurrency = currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = token.network.id, + derivationPath = token.network.derivationPath, + ) + + val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + ).getOrNull()?.filter { + it.currency.network.id == token.network.id + } ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + + val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find { + it.currency.id == nativeCurrency.id + } ?: raiseIllegalStateError("native currency not found for network ${token.network.id}") + + val tokenCurrencyStatus = userCurrenciesStatusesByNetwork.find { + it.currency.id == token.id + } ?: raiseIllegalStateError("token currency not found for network ${token.network.id}") + + tokenFeeCalculator.calculateTokenFee( + walletManager = walletManager, + tokenForPayFeeStatus = tokenCurrencyStatus, + nativeCurrencyStatus = nativeCurrencyStatus, + initialFee = initialFeeEth, + ).bind() + }, + catch = { + raise(GaslessError.DataError(it)) + }, ) - - val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( - userWallet.walletId, - ).getOrNull()?.filter { - it.currency.network.id == token.network.id - } ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") - - val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find { - it.currency.id == nativeCurrency.id - } ?: raiseIllegalStateError("native currency not found for network ${token.network.id}") - - val tokenCurrencyStatus = userCurrenciesStatusesByNetwork.find { - it.currency.id == token.id - } ?: raiseIllegalStateError("token currency not found for network ${token.network.id}") - - tokenFeeCalculator.calculateTokenFee( - walletManager = walletManager, - tokenForPayFeeStatus = tokenCurrencyStatus, - nativeCurrencyStatus = nativeCurrencyStatus, - initialFee = initialFeeEth, - ).bind() } } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt index 8934f4fb11..c7d9a57ea7 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt @@ -26,6 +26,7 @@ import com.tangem.domain.transaction.raiseIllegalStateError import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.walletmanager.WalletManagersFacade import java.math.BigDecimal +import java.math.BigInteger import java.math.RoundingMode internal class TokenFeeCalculator( @@ -51,7 +52,7 @@ internal class TokenFeeCalculator( val maybeFee = when (val result = transactionSender.getFee(transactionData = transactionData)) { is Result.Success -> result.data - is Result.Failure -> raise(result.mapToFeeError()) + is Result.Failure -> raise(GaslessError.DataError(result.error)) } maybeFee } @@ -115,7 +116,7 @@ internal class TokenFeeCalculator( val feeTransferGasLimit = when (feeTransferGasLimitResult) { is Result.Failure -> raise(GetFeeError.DataError(feeTransferGasLimitResult.error)) is Result.Success -> feeTransferGasLimitResult.data - } + }.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT) val baseGas = gaslessTransactionRepository.getBaseGasForTransaction() @@ -197,6 +198,7 @@ internal class TokenFeeCalculator( /** Gas price safety multiplier for fee calculation */ const val GAS_PRICE_MULTIPLIER = 2 const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1 + const val PERCENT_TO_INCREASE_TRANSFER_GASLIMIT = 10 /** * Increases BigDecimal value by specified percentage. @@ -216,5 +218,11 @@ internal class TokenFeeCalculator( val multiplier = BigDecimal.ONE.add(BigDecimal(percent).divide(BigDecimal("100"))) return this.multiply(multiplier) } + + private fun BigInteger.increaseByPercent(percent: Int): BigInteger { + require(percent >= 0) { "Percent must be non-negative" } + val multiplier = BigDecimal.ONE.add(BigDecimal(percent).divide(BigDecimal("100"))) + return BigDecimal(this).multiply(multiplier).toBigInteger() + } } } \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt index 8e58e9f4eb..a5ba8d69b6 100644 --- a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt @@ -110,7 +110,7 @@ class TokenFeeCalculatorTest { // Then assertTrue(result.isLeft()) result.onLeft { error -> - assertTrue(error is GetFeeError.DataError) + assertTrue(error is GetFeeError.GaslessError) } } @@ -363,7 +363,7 @@ class TokenFeeCalculatorTest { // Expected val expectedAmount = Amount( - value = BigDecimal("36.200000000000000000000000000000000000"), + value = BigDecimal("37.400000000000000000000000000000000000"), token = Token( name = "USDC", symbol = "USDC", @@ -371,9 +371,9 @@ class TokenFeeCalculatorTest { decimals = 6, ) ) - val expectedGasLimit = "181000".toBigInteger() + val expectedGasLimit = "187000".toBigInteger() val expectedCoinPriceInToken = BigInteger("2020000000") // 2000 * 1.01 * 10^6 - val expectedFeeTransferLimit = "60000".toBigInteger() + val expectedFeeTransferLimit = "66000".toBigInteger() val expectedBaseGas = "21000".toBigInteger() // Given diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt index f99abc7f71..a83a0a796c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -628,14 +628,14 @@ internal class SendConfirmModel @Inject constructor( private fun updateAmountSubtractAvailability() { modelScope.launch { - val isGaslessEthTx = - feeUMV2?.feeExtraInfo?.transactionFeeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency - isAmountSubtractAvailable = - isAmountSubtractAvailableUseCase( - userWalletId = userWallet.walletId, - currency = cryptoCurrency, - isGaslessEthTx = isGaslessEthTx, - ).getOrElse { false } + val fee = feeUMV2?.feeExtraInfo?.transactionFeeExtended?.transactionFee?.normal + // we assume if feeExtraInfo is empty then pay fee in the main currency + val feeTokenId = feeUMV2?.feeExtraInfo?.transactionFeeExtended?.feeTokenId ?: cryptoCurrency.id + isAmountSubtractAvailable = isAmountSubtractAvailableUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + maybeGaslessFee = fee?.let { feeTokenId to fee }, + ).getOrElse { false } } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt index eb07ae00ec..7c4bc6ef9d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt @@ -106,10 +106,12 @@ internal class NotificationsModel @Inject constructor( } private suspend fun checkIfSubtractAvailable() { + val feeCurrencyId = notificationData.feeCryptoCurrencyStatus.currency.id + val fee = notificationData.fee isAmountSubtractAvailable = isAmountSubtractAvailableUseCase( userWalletId = userWalletId, currency = currency, - isGaslessEthTx = notificationData.fee is Fee.Ethereum.TokenCurrency, + maybeGaslessFee = fee?.let { feeCurrencyId to fee }, ).getOrElse { false } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index bac16c9edc..fa875ef820 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -1439,8 +1439,10 @@ internal class StakingModel @Inject constructor( } private suspend fun checkIfSubtractAvailable() { - isAmountSubtractAvailable = isAmountSubtractAvailableUseCase(userWalletId, cryptoCurrencyStatus.currency) - .getOrElse { false } + isAmountSubtractAvailable = isAmountSubtractAvailableUseCase( + userWalletId = userWalletId, + currency = cryptoCurrencyStatus.currency, + ).getOrElse { false } } private fun isTopHeatupCase(): Boolean { diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 72cfe665f9..62e8af81b4 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -3,7 +3,6 @@ package com.tangem.features.swap.v2.impl.sendviaswap.confirm.model import arrow.core.Either import arrow.core.getOrElse import arrow.core.left -import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer @@ -349,13 +348,14 @@ internal class SendWithSwapConfirmModel @Inject constructor( private fun updateAmountSubtractAvailability() { modelScope.launch { - val isGaslessEthTx = - feeUMV2?.feeExtraInfo?.transactionFeeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency + val fee = feeUMV2?.feeExtraInfo?.transactionFeeExtended?.transactionFee?.normal + val feeTokenId = + feeUMV2?.feeExtraInfo?.transactionFeeExtended?.feeTokenId ?: primaryCurrencyStatus.currency.id isAmountSubtractAvailable = isAmountSubtractAvailableUseCase( userWalletId = params.userWallet.walletId, currency = primaryCurrencyStatus.currency, - isGaslessEthTx = isGaslessEthTx, + maybeGaslessFee = fee?.let { feeTokenId to fee }, ).getOrElse { false } } } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 49a960701f..fdc2399973 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-1364" +tangemBlockchainSdk = "develop-1365" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 533edc1d169812e397e0cad350db2b14129059eb Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 00:18:38 +0700 Subject: [PATCH 111/172] Updated on 2026-08-14 --- .../transaction/SavedSwapTransactionListConverter.kt | 9 +++++++-- .../swap/converters/SavedSwapTransactionListConverter.kt | 6 +++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt index 051b7d9a47..552341da60 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt @@ -17,6 +17,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.swap.models.SwapTransactionListModel +import com.tangem.lib.crypto.derivation.AccountNodeRecognizer import com.tangem.utils.converter.Converter internal class SavedSwapTransactionListConverter( @@ -132,7 +133,7 @@ internal class SavedSwapTransactionListConverter( blockchain = blockchain, extraDerivationPath = token.derivationPath, userWallet = userWallet, - accountIndex = token.accountId?.toIntOrNull()?.let { DerivationIndex(it).getOrNull() }, + accountIndex = token.getDerivationIndex(), ) } } @@ -150,6 +151,10 @@ internal class SavedSwapTransactionListConverter( } private fun UserTokensResponse.Token.getDerivationIndex(): DerivationIndex? { - return accountId?.toIntOrNull()?.let { DerivationIndex(it).getOrNull() } + val blockchain = Blockchain.fromNetworkId(networkId) ?: return null + val accountNodeRecognizer = AccountNodeRecognizer(blockchain) + return derivationPath + ?.let { accountNodeRecognizer.recognize(it) } + ?.let { DerivationIndex(it.toInt()).getOrNull() } } } \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt index 8ec8958bd3..5c0b55b780 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt @@ -170,6 +170,10 @@ internal class SavedSwapTransactionListConverter( } private fun UserTokensResponse.Token.getDerivationIndex(): DerivationIndex? { - return accountId?.toIntOrNull()?.let { DerivationIndex(it).getOrNull() } + val blockchain = Blockchain.fromNetworkId(networkId) ?: return null + val accountNodeRecognizer = AccountNodeRecognizer(blockchain) + return derivationPath + ?.let { accountNodeRecognizer.recognize(it) } + ?.let { DerivationIndex(it.toInt()).getOrNull() } } } \ No newline at end of file From 99804d023deb7b768ff761d41db6dea262e4252f Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 09:01:22 +0100 Subject: [PATCH 112/172] Updated on 2026-08-14 --- .../feed/model/feed/FeedComponentModel.kt | 5 ++++- .../model/feed/analytics/FeedAnalyticsEvent.kt | 2 ++ .../feed/model/market/list/MarketsListModel.kt | 1 + .../statemanager/MarketsListUMStateManager.kt | 15 ++++++++------- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index d2690e25b3..a54a7d58df 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -172,7 +172,10 @@ internal class FeedComponentModel @Inject constructor( currentDate = getCurrentDate(), feedListSearchBar = FeedListSearchBar( placeholderText = resourceReference(R.string.markets_search_header_title), - onBarClick = { params.feedClickIntents.onMarketOpenClick(null) }, + onBarClick = { + analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked()) + params.feedClickIntents.onMarketOpenClick(null) + }, ), feedListCallbacks = FeedListCallbacks( onSearchClick = {}, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt index c61c143dd9..cd869f6455 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt @@ -88,6 +88,8 @@ internal sealed class FeedAnalyticsEvent( ), ) + class TokenSearchedClicked : FeedAnalyticsEvent(event = "Token Searched Clicked") + private companion object { const val IS_NOT_HTTP_ERROR = "Is not http error" } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt index 063129a71e..2daaa70082 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt @@ -75,6 +75,7 @@ internal class MarketsListModel @Inject constructor( shouldAlwaysShowSearchBar = Provider { params.shouldAlwaysShowSearchBar }, preselectedSortType = Provider { params.preselectedSortType }, onBackClick = params.onBackClicked, + analyticsEventHandler = analyticsEventHandler, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt index 92e8aeafea..e503e85087 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt @@ -2,6 +2,7 @@ package com.tangem.features.feed.model.market.list.statemanager import androidx.compose.runtime.Stable import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.event.consumedEvent @@ -9,12 +10,8 @@ import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.feed.impl.R -import com.tangem.features.feed.model.market.list.state.ListUM -import com.tangem.features.feed.model.market.list.state.MarketsListUM -import com.tangem.features.feed.model.market.list.state.MarketsNotificationUM -import com.tangem.features.feed.model.market.list.state.MarketsSearchBar -import com.tangem.features.feed.model.market.list.state.SortByBottomSheetContentUM -import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent +import com.tangem.features.feed.model.market.list.state.* import com.tangem.utils.Provider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -35,6 +32,7 @@ internal class MarketsListUMStateManager( private val onTokenClick: (MarketsListItemUM) -> Unit, private val onShowTokensUnder100kClicked: () -> Unit, private val onBackClick: () -> Unit, + private val analyticsEventHandler: AnalyticsEventHandler, ) { val state = MutableStateFlow(state()) @@ -246,7 +244,10 @@ internal class MarketsListUMStateManager( ), ), marketsNotificationUM = null, - onSearchClicked = { changeSearchBarIsActive(true) }, + onSearchClicked = { + analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked()) + changeSearchBarIsActive(true) + }, ) private fun onBottomSheetOptionClicked(sortByTypeUM: SortByTypeUM) { From 9ee661158521bbcdb3571ed668e56b7395beaf36 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 09:03:49 +0100 Subject: [PATCH 113/172] Updated on 2026-08-14 --- .../list/DefaultMarketsTokenListComponent.kt | 3 ++- .../features/feed/ui/market/list/MarketsList.kt | 14 +++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) 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 b459f3e7d8..c4c3190a5a 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 @@ -29,11 +29,12 @@ internal class DefaultMarketsTokenListComponent( @Composable override fun Title(bottomSheetState: State) { val state by model.state.collectAsStateWithLifecycle() + val bsState by bottomSheetState TopBarWithSearch( onBackClick = params.onBackClicked, onSearchClick = state.onSearchClicked, marketsSearchBar = state.marketsSearchBar, - buttonsEnabled = bottomSheetState.value == BottomSheetState.EXPANDED, + bottomSheetState = bsState, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt index ea208ba663..d9194f707f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt @@ -23,6 +23,7 @@ import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition @@ -51,10 +52,10 @@ private const val DELAY_FOR_FOCUS_REQUEST = 200L @Composable internal fun TopBarWithSearch( - buttonsEnabled: Boolean, onBackClick: () -> Unit, onSearchClick: () -> Unit, marketsSearchBar: MarketsSearchBar, + bottomSheetState: BottomSheetState = BottomSheetState.EXPANDED, ) { val background = LocalMainBottomSheetColor.current.value val focusRequester = remember { FocusRequester() } @@ -70,8 +71,8 @@ internal fun TopBarWithSearch( if (showAppBar) { AppBarWithBackButtonAndIcon( onBackClick = onBackClick, - backButtonEnabled = buttonsEnabled, - endButtonEnabled = buttonsEnabled, + backButtonEnabled = bottomSheetState == BottomSheetState.EXPANDED, + endButtonEnabled = bottomSheetState == BottomSheetState.EXPANDED, text = stringResourceSafe(R.string.markets_common_title), iconRes = R.drawable.ic_search_24, onIconClick = onSearchClick, @@ -91,8 +92,11 @@ internal fun TopBarWithSearch( focusRequester = focusRequester, ) - if (marketsSearchBar.shouldAlwaysShowSearchBar || marketsSearchBar.searchBarUM.isActive) { - LaunchedEffect(Unit) { + val shouldRequestFocus = + (marketsSearchBar.shouldAlwaysShowSearchBar || marketsSearchBar.searchBarUM.isActive) && + bottomSheetState == BottomSheetState.EXPANDED + if (shouldRequestFocus) { + LaunchedEffect(bottomSheetState) { delay(DELAY_FOR_FOCUS_REQUEST) focusRequester.requestFocus() } From c55b834b86a574444e7d8f385709ba2a0987d360 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 09:53:17 +0000 Subject: [PATCH 114/172] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 9f09031069..fdc2399973 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.32-1335" +tangemBlockchainSdk = "develop-1365" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.32-574" +tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-541" +tangemHotSdk = "develop-539" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From ba28cf5b6ddae531e151a45c5a10f68cc4d233e6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 13 Jan 2026 16:42:46 +0400 Subject: [PATCH 115/172] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 10 +- core/res/src/main/res/values-es/strings.xml | 217 ++++++++++++++++++ core/res/src/main/res/values-fr/strings.xml | 205 ++++++++++++++++- core/res/src/main/res/values-ru/strings.xml | 15 +- .../src/main/res/values-uk-rUA/strings.xml | 4 +- core/res/src/main/res/values/strings.xml | 16 +- .../message/MessageBottomSheetV2.kt | 3 +- .../core/ui/extensions/TextReference.kt | 6 +- .../main/res/drawable/ic_heart_broken_32.xml | 9 + .../repository/DefaultOnboardingRepository.kt | 16 +- .../feedback/models/FeedbackEmailType.kt | 2 + .../domain/feedback/FeedbackDataBuilder.kt | 5 + .../feedback/SendFeedbackEmailUseCase.kt | 2 + .../utils/EmailMessageBodyResolver.kt | 7 + .../utils/EmailMessageTitleResolver.kt | 1 + .../feedback/utils/EmailSubjectResolver.kt | 1 + .../feedback/utils/FeedbackConstants.kt | 3 +- .../tangem/domain/pay/model/CustomerInfo.kt | 17 +- .../TangemPayMainScreenCustomerInfoUseCase.kt | 9 +- .../model/TangemPayOnboardingModel.kt | 5 +- .../wallet/child/wallet/WalletComponent.kt | 12 + .../wallet/child/wallet/model/WalletModel.kt | 16 ++ .../model/intents/TangemPayClickIntents.kt | 47 ++++ .../feature/wallet/di/WalletFeatureModule.kt | 6 + .../WalletTangemPayAnalyticsEventSender.kt | 7 +- .../wallet/state/model/WalletDialogConfig.kt | 3 + .../TangemPayUpdateInfoStateTransformer.kt | 32 ++- .../components/visa/KycRejectedComponent.kt | 49 ++++ .../ui/components/visa/KycRejectedModel.kt | 77 +++++++ 29 files changed, 761 insertions(+), 41 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_heart_broken_32.xml create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedComponent.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedModel.kt diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 0b971535c9..65cca8211d 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -303,7 +303,7 @@ Importieren In Arbeit Später - Mehr erfahren + Mehr erfahren %1$s übrig Legacy Bitcoin Gesperrt @@ -627,8 +627,8 @@ Backup anzeigen Entferne Deine Wallet für immer! Trotzdem entfernen! - Für diese Wallet existiert ein Backup. Überprüfe dieses bitte, bevor Du sie entfernst, um sicherzustellen, dass Du sie später wiederherstellen kannst. - Wenn Du diese Wallet ohne Backup entfernst, verlierst Du dauerhaft den Zugriff auf Deine Assets. + Diese Wallet verfügt über ein Backup. Stelle sicher, dass Du es wiederherstellen kannst, bevor die Wallet resettet wird. + Wenn Du diese Wallet ohne Backup löschst, verlierst Du dauerhaft den Zugriff auf Dein Guthaben. Bist du sicher, dass du diese Wallet vergessen willst? Mir ist bewusst, dass ich den Zugriff auf meine Wallet verlieren kann, wenn ich sie vor der Entfernung nicht gesichert habe. Mir ist bewusst, dass durch das Entfernen meiner Wallet diese nicht gelöscht, sondern lediglich von meinem Gerät entfernt wird. @@ -2016,7 +2016,7 @@ Deine Gelder werden derzeit dem Aave-Protokoll bereitgestellt, Du kannst sie jedoch jederzeit verwalten. Deine%s ist in Aave hinterlegt Chart konnte nicht geladen werden... - Das Bereitstellen von %1$s %2$s bei Aave steht aus. + Lieferung %1$s %2$s nach Aave. Deaktiviere den Yield-Modus APY %1$s%% Verfügbar @@ -2082,7 +2082,7 @@ Angebot Effektiver Jahreszins für Versorgung Effektiver Jahreszins - Die Zinsen fallen automatisch an. + Zinsen fallen automatisch an. Zinsen fallen automatisch an Ertragsmodus Bearbeitung Deiner Einzahlung diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index aad8904c38..fdd6015419 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -16,12 +16,53 @@ Establezca un código de acceso de %sdígitos para desbloquear su billetera. Crear código de acceso Código de acceso + No puede crear más de %1$s cuentas. Archive una para añadir una nueva. + No se puede agregar una nueva cuenta + Cuenta archivada + Cuentas archivadas + Recuperar + Está a punto de recuperar \"%1$s\". + Recuperar cuenta + Ha alcanzado el límite de 20 cuentas activas. Archive una cuenta para recuperar esta. + No se puede recuperar la cuenta + Archivado + No hemos podido archivar la cuenta. Vuelva a intentarlo más tarde. + Esta cuenta está participando en el programa de referidos. + Esta cuenta no se puede archivar + No se ha podido crear la cuenta. Por favor, inténtelo más tarde. + Cuenta creada Archivar cuenta Archivar Estás archivando esta cuenta, pero siempre puede recuperarla. + Archivando... Cuenta No se puede editar la cuenta + Cuenta guardada + Cuenta para recompensas + Cuenta #%s - utilizada para la derivación de la dirección. + Añadir cuenta + Guardar + Nombre de la cuenta + Ya existe una cuenta con este nombre. Por favor, elija un nombre diferente. + Nombre de cuenta ya utilizado + Cuenta + Nueva cuenta + Añadir cuenta + Editar cuenta + Vuelva a intentarlo más tarde. Si el problema persiste, póngase en contacto con el servicio de asistencia y le ayudaremos a resolverlo. + %1$s en %2$s + Cuenta principal + Ya ha superado el límite de %1$s cuentas activas. Archive una para recuperarla + No se puede recuperar la cuenta + Cuenta recuperada + Pulse prolongadamente sobre una cuenta para reordenarla + Seguir editando + Descartar + ¿Seguro que quieres descartar la cuenta nueva? + ¿Seguro que deseas descartar las ediciones? + Cambios no guardados Algunos tokens personalizados se moverán automáticamente de \"%1$s\" a \"%2$s\" ya que su derivación pertenece a esa cuenta. + Algunos tokens personalizados se moverán automáticamente ¿No encuentra el token en su billetera? Consulte la sección de Mercados para encontrarlo y añadirlo a la compra ¿No encuentra el token en su billetera? Consulte los mercados para encontrarlo y añadirlo a la venta Vender @@ -81,6 +122,11 @@ Agregar billetera Seleccione una billetera para iniciar sesión ¡Bienvenido de nuevo! + + %d tarjeta + %d tarjetas + + Billetera Móvil Realizó una copia de seguridad de su billetera correctamente. Estas palabras son irrecuperables si se pierden. Guárdelas en un lugar seguro. Copia de seguridad completada @@ -341,6 +387,7 @@ Transferencia No se pueden cargar los datos… Entiendo + Entiendo, continuar Hubo un error. Por favor inténtelo de nuevo. Inaccesible Termine el staking @@ -352,6 +399,9 @@ Modo de rendimiento Dirección de contrato copiada Redes disponibles + La derivación de su token coincide con la derivación de %1$s. Su token se añadirá a esta cuenta. + La derivación pertenece a otra cuenta. + Token añadido a la cuenta %1$s Dirección de contrato La dirección de contrato es incorrecta Por favor, seleccione la red @@ -413,6 +463,7 @@ Error de WalletConnect Ha usado una tarjeta de otra billetera. Toque la tarjeta asociada con esta billetera No hay fondos suficientes para la transacción. Por favor, recargue su cuenta. + Intercambie cualquier activo de su cartera por este token Mis tokens Aún no ha añadido ningún token. Agregue tokens a través de Mercados para hacer intercambios No se puede intercambiar por %s @@ -488,6 +539,12 @@ ID : %s ID de transacción copiado Convierta una de sus activos de su portafolio por este token + Una mayor velocidad significa una confirmación más rápida y una tarifa de red más alta. %s + Elija la velocidad + Elija qué token utilizar para pagar la tarifa de red. %s + Elija token + Mercado y noticias + De actualidad La siguiente información es opcional. Puede borrarla si no quiere compartirla. Cuéntenos qué funciones echa de menos y trataremos de ayudarle. Por favor, díganos qué tarjeta o anillo tiene @@ -640,6 +697,7 @@ El token seleccionado no está disponible actualmente para acciones dentro de la billetera cripto. Pero no se preocupa, puede expresar su interés votando a favor. Votar a favor La billetera no admite más de una red + Sobre la moneda Para comprar, intercambiar o recibir este activo, agréguelo a su portafolio Este activo no está disponible en la billetera Este activo no está disponible para esta billetera @@ -675,8 +733,10 @@ Top Ganadores Top Perdedores Tendencias + Modo Rendimiento Staking es la forma más fácil de recibir recompensas por sus criptomonedas. %s Gana hasta %s APY + Token añadido Acerca de %s %d intercambio @@ -747,6 +807,21 @@ Activar Modo Rendimiento Debes actualizar a %1$s para crear una mobile wallet Mobile Wallet requiere %1$s o posterior + Todas las noticias + Me Gusta + + Hace %dh + Hace %dh + + + Hace %d minuto + Hace %d minutos + + Resumen rápido + Noticias relacionadas + Tokens relacionados + Fuentes + Manténgase informado NFC no está disponible en su dispositivo Acerca de NFT Activo NFT @@ -852,12 +927,14 @@ Generar claves de forma privada Su tarjeta está activada y lista para usar ¡Éxito! + ¡Su billetera está configurada y lista para usar! En este caso, necesitará empezar desde el principio. ¿Quiere salir del proceso de activación? Inicializando Ya se ha creado otra billetera en la tarjeta que está intentando agregar. Si tiene fondos en esta billetera, por favor retírelos y luego reinicie esta tarjeta y agréguela como backup. Guardar su billetera Creando un backup + Biometría Leer más sobre seed phrase @@ -911,6 +988,7 @@ Restaurar código de acceso Tarjetas idénticas Código de acceso + Todas las ofertas Disponible con %s La compra de criptomonedas en Tangem se lleva a cabo utilizando proveedores externos. Buscar por país @@ -918,13 +996,26 @@ Otras monedas Monedas fiduciarias populares Buscar por moneda + Esta transacción ya ha sido procesada. No se requiere ninguna otra acción. + Obteniendo las mejores tarifas... + Instantáneo Al utilizar la funcionalidad onramp, acepta %1$s y %2$s del proveedor. + El servicio es proporcionado por un proveedor externo. \nTangem no es responsable. El monto de la compra no debe ser mayor a %s La cantidad a comprar debe ser como mínimo %s No hay proveedores disponibles para esta moneda + Más rápido Pagar con + Método de pago Disponible hasta %s Disponible desde %s + + %d proveedor + %d proveedores + + Proveedores + Utilizado recientemente + Recomendado Podrá completar su transacción en el proveedor externo, %s Redirigiendo a %s... Nuestros servicios no están disponibles en este país @@ -933,8 +1024,19 @@ Seleccione el país correcto para garantizar opciones de pago y servicios precisos. Ajustes Puede comprobar el estado de la transacción desde la página del token. + + hasta %d día + hasta %d días + + %s mín. + Disponible desde + Disponible hasta + Obtiene + El servicio es proporcionado por un proveedor externo. \nTangem no es responsable. Puede comprobar el estado de la transacción desde la página detallada del token + Hasta Vía + Pagará Agrupar Por saldo Organizar tokens @@ -958,6 +1060,7 @@ Envíe solo %s a esta dirección. Enviar cualquier otra moneda resultará en su pérdida irreversible. Envíe solo %1$s en la red %2$s Transfiera fondos desde otra billetera o exchange + Dirección para recompensas Participar Error al cargar la información sobre el programa de referidos. Por favor, inténtelo de nuevo más tarde. Error al cargar la información sobre el programa de referidos. Código de error: %s. Por favor, inténtelo de nuevo más tarde. @@ -990,8 +1093,13 @@ Resetear la tarjeta Entiendo que después de realizar esta acción, ya no tendré acceso a la billetera actual Entiendo que no puedo usar esta tarjeta para recuperar mi código de acceso en las otras tarjetas de la wallet actual + Entiendo que perderé completamente el acceso a mi tarjeta Tangem Pay y a todos los fondos que contenga sin posibilidad de recuperación El restablecimiento de fábrica eliminará completamente la billetera de la tarjeta seleccionada. No podrá restaurar la billetera actual ni usar la tarjeta para recuperar el código de acceso. El reseteo a valores de fábrica eliminará completamente la billetera de la tarjeta/anillo seleccionado y lo eliminará de la app. No podrá restaurar la billetera actual. + Se han restablecido todos los dispositivos Tangem. + Hubo un problema con la activación. Por favor, restablezca las tarjetas una a una. + Error en la verificación de la tarjeta + Por favor, reinicie el siguiente dispositivo para continuar Si tiene un Anillo Tangem, ¡3 swaps sin comisión en Changelly hasta el 15/11! ¡Intercambia con 0% de comisión! Los dispositivos con jailbreak se consideran menos seguros. Sus datos podrían estar expuestos a riesgos adicionales. @@ -1070,6 +1178,7 @@ Los aranceles aduaneros son altos Debido a las peculiaridades de la red %1$s, la tarifa por transferir el saldo completo es más alta. Para reducir la comisión, puedes dejar %2$s. La tarifa es más alta + La cuenta del beneficiario no está activada. El importe mínimo de la transferencia debe ser igual o superior al saldo exento de alquiler: %1$s. Su saldo no puede ser inferior al monto del alquiler. Mantenga al menos %1$s en su cuenta o retire todos los fondos. La comisión incluida excede el monto de la transferencia, lo que resulta en un valor negativo Cantidad no válida @@ -1135,15 +1244,21 @@ Esto eliminará la wallet de la aplicación. La wallet en sí puede\nañadirse de nuevo. Nombre Haga que su token trabaje para Ud. + Una tarifa de red es un pequeño pago necesario para procesar y confirmar su transacción en la blockchain. Para comenzar a hacer staking, debe activar su cuenta TON con una auto-transacción de 1 TON. Los fondos permanecerán en su billetera; este paso solo es necesario para habilitar su cuenta para staking. + Activación de cuenta + La tarifa de red ha cambiado. Por favor, revise el nuevo importe antes de continuar. + Tarifa de red actualizada La cantidad del staking debe ser al menos %s La cantidad de staking se redondeará a %1$s TRX debido a las reglas de la red. La cantidad de unstaking se redondeará a %1$s TRX debido a las reglas de la red. + APR %1$s%% Reclamar lo que no está en staking Tarifa de staking de la cuenta Una cuenta de staking es una cuenta especial donde se almacenan los tokens SOL de staking. Se crea cuando delegas sus tokens a un validador para participar en la validación de transacciones y ganar recompensas. Se cobra una pequeña tarifa por crear la cuenta de staking, que se devuelve una vez que se completa el staking. Porcentaje de rendimiento anual APR: Muestra el interés que podrías ganar en un año sin capitalización. Los intereses ganados no se añaden a tu saldo, por lo que no ganas intereses adicionales sobre ellos. + Porcentaje de rendimiento anual APY: Muestra el total de intereses que podrías ganar en un año con capitalización. La capitalización significa que los intereses ganados se añaden a tu saldo, por lo que también ganas intereses sobre esos intereses. APR APY @@ -1172,6 +1287,7 @@ La red cobrará una tarifa de aprobación de token para verificar que usted está autorizando el uso de su token para el staking. Al utilizar la función de staking, usted acepta %1$s y %2$s del proveedor Bloqueado + Importe máximo: %s Migrar Staking nativo No hay validadores activos disponibles para hacer staking en este momento. Por favor, inténtalo de nuevo más tarde. @@ -1202,6 +1318,7 @@ La opción de volver a staking le permite mover sus fondos de un validador a otro sin necesidad de retirarlos. Está a punto de hacer staking con todo su saldo. Recomendamos dejar una pequeña cantidad para cubrir las tarifas de red para hacer unstaking o reclamar recompensas. Para empezar a hacer staking, primero debes activar tu cuenta de TON. + Activación de cuenta Para empezar a hacer staking en TON, realice primero una transacción de salida de cualquier importe - esto activará su billetera. Es posible que se requieran hasta 0.2 TON además de la tarifa de red para completar la transacción. Cualquier cantidad no utilizada será reembolsada. Se requieren 0.2 TON para realizar esta operación, además de la tarifa de red. Por favor, recargue su saldo. @@ -1300,6 +1417,7 @@ Error en la estimación de la tarifa. Envíe sus comentarios al servicio de asistencia. Usted intercambia Hacer un intercambio de esta cantidad del token seleccionado causará un impacto significativo en el precio y reducirá su resultado. + Alto impacto en los precios Fondos insuficientes Dar autorización Intercambiar @@ -1322,6 +1440,7 @@ Tu tarjeta está congelada. Obtener ayuda Otro + No se puede usar en dispositivos rooteados Completado Rechazado Pendiente @@ -1334,6 +1453,8 @@ No se pudo descongelar la tarjeta. Inténtalo de nuevo más tarde. Tu tarjeta está descongelada. Retirada + No se puede usar en un dispositivo rooteado + Cancelar KYC Agregar fondos Opciones de recarga Número de tarjeta @@ -1361,19 +1482,24 @@ ¡Todo listo! Tu tarjeta está lista para usar. Añadir tarjeta a Google Pay Añade tu tarjeta a Apple Pay + Código PIN Comparte tu dirección o muestra el código QR + Se detectaron problemas técnicos. Inténtelo de nuevo más tarde o póngase en contacto con el servicio de asistencia. Recepción no disponible ahora Mostrar Mostrar detalles Intercambia cualquier activo de tu portafolio por una tarjeta Detalles de la tarjeta Descongelar tarjeta + Vuelva a la aplicación si lo olvida. + Su código PIN Retirar Retiro no disponible ahora No puedes iniciar un intercambio o un nuevo retiro hasta que el actual finalice. Retiro en progreso Cambiar código PIN Vuelve a la app si lo olvidas. + Entiendo que perderé completamente el acceso a mi tarjeta Tangem Pay y a todos los fondos que contenga sin posibilidad de recuperación Error al emitir la tarjeta Ha ocurrido un error técnico, por favor inténtalo de nuevo haciendo clic en el botón de abajo Ha ocurrido un error técnico, por favor contacta con el soporte @@ -1385,11 +1511,14 @@ Emisión de su tarjeta Estamos preparando tu tarjeta. Esto puede llevar un poco de tiempo. Tangem Pay + Confirmar cancelación + ¿Seguro que desea detener el proceso KYC? Puede retomarlo en cualquier momento. No pudimos verificar tu perfil. Si tienes alguna pregunta, contacta con el soporte. Lamentablemente, no pudimos verificar tu identidad KYC en curso Ver estado KYC en progreso para Tangem Pay + Utilice los botones a continuación para ver su estado KYC actual o cancelarlo. Obtén tu tarjeta virtual Tangem Visa gratuita Usa USDC para pagos cotidianos Obtener tarjeta @@ -1406,7 +1535,9 @@ Estamos solucionando un problema técnico. Por favor, inténtelo de nuevo más tarde. Servicio temporalmente no disponible No es posible mostrar los datos en este momento, pero los pagos con tarjeta siguen funcionando. + Establecer \nCódigo PIN Sincronización necesaria + Restablecer acceso Usa USDC para pagos cotidianos Tangem Pay temporalmente no disponible Tangem Pay @@ -1440,6 +1571,7 @@ Token en la red %%image%% %1$s El token %1$s (%2$s) es la moneda principal en la red %3$s y no se puede ocultar mientras tengas otros tokens de esta red en la lista No se puede ocultar %s + Mostrar código QR Cambie este token por otro por una tarifa de servicio de %1$s del %2$s al %3$s de febrero. Intercambie con Changelly, %s comisiones Intercambie ahora @@ -1454,6 +1586,7 @@ Múltiples direcciones El historial de transacciones no está soportado actualmente para esta blockchain. Pero no se preocupe, ¡estamos trabajando en ello! Mientras tanto, puede consultarlo en el explorador. Operación + para: %s desde: %s a: %s validador: %s @@ -1500,11 +1633,81 @@ Renombrar la billetera Desbloquear todo Desbloquear todo con %s + AML verificado + Disponible + Bloqueado + Deuda + Límites + Otros (no-otp) + Transacción única + Total + + disponible por %d día + disponible por %d días + + El saldo disponible son los fondos reales disponibles, teniendo en cuenta las operaciones pendientes, los importes bloqueados y el saldo deudor para evitar descubiertos. + Disponible hasta %1$s + Saldos y límites + Falta información de la cuenta de pago. Contacte con el soporte técnico. + No se puede cargar información sobre el token + La información aún se está cargando, por favor espere. + Los límites son necesarios para controlar los costes, mejorar la seguridad y gestionar el riesgo. Puede gastar %1$s durante el periodo para pagos con tarjeta en comercios y %2$s para otras transacciones, por ejemplo, suscripciones o deudas. + El código de acceso se utilizará para gestionar su cuenta de pago y protegerla de accesos no autorizados + Código de acceso + Activación de cuenta + Por favor, seleccione la billetera con la que inició el proceso de registro para firmar la transacción de creación de su cuenta en Blockchain. + Cancelar Activación + ¿Seguro que quiere salir? Puede continuar más tarde desde donde lo dejó. + Esto no tardará mucho. Estamos configurando tu cuenta. + Esto no tardará mucho. Estamos completando la activación. + ¡Preparando todo! + Otra billetera Configure un código de 4 dígitos.\nSe utilizará para los pagos. + Código PIN Crear código PIN No se aceptó el PIN. Inténtalo de nuevo o usa un código diferente. PIN no válido: evitar secuencias o repeticiones + ¡Está listo para empezar! + Prepare la tarjeta Tangem y toque para aprobar + Prepare su Billetera Tangem + Podrá completar su conexión \n en un sitio web de terceros \n y volver a la aplicación Tangem + Ir al sitio web + Conexión de billetera + Elija su billetera + Continuar Activación + Continuemos configurando su cuenta. + ¡Bienvenido de nuevo! + Iniciar Activación + Siga los pasos para configurar su cuenta. + ¡Bienvenido! + Cantidad en blockchain + Código de moneda + Fecha + Código de error + Detalles de la transacción + Código de categoría de comerciante + Ciudad del comercio + Código de país del comercio + Nombre del comercio + ID Solicitud + Estado + Transacción + Importe de la transacción + Hash de la transacción + Solicitud de transacción + Estado de la transacción + Tipo + Impugnar esta transacción + Desbloquear + Escanee su tarjeta para desbloquear el acceso + Desbloqueo necesario Elija cómo agregar su billetera + Escanee su tarjeta o anillo Tangem para restaurarlo o importarlo desde otra billetera. + Crear una billetera de hardware + ¿Quiere comprar una  Billetera Tangem? + Importar seed phrase + Restaure su billetera en su teléfono o impórtela desde otra aplicación: cómodo, pero menos seguro que una tarjeta Tangem. + ¿Qué elegir? La blockchain está Inaccesible. Inténtelo más tarde Escanee la tarjeta o el anillo Esta billetera ya ha sido activada anteriormente.\nSi no fue usted quien lo hizo, comuníquese con el servicio de asistencia.\nTangem nunca vende billeteras con un código de acceso pre generado. @@ -1525,6 +1728,8 @@ Tarjeta/anillo incorrecta (o) seleccionada en Tangem App Error al crear la transacción a partir de los datos de la dApp. Código: %s Hemos encontrado un error desconocido. Mensaje de error: %d. Si el problema persiste, contacte con nuestro soporte + Transacción en varias partes + Para que se procese correctamente, su transacción se dividirá en varias partes. Tendrá que tocar su tarjeta varias veces para completarla. No hay sesiones abiertas de WalletConnect ¡Qué lástima! No hay sesiones. Error al emparejar la sesión de WalletConnect: %1$s @@ -1536,11 +1741,22 @@ Esta tarjeta no se puede usar para establecer una sesión de WalletConnect Esta red no es compatible. Por favor, seleccione otra red. Seleccione una red + La transacción se está procesando. Toque su tarjeta varias veces para completarla. + Transacción en curso Sesiones de WalletConnect Conectar a dApps WalletConnect La conexión puede tardar unos segundos. Crear una billetera Tangem + Desde %s + Compre Tangem Wallet, un dispositivo físico que almacena de forma segura su clave privada sin conexión a internet. + Billetera de hardware + Crea una billetera segura en su teléfono en segundos. + Billetera Móvil + ¿Qué elegir? + ¿Ya está usando una Billetera Tangem? + Escanear ahora + Elija un método de configuración de billetera ¿Quiere comprar Tangem Wallet? Comprar ahora Recuperar una billetera existente mediante copia de seguridad de Google Drive @@ -1607,6 +1823,7 @@ Esto puede suceder porque el proveedor actualmente no puede operar con el par que usted seleccionó. Espere un momento e inténtelo de nuevo. (Código %s) El par seleccionado no está disponible temporalmente Algunos proveedores no están autorizados por la Autoridad de Conducta Financiera del Reino Unido. Debe evitar tratar con ellos. + Lista de advertencias de la FCA Servicio no disponible temporalmente La cantidad de tokens a intercambiar no debe exceder %s El monto a cambiar debe ser de al menos %s diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 16ebbde2fe..58a8d7eb70 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -16,6 +16,52 @@ Définissez un code d\'accès à %s chiffres pour déverrouiller votre portefeuille. Créer un code d\'accès Code d\'accès + Vous ne pouvez pas créer plus de %1$s comptes. Archivez-en un pour en ajouter un nouveau. + Impossible d\'ajouter un nouveau compte + Compte archivé + Comptes archivés + Récupérer + Vous êtes sur le point de récupérer « %1$s ». + Récupérer le compte + Vous avez atteint la limite de 20 comptes actifs. Archivez un compte pour récupérer celui-ci. + Impossible de récupérer le compte + Archivé + Nous n\'avons pas pu archiver le compte. Veuillez réessayer plus tard. + Ce compte participe au programme de parrainage. + Ce compte ne peut pas être archivé. + Nous n\'avons pas pu créer de compte. Veuillez réessayer plus tard. + Compte créé + Compte archivé + Archives + Vous archivez ce compte, mais vous pouvez toujours le récupérer. + Archivage... + Compte + Impossible de modifier le compte + Compte enregistré + Compte pour les récompenses + Compte n° %s — utilisé pour la dérivation d\'adresse. + Ajouter un compte + Enregistrer + Nom du compte + Nom de compte déjà utilisé + Compte + Nouveau compte + Ajouter un compte + Modifier le compte + Veuillez réessayer plus tard. Si le problème persiste, contactez le service d\'assistance et nous vous aiderons à le résoudre. + %1$s dans %2$s + Compte principal + Vous avez déjà dépassé la limite de %1$s comptes actifs. Archivez-en un pour en récupérer un autre. + Impossible de récupérer le compte + Compte récupéré + Appuyez longuement sur un compte pour réorganiser les comptes. + Continuer à modifier + Supprimer + Êtes-vous sûr de vouloir supprimer le nouveau compte ? + Êtes-vous sûr de vouloir supprimer les modifications ? + Modifications non enregistrées + Certains jetons personnalisés seront automatiquement déplacés de « %1$s » vers « %2$s », car leur dérivation appartient à ce compte. + Certains jetons personnalisés seront automatiquement déplacés. Jeton non trouvé dans votre portefeuille ? Consultez les marchés pour le trouver et l\'ajouter à l\'achat Jeton non trouvé dans votre portefeuille ? Consultez les marchés pour le trouver et l\'ajouter à la vente Vendre @@ -51,14 +97,17 @@ Avez-vous des difficultés à scanner votre carte ? Cette carte n\'est pas conçue pour fonctionner avec Tangem Définissez d\'abord un code d\'accès pour activer la biométrie. + Utilisez %1$s pour déverrouiller votre portefeuille et approuver les actions sensibles, telles que la signature de transactions. Pour les portefeuilles matériels, une carte ou une bague est toujours nécessaire pour signer. Frais par défaut Activez les frais par défaut pour définir automatiquement les frais de transaction et ignorer la page Frais lors de l\'envoi de fonds. Vous pouvez toujours revenir sur cette page si nécessaire. Accédez aux paramètres pour activer l\'authentification biométrique dans l\'application Tangem Activer l\'authentification biométrique + La désactivation de %1$s vous obligera à saisir votre code d\'accès pour déverrouiller l\'application et interagir avec votre portefeuille. Le code d\'accès à votre portefeuille vous sera demandé ultérieurement afin que nous puissions le stocker en toute sécurité pour une utilisation future. Cela supprimera tous les codes d\'accès des portefeuilles enregistrés. Toute opération ultérieure avec le portefeuille nécessitera la soumission du code d\'accès. La suppression de la carte enregistrée supprime de l\'application tous les portefeuilles enregistrés et leurs codes d\'accès. Cela supprimera tous les codes d\'accès enregistrés pour le portefeuille. Toute interaction ultérieure avec le portefeuille nécessitera la saisie du code d\'accès. + Code d\'accès requis Cette option désactive la biométrie pour les actions sensibles. Vous devrez saisir votre code d\'accès à chaque fois que vous signerez une transaction. Enregistrer le code d\'accès L\'authentification biométrique sera demandée à la place du code d\'accès pour les interactions avec votre carte. @@ -72,6 +121,11 @@ Ajouter un wallet Sélectionnez un wallet pour vous connecter Heureux de te revoir! + + %d carte + %d cartes + + Mobile Wallet Vous avez sauvegardé votre portefeuille avec succès. Ces mots ne peuvent pas être récupérés en cas de perte. Assurez-vous de les conserver en lieu sûr. Sauvegarde terminée @@ -332,6 +386,7 @@ Fourniture Impossible de charger les données… Je comprends + Je comprends, continuer. Il y avait une erreur. Veuillez réessayer. Inaccessible Unstakez @@ -343,6 +398,7 @@ Mode Rendement Adresse du contrat copiée ! Réseaux disponibles + Jeton ajouté au compte %1$s Adresse du contrat L\'adresse du contrat n\'est pas valide Veuillez sélectionner le réseau @@ -404,6 +460,7 @@ Erreur WalletConnect Vous avez utilisé une carte d\'un autre portefeuille. Appuyez sur la carte associée à ce portefeuille Pas assez de fonds pour la transaction. Veuillez recharger votre compte. + Échangez n\'importe quel actif de votre portefeuille contre ce jeton. Mes jetons Vous n\'avez pas encore ajouté de jetons. Ajoutez des jetons via Market pour effectuer une échange Ne peut pas être échangé contre %s @@ -442,6 +499,7 @@ En attente du dépôt En attente du dépôt Remboursé + En attente de remboursement Transfert à votre compte En cours d\'envoi Envoyé @@ -459,6 +517,7 @@ Meilleur taux Liste d’avertissement de la FCA Meilleur choix + Fournisseur figurant sur la liste d\'avertissement de la FCA Disponible jusqu\'à %s Disponible à partir de %s Indisponible pour cette paire @@ -477,6 +536,12 @@ ID : %s ID de transaction copié Convertissez une de vos devises en une autre + Une vitesse plus élevée signifie une confirmation plus rapide et des frais de réseau plus élevés. %s + Choisissez la vitesse + Choisissez le token à utiliser pour payer les frais de réseau. %s + Choisissez un token + Marché et actualités + Tendances actuelles Les informations suivantes sont facultatives. Vous pouvez les effacer si vous ne souhaitez pas les partager. Dites-nous quelles fonctions vous manquent, et nous essaierons de vous aider. Veuillez nous dire quelle carte vous avez @@ -629,6 +694,7 @@ Le jeton sélectionné n\'est actuellement pas disponible pour des actions dans le portefeuille crypto. Mais ne vous inquiétez pas, vous pouvez exprimer votre intérêt en votant positivement. Vote positif Le portefeuille ne prend pas en charge plus d\'un réseau + À propos de la crypto Pour acheter, échanger ou recevoir cet actif, ajoutez-le à votre portefeuille Cet actif n\'est pas pris en charge dans le portefeuille Cet actif n\'est pas disponible pour ce portefeuille @@ -664,8 +730,10 @@ Meilleurs Gagnants Top Perdants Tendances + Mode rendement Le staking est le moyen le plus simple de recevoir des récompenses sur votre crypto. %s Gagnez jusqu\'à %s APY + Jeton ajouté À propos de %s %d échange @@ -736,6 +804,21 @@ Activer le mode rendement Vous devez effectuer la mise à jour %1$s afin de créer un portefeuille mobile. Le portefeuille mobile nécessite %1$s ou une version supérieure. + Toutes les actualités + Like + + Il y a %dh + Il y a %dh + + + Il y a %d minute + Il y a %d minutes + + Récapitulatif rapide + Actualités similaires + Tokens associés + Sources + Restez informé NFC n\'est pas disponible sur votre appareil À propos des NFT Actif NFT @@ -841,20 +924,22 @@ Générer des clés de manière privée Votre carte est activée et prête à être utilisée Succès ! + Votre portefeuille est configuré et prêt à être utilisé ! Dans ce cas, vous devrez recommencer depuis le début. Voulez-vous quitter le processus d\'activation ? Initialisation en cours Un autre portefeuille a déjà été créé sur la carte que vous essayez d\'ajouter. Si vous avez des fonds dans ce portefeuille, veuillez les retirer, puis réinitialiser cette carte et l\'ajouter comme sauvegarde. Enregistrer votre portefeuille Sauvegarde en cours + Biométrie En savoir plus sur les seed phrases - + Vide Écrivez ces %d mots dans l\'ordre indiqué ci-dessous et conservez-les dans un endroit sûr et secret. Votre seed phrase - + Vide %d mots Pour importer votre portefeuille, entrez votre seed phrase dans le champ ci-dessous @@ -900,6 +985,7 @@ Restauration du code d\'accès Cartes identiques Code d\'accès + Toutes les offres Disponible avec %s L\'achat de crypto-monnaies sur Tangem est assuré par des fournisseurs tiers selon leurs conditions. Recherche par pays @@ -907,13 +993,26 @@ Autres devises Devises Fiats Populaires Recherche par devise + Cette transaction a déjà été traitée. Aucune autre action n\'est requise. + Recherche des meilleurs tarifs... + Instantané En utilisant la fonctionnalité onramp, vous acceptez %1$s et %2$s du fournisseur + Le service est fourni par un prestataire externe. Tangem n\'est pas responsable. Le montant de l\'achat ne doit pas dépasser %s Le montant à acheter doit être au moins %s Aucun fournisseur disponible pour cette devise + Le plus rapide Payer avec + Mode de paiement Disponible jusqu\'à %s Disponible à partir de %s + + %d fournisseur + %d fournisseurs + + Fournisseurs + Récemment utilisé + Recommandé Vous pourrez finaliser votre transaction sur le fournisseur tiers, %s Redirection vers %s... Nos services ne sont pas disponibles dans ce pays @@ -922,8 +1021,18 @@ Veuillez sélectionner le bon pays pour garantir des options de paiement et des services précis. Paramètres Vous pouvez vérifier l\'état de la transaction à partir de la page du jeton. + + jusqu\'à %d jour + jusqu\'à %d jours + + %s min + Disponible auprès de + Disponible jusqu\'à + Vous obtenez Vous pouvez vérifier l\'état de la transaction à partir de la page du jeton + Jusqu\'à Via + Vous paierez Grouper Par solde Organiser les jetons @@ -947,6 +1056,7 @@ Envoyez uniquement %s à cette adresse. L\'envoi de toute autre devise entraînera sa perte irréversible. Envoyez uniquement %1$s sur le réseau %2$s Transférez des fonds depuis un autre portefeuille ou une autre bourse + Adresse pour les récompenses Participer Échec du chargement des informations sur le programme de parrainage. Veuillez réessayer plus tard. Échec du chargement des informations sur le programme de parrainage. Code d\'erreur : %s. Veuillez réessayer plus tard. @@ -979,8 +1089,13 @@ Réinitialiser la carte Je comprends qu\'après avoir effectué cette action, je n\'aurai plus accès au portefeuille actuel Je comprends que je ne peux pas utiliser cette carte pour récupérer mon code d\'accès sur les autres cartes du portefeuille actuel + Je comprends que je perdrai complètement l\'accès à ma carte Tangem Pay et à tous les fonds qui s\'y trouvent, sans possibilité de récupération. La réinitialisation aux paramètres d\'usine supprimera complètement le portefeuille de la carte sélectionnée. Vous ne pourrez pas restaurer le portefeuille actuel ni utiliser la carte pour récupérer le code d\'accès. La réinitialisation aux paramètres d\'usine supprimera complètement le portefeuille de la carte sélectionnée et le supprimera de l\'application. Vous ne pourrez pas restaurer le portefeuille actuel. + Tous les appareils Tangem ont été réinitialisés. + Une erreur s\'est produite lors du processus d\'activation. Veuillez réinitialiser les cartes une par une. + Échec de la vérification de la carte + Veuillez réinitialiser l\'appareil suivant pour continuer. Les propriétaires du Tangem Ring ont droit à 3 swap gratis sur Changelly jusqu\'au 15/11 ! Échangez avec 0 % de frais ! Les appareils disposant d\'un accès root sont considérés comme moins sécurisés. Vos données peuvent être exposées à des risques supplémentaires. @@ -1126,14 +1241,18 @@ Nom Faites travailler votre jeton pour vous Pour commencer à staker, votre compte TON doit être activé avec une transaction de 1 TON. Les fonds restent sur votre compte, car cette étape sert uniquement à l\'activer pour le staking. + Les frais de réseau ont changé. Veuillez vérifier le nouveau montant avant de continuer. + Frais de réseau mis à jour Le montant à staker doit être au moins %s Le montant du staking sera arrondi à %1$s TRX en raison des règles du réseau. Le montant d\'annulation du staking sera arrondi à %1$s TRX en raison des règles du réseau. + TAEG %1$s%% Réclamation déstakée Frais de staking du compte Un compte de staking est un compte spécial où sont stockés les jetons SOL stakés. Il est créé lorsque vous déléguez vos jetons à un validateur pour participer à la validation des transactions et gagner des récompenses. Des frais minimes sont facturés pour la création du compte de staking, qui sont restitués une fois le staking terminé. Pourcentage de rendement annuel APR : Montre le taux d’intérêt que vous pourriez gagner en un an sans capitalisation. Les intérêts gagnés ne sont pas ajoutés à votre solde, donc vous ne gagnez pas d’intérêts supplémentaires dessus. + Taux de rendement annuel APY : Montre le total des intérêts que vous pourriez gagner en un an avec capitalisation. La capitalisation signifie que les intérêts gagnés sont ajoutés à votre solde, et vous gagnez également des intérêts sur ces intérêts. APR APY @@ -1162,6 +1281,7 @@ Le réseau facturera des frais d’approbation de jeton pour vérifier que vous autorisez l’utilisation de votre jeton pour le jalonnement. En utilisant la fonctionnalité de staking, vous acceptez les %1$s et %2$s du fournisseur Bloqué + Montant maximal : %s Migrer Native staking Aucun validateur actif n\'est disponible pour le staking pour le moment. Veuillez réessayer plus tard. @@ -1290,6 +1410,7 @@ Erreur d\'estimation des frais. Veuillez envoyer vos commentaires à l\'équide de support. Vous échangez Échanger ce montant de jetons sélectionnés aura un impact significatif sur les prix et réduira votre résultat. + Impact élevé sur les prix Fonds insuffisants Donner l\'autorisation Échanger @@ -1312,6 +1433,7 @@ Votre carte est gelée. Obtenir de l\'aide Autre + Impossible à utiliser sur les appareils rootés Terminé Refusé En attente @@ -1324,6 +1446,8 @@ Échec du dégel de la carte. Réessayez plus tard. Votre carte est dégelée. Retrait + Impossible à utiliser sur un appareil rooté + Annuler KYC Ajouter des fonds Options de recharge Numéro de carte @@ -1351,19 +1475,24 @@ Tout est prêt ! Votre carte est prête à l\'emploi. Ajouter une carte à Google Pay Ajouter la carte à Apple Pay + code PIN Partagez votre adresse ou montrez le QR code + Problèmes techniques détectés. Veuillez réessayer plus tard ou contacter le service d\'assistance. Réception indisponible pour le moment Révéler Afficher les détails Échangez n\'importe quel actif de votre portefeuille contre une carte Détails de la carte Dégeler la carte + Revenez à l\'application si vous l\'oubliez. + Votre code PIN Retirer Retrait indisponible pour le moment Vous ne pouvez pas lancer d\'échange ou de nouveau retrait tant que le retrait actuel n\'est pas terminé. Retrait en cours Modifier le code PIN Revenez dans l\'application si vous l\'oubliez. + Je comprends que je perdrai complètement l\'accès à ma carte Tangem Pay et à tous les fonds qui s\'y trouvent, sans possibilité de récupération. Échec de l\'émission de la carte Une erreur technique s\'est produite, veuillez réessayer en cliquant sur le bouton ci-dessous Une erreur technique s\'est produite, veuillez contacter le support @@ -1375,11 +1504,14 @@ Émission de votre carte Nous préparons votre carte. Cela peut prendre un peu de temps. Tangem Pay + Confirmer l\'annulation + Êtes-vous sûr de vouloir interrompre le processus KYC ? Vous pouvez y revenir à tout moment. Nous n\'avons pas pu vérifier votre profil. Pour toute question, veuillez contacter le support. Malheureusement, nous n\'avons pas pu vérifier votre identité KYC en cours Voir le statut KYC en cours pour Tangem Pay + Utilisez les boutons ci-dessous pour consulter votre statut KYC actuel ou l\'annuler. Obtenez votre carte virtuelle Tangem Visa gratuite Utilisez USDC pour les paiements quotidiens Obtenir la carte @@ -1396,7 +1528,9 @@ Nous réparons un problème technique. Veuillez réessayer plus tard. Service temporairement indisponible Les données ne peuvent pas être affichées pour le moment, mais les paiements par carte fonctionnent toujours. + Définir le \ncode PIN Synchronisation requise + Restaurer l\'accès Utilisez USDC pour les paiements quotidiens Tangem Pay est temporairement indisponible Tangem Pay @@ -1408,6 +1542,7 @@ Annuler Cette opération est actuellement indisponible. Veuillez réessayer plus tard. L\'achat de %s n\'est pas pris en charge par les fournisseurs actuels mais nous travaillons à ajouter davantage d\'options. + L\'action n\'est pas disponible pour les jetons personnalisés. Vous n\'avez pas de fonds à vendre. Renflouez votre compte pour pouvoir vendre des fonds à partir de celui-ci. Vous n\'avez pas de fonds à envoyer. Renflouez votre compte pour pouvoir envoyer des fonds à partir de celui-ci. Die Daten wurden noch nicht geladen. Dies kann einige Sekunden dauern. Bitte versuchen Sie es später noch einmal. @@ -1443,6 +1578,7 @@ Adresses multiples L\'historique des transactions n\'est actuellement pas pris en charge pour cette blockchain. Mais ne vous inquiétez pas, nous y travaillons ! En attendant, vous pouvez le vérifier dans l\'explorateur. Opération + pour : %s de : %s à : %s validateur : %s @@ -1489,26 +1625,71 @@ Renommer le portefeuille Tout déverrouiller Tout déverrouiller avec %s + AML vérifié + Disponible + Bloqué + Dette + Limites + Autre (sans mot de passe) + Transaction unique + Total disponible pour %d jour disponible pour %d jours + Le solde disponible correspond aux fonds réellement disponibles, en tenant compte des transactions en attente, des montants bloqués et du solde débiteur afin d\'éviter les découverts. + Disponible jusqu\'au %1$s Soldes et Limites + Informations de paiement manquantes. Veuillez contacter le service d\'assistance. + Impossible de charger les informations relatives au jeton + Les informations sont en cours de chargement, veuillez patienter. + Des limites sont nécessaires pour contrôler les coûts, améliorer la sécurité et gérer les risques. Vous pouvez dépenser %1$s pendant la période pour les paiements par carte dans les magasins et %2$s pour les autres transactions, par exemple les abonnements ou les dettes. Le code d\'accès sera utilisé pour gérer votre compte de paiement et le protéger contre tout accès non autorisé. Code d\'accès + Activation du compte + Veuillez choisir le portefeuille avec lequel vous avez commencé le processus d\'inscription afin de signer la transaction pour créer votre compte sur la blockchain. + Annuler l\'activation Êtes-vous sûr de vouloir quitter ? Vous pourrez reprendre plus tard là où vous vous étiez arrêté. Cela ne prendra pas longtemps. Nous configurons votre compte. Cela ne prendra pas longtemps. Nous terminons l\'activation. Tout est en cours de préparation ! + Autre portefeuille Créez un code à 4 chiffres. Il servira pour les paiements. + code PIN Créer un code PIN Le code PIN n\'a pas été accepté. Veuillez réessayer ou utiliser un autre code. Code PIN invalide : évitez les séquences ou les répétitions + Vous êtes prêt à partir ! + Préparez la carte Tangem et appuyez pour approuver. + Préparez votre Tangem Wallet + Vous pourrez terminer votre connexion \n sur le site Web tiers \n et revenir à l\'application Tangem. Accéder le site Web + Connexion au portefeuille + Choisissez un portefeuille + Continuer l\'activation Continuons la configuration de votre compte. Content de vous revoir ! + Lancer l\'activation Suivez les étapes pour configurer votre compte. Bienvenue ! + Montant de la blockchain + Code monétaire + Date + Code d\'erreur + Détails de la transaction + Code de catégorie de commerçant + Cité marchande + Code pays du commerçant + Nom du commerçant + Identifiant de la demande + Statut + Transaction + Montant de la transaction + Hash de la transaction + Demande de transaction + Statut de la transaction + Type + Contester cette transaction Déverrouiller Scannez votre carte pour déverrouiller l\'accès Déverrouillage nécessaire @@ -1533,6 +1714,8 @@ Mauvaise carte/bague sélectionnée dans le Tangem App Échec de la création de la transaction à partir des données de la Dapp. Code: %s Nous avons rencontré une erreur inconnue. Code d\'erreur : %d. Si le problème persiste, n\'hésitez pas à contacter notre support + Transaction en plusieurs parties + Pour que le traitement soit effectué correctement, votre transaction sera divisée en plusieurs parties. Vous devrez passer votre carte plusieurs fois pour la finaliser. Aucune session WalletConnect ouverte Oups. Aucune session. Échec de l\'appairage de la session WalletConnect : %1$s @@ -1544,11 +1727,22 @@ Cette carte ne peut pas être utilisée pour établir une session WalletConnect Ce réseau n\'est pas pris en charge. Veuillez sélectionner un autre réseau. Sélectionnez un réseau + La transaction est en cours de traitement. Veuillez taper plusieurs fois votre carte pour la terminer. + Transaction en cours Sessions WalletConnect Se connecter aux dApps WalletConnect La connexion peut prendre quelques secondes Créer un Tangem Wallet + À partir de %s + Achetez Tangem Wallet, un dispositif physique qui stocke votre clé privée hors ligne en toute sécurité. + Hardware Wallet + Créez un portefeuille sécurisé sur votre téléphone en quelques secondes. + Mobile Wallet + Quoi choisir ? + Vous utilisez déjà un Tangem Wallet ? + Scanner maintenant + Choisissez une méthode de configuration du portefeuille Vous souhaitez acheter le Tangem Wallet ? Acheter maintenant Récupérer un portefeuille existant via la sauvegarde Google Drive @@ -1727,6 +1921,7 @@ Connexions Contenu Copier les données + Allocation personnalisée dApp déconnectée Déconnecter tout Toutes les sessions dApp seront déconnectées. Votre portefeuille ne sera plus lié à aucune dApp. @@ -1739,6 +1934,8 @@ La proposition de connexion a expiré Modifications estimées du portefeuille La transaction n\'a pas pu être simulée. Veuillez procéder avec prudence. + L\'estimation n\'est pas prise en charge pour %s. + Suggéré par %s Rechargez votre solde pour couvrir les frais de réseau. %1$s Insuffisant Transaction malveillante @@ -1752,6 +1949,7 @@ Risque de sécurité connu Ouvrez l\'application Web3 et sélectionnez l\'option WalletConnect. Demande de + Signer quand même Type de signature Au moins un réseau est requis pour la connexion à une dApp. Spécifier les réseaux sélectionnés @@ -1760,7 +1958,10 @@ Demande de transaction Demande de transaction Montant illimité + Assurez-vous que chaque tentative d\'appairage utilise un URI unique et nouveau. + URI déjà utilisé WalletConnect + Transaction suspecte Vous avez déjà un portefeuille Tangem ? Des milliers d\'actifs Le meilleur hardware wallet de sa catégorie diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index aa04e8ffcf..2f51bfad1e 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -546,6 +546,7 @@ ID: %s ID транзакции скопирован Обменяйте любой актив в вашем портфеле на этот токен + Выбрать токен Рынок и Новости В тренде Информация ниже не является обязательной. Вы можете стереть её, если хотите. @@ -602,7 +603,7 @@ Создать новый кошелек Повысьте уровень безопасности с помощью продвинутого аппаратного кошелька Tangem. Аппаратный кошелёк - Перенести текущий кошелёк в Tangem Wallet. + Перенесите свой текущий кошелек в холодный кошелек Tangem. Улучшить текущий кошелёк Перейти к бэкапу Пожалуйста, создайте резервную копию вашего кошелька перед установкой кода доступа. @@ -627,7 +628,7 @@ Вы уверены, что хотите выполнить эту операцию? Забыть кошелек Перейти к резервному копированию - Посмотреть резервное копирование + Показать фразу восстановления Забыть кошелек Забыть всё равно Резервная копия этого кошелька существует. Проверьте её перед удалением, чтобы убедиться, что сможете восстановить кошелёк позже. @@ -1479,6 +1480,7 @@ Карта разморожена Вывести Запрещено использовать на root-устройствах + Отменить KYC Пополнить Способы пополнения Номер @@ -1534,6 +1536,8 @@ Выпускаем карту Выпускаем карту, это займет немного времени. Tangem Pay + Подтвердите отмену + Если вы отмените KYC сейчас, вы сможете потом начать его заново Не удалось подтвердить ваш профиль. Если у вас есть вопросы, обратитесь в службу поддержки. К сожалению, нам не удалось подтвердить вашу личность KYC в процессе @@ -1659,7 +1663,9 @@ Установить ПИН-код ПИН не принят. Попробуйте ещё раз или введите другой код. Слабый ПИН: не используйте повторы или последовательности. + Код валюты Разблокировать + Отсканируйте свою карту, чтобы разблокировать доступ Выберите тип кошелька Отсканируйте вашу карту или кольцо Tangem, чтобы восстановить её или импортировать из другого кошелька. Создать аппаратный кошелёк @@ -1740,8 +1746,8 @@ Откройте Tangem Wallet Код доступа защищает кошелёк и нужен для входа и подписания транзакций. Установить/изменить код доступа - Изменить Код Доступа - Получайте уведомления о входящих транзакциях в кошельке и обновлениях Tangem. + Изменить код доступа + Получайте push-уведомления о входящих транзакциях в кошелек. В настоящий момент push-уведомления могут не работать на устройствах Huawei. Мы уже работаем над решением этой проблемы и планируем исправление в ближайших обновлениях. Спасибо за понимание! Уведомления о транзакциях Установить Код Доступа @@ -1781,6 +1787,7 @@ Сумма получения не может быть менее %s Это может произойти из-за того, что провайдер временно не предоставляет обмен выбранной вами пары. Пожалуйста, подождите некоторое время и попробуйте снова. (Код %s) Выбранная пара временно недоступна + Предупреждающий список FCA Сервис временно недоступен Сумма для обмена должна быть не более %s Сумма для обмена должна быть не менее %s diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 016bb09e5e..fbf1149a65 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -607,7 +607,7 @@ Створити новий гаманець Підвищіть рівень своєї безпеки за допомогою просунутого апаратного гаманця Tangem. Апаратний гаманець - Перенесіть свій поточний гаманець у Tangem. + Перетворіть поточний гаманець на холодний гаманець Tangem. Оновіть поточний гаманець Перейти до бекапу Будь ласка, створіть резервну копію свого гаманця, перш ніж створювати код доступу. @@ -1767,7 +1767,7 @@ Цей код доступу захищає ваш гаманець і використовується для входу та підписання транзакцій. Встановити/Змінити код доступу Змінити код доступу - Отримуйте сповіщення про вхідні транзакції в гаманці та оновлення Tangem. + Отримуйте сповіщення про вхідні транзакції в гаманці. Push-повідомлення наразі можуть не працювати на пристроях Huawei. Ми активно працюємо над розв\'язанням цієї проблеми та плануємо випустити виправлення в наступному оновленні. Дякуємо за розуміння! Сповіщення про транзакції Встановити код доступу diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 4833e76320..d5b33d94e7 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -539,9 +539,9 @@ ID: %s Transaction ID copied Swap any asset in your portfolio for this token - Higher speed means faster confirmation and a higher network fee. %s + Higher speed means faster confirmation\nand a higher network fee. %s Choose speed - Choose which token to use to pay the network fee. %s + Choose which token to use to pay\nthe network fee. %s Choose token Market & News Tangem AI @@ -600,7 +600,7 @@ Create new wallet Level up your security with the superior Tangem hardware wallet. Hardware wallet - Move your current wallet into Tangem. + Convert your current mobile wallet into a Tangem cold wallet. Upgrade current wallet Go to backup Please back up your wallet before creating an access code. @@ -625,7 +625,7 @@ Are you sure you want to do this? Forget wallet Go to backup - View backup + View recovery phrase Forget wallet Forget anyway This wallet has a backup. Ensure you can recover it before forgetting the wallet. @@ -1264,6 +1264,7 @@ APR APY Rewards automatically accumulate in your staking balance daily. + Rewards are compounded to your staking balance. Funds earned: %s Available Average Reward Rate How Staking Works? @@ -1516,10 +1517,15 @@ Are you sure you want to stop the KYC process? You can return to it anytime. We could not verify your profile. If you have any questions, please contact support. Unfortunately, we couldn\'t verify your identity + KYC has failed KYC in progress View Status KYC in progress for Tangem Pay Use the buttons below to view your current KYC status or cancel it. + KYC rejected + Hide KYC block + Sorry, we couldn\'t verify + your profile. Get your free Tangem Visa virtual card Use USDC for everyday payments Get card @@ -1783,7 +1789,7 @@ This access code protects your wallet and is used to log in and sign transactions. Set/Change access code Change access code - Stay notified on wallet incoming transactions and Tangem updates. + Get push notifications for incoming wallet transactions. Push notifications may currently not work on Huawei devices. We\'re actively working on a solution and will release a fix in an upcoming update. Thank you for your understanding! Transaction notifications Set access code diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt index 7b3ab2dd0b..ab89d9ede7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt @@ -27,6 +27,7 @@ import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults import com.tangem.core.ui.components.icons.HighlightedIcon import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -109,7 +110,7 @@ private fun ContentContainer(state: MessageBottomSheetUMV2.InfoBlock, modifier: modifier = Modifier .fillMaxWidth() .padding(top = TangemTheme.dimens.spacing8), - text = body.resolveReference(), + text = body.resolveAnnotatedReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.secondary, textAlign = TextAlign.Center, diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt index 9997470e8c..e771427974 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt @@ -14,11 +14,8 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.* import androidx.compose.ui.text.AnnotatedString.Builder -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withLink import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -399,6 +396,7 @@ private fun createStyledText( link = LinkAnnotation.Clickable( tag = text, linkInteractionListener = { onClick() }, + styles = TextLinkStyles(style = spanStyleReference()), ), block = { appendStyled( diff --git a/core/ui/src/main/res/drawable/ic_heart_broken_32.xml b/core/ui/src/main/res/drawable/ic_heart_broken_32.xml new file mode 100644 index 0000000000..2d9d8aad75 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_heart_broken_32.xml @@ -0,0 +1,9 @@ + + + diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index d877f0f1f3..aac0e1c218 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -29,7 +29,9 @@ import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject private const val VALID_STATUS = "valid" -private const val APPROVED_KYC_STATUS = "APPROVED" +private const val APPROVED_KYC_STATUS = "approved" +private const val IN_PROGRESS_KYC_STATUS = "in_progress" +private const val DECLINED_KYC_STATUS = "declined" private const val TAG = "TangemPay: OnboardingRepository" @Suppress("LongParameterList") @@ -168,8 +170,9 @@ internal class DefaultOnboardingRepository @Inject constructor( ProductInstance(id = instance.id, cardId = instance.cardId, cardFrozenState = cardFrozenState) } return CustomerInfo( + customerId = response?.id, productInstance = productInstance, - isKycApproved = response?.kyc?.status == APPROVED_KYC_STATUS, + kycStatus = getKycStatus(status = response?.kyc?.status), cardInfo = cardInfo, ).also { lastFetchedCustomerInfoMap[userWalletId] = it @@ -236,4 +239,13 @@ internal class DefaultOnboardingRepository @Inject constructor( setHideMainOnboardingBanner(userWalletId) } } + + private fun getKycStatus(status: String?): CustomerInfo.KycStatus { + return when (status?.lowercase()) { + IN_PROGRESS_KYC_STATUS -> CustomerInfo.KycStatus.PENDING + DECLINED_KYC_STATUS -> CustomerInfo.KycStatus.REJECTED + APPROVED_KYC_STATUS -> CustomerInfo.KycStatus.APPROVED + else -> CustomerInfo.KycStatus.INIT + } + } } \ No newline at end of file diff --git a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt index 81aaaffd7a..1dae5fb656 100644 --- a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt +++ b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt @@ -84,5 +84,7 @@ sealed interface FeedbackEmailType { ) : Visa() data class FeatureIsBeta(override val walletMetaInfo: WalletMetaInfo) : Visa() + + data class KycRejected(override val walletMetaInfo: WalletMetaInfo, val customerId: String) : Visa() } } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt index 00815bea99..d10954f517 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt @@ -54,6 +54,10 @@ internal class FeedbackDataBuilder { builder.appendKeyValue("User Wallet ID", userWalletId) } + fun addCustomerId(customerId: String) { + builder.appendKeyValue("ID: ", customerId) + } + fun addUserWalletMetaInfo(walletMetaInfo: WalletMetaInfo) { builder.appendKeyValue("Mobile Wallet is backed up", walletMetaInfo.hotWalletIsBackedUp?.toString()) builder.appendKeyValue("Card ID", walletMetaInfo.cardId) @@ -116,6 +120,7 @@ internal class FeedbackDataBuilder { is FeedbackEmailType.Visa.Dispute, is FeedbackEmailType.Visa.FeatureIsBeta, is FeedbackEmailType.Visa.Withdrawal, + is FeedbackEmailType.Visa.KycRejected, -> return is FeedbackEmailType.Visa.DisputeV2 -> when (type.item) { is TangemPayTxHistoryItem.Collateral -> "Receive/Withdraw" diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt index 7bc3d5342a..f1228c50c6 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt @@ -48,6 +48,7 @@ class SendFeedbackEmailUseCase( private fun getAddress(type: FeedbackEmailType): String { return when { + type is FeedbackEmailType.Visa.KycRejected -> PAERA_SUPPORT_EMAIL type.isVisaEmail() -> TANGEM_VISA_SUPPORT_EMAIL type.walletMetaInfo?.isStart2Coin == true -> START2COIN_SUPPORT_EMAIL else -> TANGEM_SUPPORT_EMAIL @@ -95,6 +96,7 @@ class SendFeedbackEmailUseCase( is FeedbackEmailType.Visa.Dispute, is FeedbackEmailType.Visa.DisputeV2, is FeedbackEmailType.Visa.FeatureIsBeta, + is FeedbackEmailType.Visa.KycRejected, -> this is FeedbackEmailType.DirectUserRequest, is FeedbackEmailType.BackupProblem, diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt index 998110b7b7..ec9d994636 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt @@ -39,6 +39,7 @@ internal class EmailMessageBodyResolver( is FeedbackEmailType.Visa.DisputeV2 -> addTangemPayDisputeRequestBody(type) is FeedbackEmailType.Visa.Withdrawal -> addTangemPayWithdrawalRequestBody(type) is FeedbackEmailType.Visa.FeatureIsBeta -> addTangemPayBetaRequestBody(type) + is FeedbackEmailType.Visa.KycRejected -> addTangemPayKycRejectedRequestBody(type) } return build() @@ -70,6 +71,12 @@ internal class EmailMessageBodyResolver( } } + private fun FeedbackDataBuilder.addTangemPayKycRejectedRequestBody(type: FeedbackEmailType.Visa.KycRejected) { + addTangemPayPhoneInfoBody(type) + addDelimiter() + addCustomerId(customerId = type.customerId) + } + private suspend fun FeedbackDataBuilder.addTangemPayWithdrawalRequestBody( type: FeedbackEmailType.Visa.Withdrawal, ) { diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt index 03d304ddb7..4064444636 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt @@ -27,6 +27,7 @@ internal class EmailMessageTitleResolver(private val resources: Resources) { is FeedbackEmailType.Visa.FailedIssueCard, is FeedbackEmailType.Visa.Withdrawal, is FeedbackEmailType.Visa.FeatureIsBeta, + is FeedbackEmailType.Visa.KycRejected, is FeedbackEmailType.PreActivatedWallet, is FeedbackEmailType.BackupProblem, -> R.string.feedback_preface_support diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt index ad9330b4bc..451c9a1beb 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt @@ -47,6 +47,7 @@ internal class EmailSubjectResolver(private val resources: Resources) { -> "[Visa] [DISPUTE] {auto-filled subject}" is FeedbackEmailType.Visa.Withdrawal -> "[Visa] [WITHDRAWAL] {auto-filled subject}" is FeedbackEmailType.Visa.FeatureIsBeta -> "[VISA] [FEEDBACK]" + is FeedbackEmailType.Visa.KycRejected -> "Tangem Pay - [VISA] [KYC REJECTED]" } } } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt index e1ec372cc9..c1bc6d1043 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt @@ -4,4 +4,5 @@ internal const val GMAIL_MAX_FILE_SIZE = 24_900_000 // ≈ 25 MB internal const val START2COIN_SUPPORT_EMAIL = "cardsupport@start2coin.com" internal const val TANGEM_SUPPORT_EMAIL = "support@tangem.com" -internal const val TANGEM_VISA_SUPPORT_EMAIL = "pay@tangem.com" \ No newline at end of file +internal const val TANGEM_VISA_SUPPORT_EMAIL = "pay@tangem.com" +internal const val PAERA_SUPPORT_EMAIL = "support@paera.com" \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 234366b079..baac6c4f75 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -15,11 +15,26 @@ data class MainScreenCustomerInfo( ) data class CustomerInfo( + val customerId: String?, val productInstance: ProductInstance?, - val isKycApproved: Boolean, + val kycStatus: KycStatus, val cardInfo: CardInfo?, ) { + enum class KycStatus { + /** Initial state */ + INIT, + + /** Performing the check */ + PENDING, + + /** SumSub approved */ + APPROVED, + + /** The check failed, documents rejected */ + REJECTED, + } + data class ProductInstance( val id: String, val cardId: String, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt index 6cf5c28a6f..33ddcff4c8 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt @@ -128,7 +128,7 @@ class TangemPayMainScreenCustomerInfoUseCase( } .map { customerInfo -> Timber.tag(TAG).i("customerInfo") - if (customerInfo.cardInfo == null && customerInfo.isKycApproved) { + if (customerInfo.cardInfo == null && customerInfo.kycStatus == CustomerInfo.KycStatus.APPROVED) { // If order id wasn't saved -> start order creation and get customer info onboardingRepository.createOrder(userWalletId) } @@ -151,7 +151,12 @@ class TangemPayMainScreenCustomerInfoUseCase( OrderStatus.NEW, OrderStatus.PROCESSING, -> MainScreenCustomerInfo( - info = CustomerInfo(productInstance = null, isKycApproved = true, cardInfo = null), + info = CustomerInfo( + customerId = null, + productInstance = null, + kycStatus = CustomerInfo.KycStatus.APPROVED, + cardInfo = null, + ), orderStatus = orderStatus, ).right() diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt index 83700ab313..1c484af706 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt @@ -13,6 +13,7 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.navigation.url.UrlOpener import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayEligibilityManager +import com.tangem.domain.pay.model.CustomerInfo.KycStatus import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents @@ -95,7 +96,7 @@ internal class TangemPayOnboardingModel @Inject constructor( .onRight { customerInfo -> uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = false)) when { - !customerInfo.isKycApproved -> { + customerInfo.kycStatus != KycStatus.APPROVED -> { when (params) { is TangemPayOnboardingComponent.Params.ContinueOnboarding -> openKyc(userWalletId) else -> startOnboarding(userWalletId) @@ -171,7 +172,7 @@ internal class TangemPayOnboardingModel @Inject constructor( uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = false)) }, ifRight = { customerInfo -> - if (customerInfo.isKycApproved) { + if (customerInfo.kycStatus == KycStatus.APPROVED) { back() } else { openKyc(userWalletId) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt index a48d9edade..8d96777c9e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt @@ -23,6 +23,7 @@ import com.tangem.feature.wallet.child.wallet.model.WalletModel import com.tangem.feature.wallet.navigation.WalletRoute import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen +import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedComponent import com.tangem.feature.walletsettings.component.RenameWalletComponent import com.tangem.features.biometry.AskBiometryComponent import com.tangem.features.feed.entry.components.FeedEntryComponent @@ -126,6 +127,17 @@ internal class WalletComponent @AssistedInject constructor( ), ) } + is WalletDialogConfig.KycRejected -> { + KycRejectedComponent( + appComponentContext = childByContext(componentContext), + params = KycRejectedComponent.Params( + walletId = dialogConfig.walletId, + customerId = dialogConfig.customerId, + callbacks = model.tangemPayKycRejectedCallbacks, + onDismiss = model.innerWalletRouter.dialogNavigation::dismiss, + ), + ) + } } }, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 6759eefd27..2e9816c24a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -39,6 +39,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent.Dem import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState import com.tangem.feature.wallet.presentation.wallet.state.transformers.* import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender +import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedCallbacks import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider import com.tangem.features.biometry.AskBiometryComponent import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle @@ -107,6 +108,7 @@ internal class WalletModel @Inject constructor( ) : Model() { val askBiometryModelCallbacks = AskBiometryModelCallbacks() + val tangemPayKycRejectedCallbacks = TangemPayKycRejectedCallbacks() val askForPushNotificationsModelCallbacks = AskForPushNotificationsCallbacks() val uiState: StateFlow = stateHolder.uiState @@ -769,6 +771,20 @@ internal class WalletModel @Inject constructor( } } + inner class TangemPayKycRejectedCallbacks : KycRejectedCallbacks { + override fun onClickYourProfile(userWalletId: UserWalletId) { + clickIntents.onKycRejectedOpenProfileClicked(userWalletId) + } + + override fun onClickGoToSupport(customerId: String) { + clickIntents.onKycRejectedGoToSupportClicked(customerId) + } + + override fun onClickHideKyc(userWalletId: UserWalletId) { + clickIntents.onKycRejectedHideKycClicked(userWalletId) + } + } + inner class AskForPushNotificationsCallbacks : PushNotificationsModelCallbacks { override fun onAllowSystemPermission() { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt index e7a6f9325e..f157783abb 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.child.wallet.model.intents +import com.arkivanov.decompose.router.slot.activate import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.ui.UiMessageSender @@ -13,6 +14,7 @@ import com.tangem.core.ui.message.bottomSheetMessage import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayDetailsConfig @@ -21,6 +23,7 @@ import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig import com.tangem.feature.wallet.presentation.wallet.state.transformers.TangemPayHideOnboardingStateTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.TangemPayRefreshNeededStateTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.TangemPayRefreshShowProgressTransformer @@ -38,6 +41,14 @@ internal interface TangemPayIntents { fun onKycProgressClicked(userWalletId: UserWalletId) + fun onKycRejectedClicked(userWalletId: UserWalletId, customerId: String) + + fun onKycRejectedOpenProfileClicked(userWalletId: UserWalletId) + + fun onKycRejectedGoToSupportClicked(customerId: String) + + fun onKycRejectedHideKycClicked(userWalletId: UserWalletId) + fun onIssuingCardClicked() fun onIssuingFailedClicked() @@ -137,6 +148,42 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( uiMessageSender.send(kycInfoBottomSheet) } + override fun onKycRejectedOpenProfileClicked(userWalletId: UserWalletId) { + router.openTangemPayOnboarding( + mode = AppRoute.TangemPayOnboarding.Mode.ContinueOnboarding(userWalletId), + ) + } + + override fun onKycRejectedGoToSupportClicked(customerId: String) { + goToSupportForRejectKyc(customerId) + } + + override fun onKycRejectedHideKycClicked(userWalletId: UserWalletId) { + disableTangemPay(userWalletId) + } + + override fun onKycRejectedClicked(userWalletId: UserWalletId, customerId: String) { + router.dialogNavigation.activate( + configuration = WalletDialogConfig.KycRejected( + walletId = userWalletId, + customerId = customerId, + ), + ) + } + + private fun goToSupportForRejectKyc(customerId: String) { + modelScope.launch { + sendFeedbackEmailUseCase( + type = FeedbackEmailType.Visa.KycRejected( + walletMetaInfo = WalletMetaInfo( + userWalletId = stateHolder.getSelectedWalletId(), + ), + customerId = customerId, + ), + ) + } + } + override fun onIssuingCardClicked() { val issuingBottomSheet = bottomSheetMessage { infoBlock { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt index 1a925f467e..913e1262d2 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt @@ -4,6 +4,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.feature.wallet.DefaultWalletEntryComponent import com.tangem.feature.wallet.child.organizetokens.model.OrganizeTokensModel import com.tangem.feature.wallet.child.wallet.model.WalletModel +import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedModel import com.tangem.feature.wallet.utils.DefaultUserWalletImageFetcher import com.tangem.feature.wallet.utils.DefaultUserWalletsFetcher import com.tangem.features.wallet.WalletEntryComponent @@ -36,6 +37,11 @@ internal interface WalletFeatureModule { @ClassKey(WalletModel::class) fun bindWalletModel(model: WalletModel): Model + @Binds + @IntoMap + @ClassKey(KycRejectedModel::class) + fun bindKycRejectedModel(model: KycRejectedModel): Model + @Binds @IntoMap @ClassKey(OrganizeTokensModel::class) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt index e2df7c8cca..5e65dfb36a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt @@ -2,6 +2,7 @@ package com.tangem.feature.wallet.presentation.wallet.analytics.utils import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.MainScreenCustomerInfo import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.tangempay.TangemPayAnalyticsEvents @@ -25,8 +26,10 @@ internal class WalletTangemPayAnalyticsEventSender @Inject constructor( // TODO: TangemPay refactor analytics // when statement copied from TangemPayUpdateInfoStateTransformer. Be careful when editing val event = when { - customerInfo.orderStatus == OrderStatus.CANCELED -> return // ignore cancelled state on analytics - !customerInfo.info.isKycApproved -> return // ignore kyc not approved state on analytics + // ignore cancelled state on analytics + customerInfo.orderStatus == OrderStatus.CANCELED -> return + // ignore kyc not approved state on analytics + customerInfo.info.kycStatus != CustomerInfo.KycStatus.APPROVED -> return cardInfo != null && productInstance != null -> return else -> TangemPayAnalyticsEvents.IssuingBannerDisplayed() } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt index 4042c8b37e..a482dc420f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt @@ -32,4 +32,7 @@ internal sealed interface WalletDialogConfig { val tokenAction: TokenAction, val onWarningAcknowledged: (TokenAction) -> Unit, ) : WalletDialogConfig + + @Serializable + data class KycRejected(val walletId: UserWalletId, val customerId: String) : WalletDialogConfig } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt index b6ad2482ff..9a9ce4feee 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt @@ -16,6 +16,8 @@ import com.tangem.feature.wallet.child.wallet.model.intents.TangemPayIntents import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState.Progress import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState +import com.tangem.domain.pay.model.CustomerInfo.KycStatus.APPROVED +import com.tangem.domain.pay.model.CustomerInfo import java.util.Currency /** @@ -43,11 +45,13 @@ internal class TangemPayUpdateInfoStateTransformer( private fun createInitialState(): TangemPayState { val cardInfo = value.info.cardInfo val productInstance = value.info.productInstance + val customerId = value.info.customerId // when statement copied to WalletTangemPayAnalyticsEventSender. Be careful when editing. return when { value.orderStatus == OrderStatus.CANCELED -> createCancelledState() - !value.info.isKycApproved -> createKycInProgressState() + value.info.kycStatus != APPROVED && !customerId.isNullOrEmpty() -> + createKycInProgressState(kycStatus = value.info.kycStatus, customerId = customerId) cardInfo != null && productInstance != null -> getCardInfoState(cardInfo, productInstance) else -> createIssueProgressState() } @@ -80,13 +84,25 @@ internal class TangemPayUpdateInfoStateTransformer( } } - private fun createKycInProgressState(): TangemPayState = Progress( - title = TextReference.Res(R.string.tangempay_payment_account), - description = TextReference.Res(R.string.tangempay_kyc_in_progress), - buttonText = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_button), - iconRes = R.drawable.ic_promo_kyc_36, - onButtonClick = { tangemPayClickIntents.onKycProgressClicked(userWalletId) }, - ) + private fun createKycInProgressState(kycStatus: CustomerInfo.KycStatus, customerId: String): TangemPayState = + Progress( + title = TextReference.Res(R.string.tangempay_payment_account), + description = when (kycStatus) { + CustomerInfo.KycStatus.REJECTED -> TextReference.Res(R.string.tangempay_kyc_has_failed) + else -> TextReference.Res(R.string.tangempay_kyc_in_progress) + }, + buttonText = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_button), + iconRes = R.drawable.ic_promo_kyc_36, + onButtonClick = { + when (kycStatus) { + CustomerInfo.KycStatus.REJECTED -> tangemPayClickIntents.onKycRejectedClicked( + userWalletId = userWalletId, + customerId = customerId, + ) + else -> tangemPayClickIntents.onKycProgressClicked(userWalletId) + } + }, + ) private fun createIssueProgressState(): TangemPayState = Progress( title = TextReference.Res(R.string.tangempay_payment_account), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedComponent.kt new file mode 100644 index 0000000000..5cccb5d97a --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedComponent.kt @@ -0,0 +1,49 @@ +package com.tangem.feature.wallet.presentation.wallet.ui.components.visa + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetV2 +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.wallet.UserWalletId +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class KycRejectedComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: KycRejectedModel = getOrCreateModel(params = params) + + override fun dismiss() { + model.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.uiState.collectAsStateWithLifecycle() + MessageBottomSheetV2(state = state, onDismissRequest = ::dismiss) + } + + data class Params( + val callbacks: KycRejectedCallbacks, + val walletId: UserWalletId, + val customerId: String, + val onDismiss: () -> Unit, + ) + + @AssistedFactory + interface Factory { + fun create(appComponentContext: AppComponentContext, params: Params): KycRejectedComponent + } +} + +internal interface KycRejectedCallbacks { + fun onClickYourProfile(userWalletId: UserWalletId) + fun onClickGoToSupport(customerId: String) + fun onClickHideKyc(userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedModel.kt new file mode 100644 index 0000000000..40e010fd56 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedModel.kt @@ -0,0 +1,77 @@ +package com.tangem.feature.wallet.presentation.wallet.ui.components.visa + +import androidx.compose.runtime.Stable +import androidx.compose.ui.text.style.TextDecoration +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.res.R +import com.tangem.core.ui.components.bottomsheets.message.* +import com.tangem.core.ui.extensions.combinedReference +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.message.bottomSheetMessage +import com.tangem.core.ui.res.TangemTheme +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@Stable +@ModelScoped +internal class KycRejectedModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + private fun getInitialState(): MessageBottomSheetUMV2 { + return bottomSheetMessage { + infoBlock { + icon(com.tangem.core.ui.R.drawable.ic_heart_broken_32) { + type = MessageBottomSheetUMV2.Icon.Type.Warning + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Warning + } + title = resourceReference(R.string.tangempay_kyc_rejected) + body = combinedReference( + resourceReference(R.string.tangempay_kyc_rejected_description), + stringReference(" "), + styledResourceReference( + id = R.string.tangempay_kyc_rejected_description_span, + spanStyleReference = { + TangemTheme.typography.body2.copy(TangemTheme.colors.text.accent).toSpanStyle() + .copy(textDecoration = TextDecoration.None) + }, + onClick = { + params.callbacks.onClickYourProfile(userWalletId = params.walletId) + onDismiss() + }, + ), + ) + } + primaryButton { + text = resourceReference(R.string.tangempay_go_to_support) + onClick = { + params.callbacks.onClickGoToSupport(customerId = params.customerId) + onDismiss() + } + } + secondaryButton { + text = resourceReference(R.string.tangempay_kyc_rejected_button_text) + onClick = { + params.callbacks.onClickHideKyc(userWalletId = params.walletId) + onDismiss() + } + } + }.messageBottomSheetUMV2 + } + + fun onDismiss() { + params.onDismiss() + } +} \ No newline at end of file From 7bbab0a40bac4b1c0aaa8a636131609e483e2310 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 13:45:02 +0300 Subject: [PATCH 116/172] Updated on 2026-08-14 --- .../core/ui/DefaultDesignFeatureToggles.kt | 11 ++++ .../com/tangem/tap/di/UiDependenciesModule.kt | 3 + .../tap/di/core/ui/CoreUiBindsModule.kt | 16 +++++ app/src/main/res/values/styles.xml | 2 +- .../configs/feature_toggles_config.json | 4 ++ core/ui/build.gradle.kts | 10 +++ .../tangem/core/ui/DesignFeatureToggles.kt | 5 ++ .../java/com/tangem/core/ui/UiDependencies.kt | 2 + .../tangem/core/ui/components/haze/HazeExt.kt | 57 ++++++++++++++++ .../powersaving/PowerSavingState.kt | 66 +++++++++++++++++++ .../com/tangem/core/ui/res/TangemTheme.kt | 24 ++++++- .../tangem/core/ui/res/TangemThemeRedesign.kt | 11 +++- .../TangemPayCardDetailsBlockComponent.kt | 2 +- gradle/dependencies.toml | 3 + 14 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/core/ui/DefaultDesignFeatureToggles.kt create mode 100644 app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/DesignFeatureToggles.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/powersaving/PowerSavingState.kt diff --git a/app/src/main/java/com/tangem/tap/core/ui/DefaultDesignFeatureToggles.kt b/app/src/main/java/com/tangem/tap/core/ui/DefaultDesignFeatureToggles.kt new file mode 100644 index 0000000000..ca241efce8 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/core/ui/DefaultDesignFeatureToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.tap.core.ui + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.core.ui.DesignFeatureToggles +import javax.inject.Inject + +class DefaultDesignFeatureToggles @Inject constructor( + featureTogglesManager: FeatureTogglesManager, +) : DesignFeatureToggles { + override val isRedesignEnabled: Boolean = featureTogglesManager.isFeatureEnabled("APP_REDESIGN_ENABLED") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt b/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt index de5f7ef9e3..b5b3c5ea29 100644 --- a/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt +++ b/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt @@ -4,6 +4,7 @@ import androidx.compose.material3.SnackbarHostState import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.ui.DefaultUiMessageSender import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.EventMessageHandler @@ -23,12 +24,14 @@ internal object UiDependenciesModule { fun provideUiDependencies( vibratorHapticManager: VibratorHapticManager, appThemeModeHolder: AppThemeModeHolder, + designFeatureToggles: DesignFeatureToggles, ): UiDependencies { return object : UiDependencies { override val vibratorHapticManager = vibratorHapticManager override val appThemeModeHolder = appThemeModeHolder override val globalSnackbarHostState: SnackbarHostState = SnackbarHostState() override val eventMessageHandler: EventMessageHandler = EventMessageHandler() + override val designFeatureToggles: DesignFeatureToggles = designFeatureToggles } } diff --git a/app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt b/app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt new file mode 100644 index 0000000000..b02028c5bf --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt @@ -0,0 +1,16 @@ +package com.tangem.tap.di.core.ui + +import com.tangem.core.ui.DesignFeatureToggles +import com.tangem.tap.core.ui.DefaultDesignFeatureToggles +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface CoreUiBindsModule { + + @Binds + fun bindDesignFeatureToggles(impl: DefaultDesignFeatureToggles): DesignFeatureToggles +} \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index d91dbb1dbe..6ec92245ac 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -16,7 +16,7 @@ diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 36923953bd..51984d020a 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -67,6 +67,10 @@ "name": "FEED_ENABLED", "version": "undefined" }, + { + "name": "APP_REDESIGN_ENABLED", + "version": "undefined" + }, { "name": "GASLESS_TRANSACTIONS_ENABLED", "version": "5.33.0" diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 33e81eb02f..db9bc30c60 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -61,6 +61,16 @@ dependencies { api(deps.jodatime) implementation(deps.timber) implementation(deps.markdown) + implementation(deps.haze) { + exclude(module = "activity-compose") + exclude(module = "activity") + exclude(module = "activity-ktx") + } + implementation(deps.haze.materials) { + exclude(module = "activity-compose") + exclude(module = "activity") + exclude(module = "activity-ktx") + } /** Tests */ testImplementation(deps.test.junit) diff --git a/core/ui/src/main/java/com/tangem/core/ui/DesignFeatureToggles.kt b/core/ui/src/main/java/com/tangem/core/ui/DesignFeatureToggles.kt new file mode 100644 index 0000000000..fc8cf40b5c --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/DesignFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui + +interface DesignFeatureToggles { + val isRedesignEnabled: Boolean +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt b/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt index 0db95df8d1..10c50f5742 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt @@ -16,4 +16,6 @@ interface UiDependencies { val globalSnackbarHostState: SnackbarHostState val eventMessageHandler: EventMessageHandler + + val designFeatureToggles: DesignFeatureToggles } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt b/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt new file mode 100644 index 0000000000..779a7a0b55 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt @@ -0,0 +1,57 @@ +package com.tangem.core.ui.components.haze + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.tangem.core.ui.res.LocalHazeState +import com.tangem.core.ui.res.LocalPowerSavingState +import com.tangem.core.ui.res.LocalRootBackgroundColor +import dev.chrisbanes.haze.* +import dev.chrisbanes.haze.materials.CupertinoMaterials +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +internal fun ProvideHaze(content: @Composable () -> Unit) { + val hazeState = rememberHazeState() + CompositionLocalProvider( + LocalHazeState provides hazeState, + LocalHazeStyle provides CupertinoMaterials.ultraThin(), + ) { + content() + } +} + +/** + * Applies a haze effect to the [Modifier] with consideration of global haze settings and power saving mode. + * + * @param configure A lambda to configure the [HazeEffectScope]. + * @return A [Modifier] with the configured haze effect applied. + */ +@Composable +fun Modifier.hazeEffectTangem( + style: HazeStyle = HazeStyle.Unspecified, + configure: HazeEffectScope.() -> Unit = {}, +): Modifier { + val powerSavingEnabled = LocalPowerSavingState.current.isPowerSavingModeEnabled.collectAsState() + val hazeState = LocalHazeState.current + val isGlobalBlurEnabled = hazeState.blurEnabled && !powerSavingEnabled.value + val rootBackground by LocalRootBackgroundColor.current + + return hazeEffect(hazeState, style) { + fallbackTint = HazeTint(rootBackground) + if (isGlobalBlurEnabled) { + configure() + } + blurEnabled = isGlobalBlurEnabled + } +} + +/** + * Applies a haze source to the [Modifier] using the current global haze state. + */ +@Composable +fun Modifier.hazeSourceTangem(zIndex: Float = 0f, key: Any? = null) = + this.hazeSource(LocalHazeState.current, zIndex, key) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/powersaving/PowerSavingState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/powersaving/PowerSavingState.kt new file mode 100644 index 0000000000..54a3a42e9b --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/powersaving/PowerSavingState.kt @@ -0,0 +1,66 @@ +package com.tangem.core.ui.components.powersaving + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.PowerManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class PowerSavingState( + appContext: Context, + lifecycle: androidx.lifecycle.Lifecycle, +) { + + private val powerManager = + appContext.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: error("PowerManager not available") + + val isPowerSavingModeEnabled: StateFlow + field = MutableStateFlow(powerManager.isPowerSaveMode) + + init { + val intentFilter = IntentFilter().apply { + addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) + } + + val receiver = PowerSavingModeReceiver() + + ContextCompat.registerReceiver( + appContext, + receiver, + intentFilter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + appContext.unregisterReceiver(receiver) + } + }) + } + + inner class PowerSavingModeReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) { + isPowerSavingModeEnabled.value = powerManager.isPowerSaveMode + } + } + } +} + +@Composable +internal fun rememberPowerSavingState(): PowerSavingState { + val appContext = LocalContext.current.applicationContext + val lifecycle = LocalLifecycleOwner.current.lifecycle + return remember(appContext, lifecycle) { + PowerSavingState(appContext = appContext, lifecycle = lifecycle) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index a83759bff8..829a8ff0e1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -18,6 +18,8 @@ import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.components.LocalSystemBarsIconsController import com.tangem.core.ui.components.SystemBarsIconsController import com.tangem.core.ui.components.TangemShimmer +import com.tangem.core.ui.components.powersaving.PowerSavingState +import com.tangem.core.ui.components.powersaving.rememberPowerSavingState import com.tangem.core.ui.components.text.BladeAnimation import com.tangem.core.ui.components.text.rememberBladeAnimation import com.tangem.core.ui.haptic.DefaultHapticManager @@ -28,6 +30,7 @@ import com.tangem.core.ui.windowsize.WindowSize import com.tangem.core.ui.windowsize.rememberWindowSize import com.tangem.domain.apptheme.model.AppThemeMode import com.valentinilk.shimmer.Shimmer +import dev.chrisbanes.haze.HazeState @OptIn(ExperimentalFoundationApi::class) @Composable @@ -54,7 +57,13 @@ fun TangemTheme( overrideSystemBarColors = overrideSystemBarColors, typography = typography, dimens = dimens, - content = content, + content = { + if (uiDependencies.designFeatureToggles.isRedesignEnabled) { + TangemThemeRedesign { content() } + } else { + content() + } + }, ) } @@ -114,6 +123,7 @@ fun TangemTheme( LocalWindowSize provides windowSize, LocalBladeAnimation provides rememberBladeAnimation(), LocalSystemBarsIconsController provides systemBarsIconsController, + LocalPowerSavingState provides rememberPowerSavingState(), ) { CompositionLocalProvider( LocalTangemShimmer provides TangemShimmer, @@ -403,6 +413,18 @@ val LocalBladeAnimation = staticCompositionLocalOf { error("No MainBottomSheetColor provided") } +val LocalHazeState = staticCompositionLocalOf { + error("No HazeState provided") +} + +val LocalRedesignEnabled = staticCompositionLocalOf { + false +} + +val LocalPowerSavingState = compositionLocalOf { + error("No PowerSavingState provided") +} + /** * Determines whether the dark theme should be used based on the given [AppThemeMode]. * 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 8862de2500..efa104abf2 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 @@ -2,8 +2,12 @@ package com.tangem.core.ui.res +import androidx.compose.foundation.layout.Box import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.haze.ProvideHaze +import com.tangem.core.ui.components.haze.hazeSourceTangem /** * Provides additional theming for redesigned components. @@ -21,12 +25,17 @@ fun TangemThemeRedesign(content: @Composable () -> Unit) { colorScheme = tangemColorScheme(colors = themeColors), ) { CompositionLocalProvider( + LocalRedesignEnabled provides true, LocalTangemColors provides themeColors, LocalTangemColors2 provides if (LocalIsInDarkTheme.current) darkThemeColors2() else lightThemeColors2(), LocalTangemTypography2 provides TangemTypography2(InterFamily), LocalRootBackgroundColor provides remember(rootBackgroundColor) { mutableStateOf(rootBackgroundColor) }, ) { - content() + ProvideHaze { + Box(Modifier.hazeSourceTangem(zIndex = 1f)) { + content() + } + } } } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt index 2714c0a662..9fb41015c0 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt @@ -12,7 +12,7 @@ internal interface TangemPayCardDetailsBlockComponent { val state: StateFlow @Composable - fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) + fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier) data class Params(val params: TangemPayDetailsContainerComponent.Params) } \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 6acac86f00..02b196315c 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -98,6 +98,7 @@ markdown = "0.7.2" markdownComposeView = "0.5.4" usedesk = "4.4.0" sumsub = "1.38.0" +haze = "1.7.1" # endregion Other libraries # region Tools @@ -302,4 +303,6 @@ markdown-composeview = { module = "com.github.jeziellago:compose-markdown", vers usedesk-chat-sdk = { module = "com.github.Usedesk.Android_SDK:chat-sdk", version.ref = "usedesk" } usedesk-chat-gui = { module = "com.github.Usedesk.Android_SDK:chat-gui", version.ref = "usedesk" } sumsub-sdk = { module = "com.sumsub.sns:idensic-mobile-sdk", version.ref = "sumsub" } +haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } +haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } # endregion Other From 46dd1333ff7e4da627d77d690df31ccb4a32d496 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 17:58:26 +0700 Subject: [PATCH 117/172] Updated on 2026-08-14 --- .../data/account/di/AccountDataModule.kt | 8 +- .../fetcher/DefaultWalletAccountsFetcher.kt | 5 +- .../DefaultMainAccountTokensMigration.kt | 78 +++++++ .../DefaultWalletAccountsFetcherTest.kt | 9 + .../DefaultMainAccountTokensMigrationTest.kt | 208 ++++++++++++++++++ 5 files changed, 306 insertions(+), 2 deletions(-) diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt index 42eb66d25b..10b55769a9 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -108,10 +108,16 @@ internal object AccountDataModule { @Provides @Singleton fun provideMainAccountTokensMigration( + defaultMainAccountTokensMigration: DefaultMainAccountTokensMigration, + ): MainAccountTokensMigration = defaultMainAccountTokensMigration + + @Provides + @Singleton + fun provideDefaultMainAccountTokensMigration( accountsResponseStoreFactory: AccountsResponseStoreFactory, userTokensSaver: UserTokensSaver, accountTokenMigrationStore: AccountTokenMigrationStore, - ): MainAccountTokensMigration { + ): DefaultMainAccountTokensMigration { return DefaultMainAccountTokensMigration( accountsResponseStoreFactory = accountsResponseStoreFactory, accountTokenMigrationStore = accountTokenMigrationStore, 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 8c475f3b58..bd25eb1add 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 @@ -2,6 +2,7 @@ package com.tangem.data.account.fetcher import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.account.utils.assignTokens import com.tangem.data.common.account.WalletAccountsFetcher @@ -50,6 +51,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory, private val eTagsStore: ETagsStore, private val dispatchers: CoroutineDispatcherProvider, + private val mainAccountTokensMigration: DefaultMainAccountTokensMigration, ) : WalletAccountsFetcher, WalletAccountsSaver { override suspend fun fetch(userWalletId: UserWalletId): GetWalletAccountsResponse { @@ -71,7 +73,8 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( throw fetchResult.error } - return updatedResponse + val migratedResponse = mainAccountTokensMigration.migrate(userWalletId).getOrNull() ?: updatedResponse + return migratedResponse } override suspend fun getSaved(userWalletId: UserWalletId): GetWalletAccountsResponse? { diff --git a/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt b/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt index d4e9d0325f..8e65c680ce 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt @@ -7,6 +7,7 @@ import arrow.core.raise.ensureNotNull import arrow.core.toNonEmptyListOrNull import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.data.account.converter.toDerivationIndex import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.utils.assignTokens import com.tangem.data.common.currency.UserTokensSaver @@ -37,6 +38,67 @@ internal class DefaultMainAccountTokensMigration( private val userTokensSaver: UserTokensSaver, ) : MainAccountTokensMigration { + internal suspend fun migrate(userWalletId: UserWalletId): Either = either { + val store = accountsResponseStoreFactory.create(userWalletId) + val response = store.getSyncOrNull() + ensureNotNull(response) { + val exception = IllegalStateException("No cached accounts response found") + Timber.e(exception) + exception + } + val mainAccount = findAccount(response = response, derivationIndex = DerivationIndex.Main) + val notMainAccounts = response.accounts + .filterNot { accountDTO -> accountDTO.derivationIndex.toDerivationIndex().isMain } + + if (notMainAccounts.isEmpty()) { + Timber.i("There is only the Main account. Nothing to migrate") + return@either response + } + + val unassignedTokens = mainAccount.groupUnassignedTokens() + + if (unassignedTokens.isEmpty()) { + Timber.i("No unassigned tokens found for migration") + return@either response + } + + var updatedMainAccount = mainAccount + val assignedTokensAccounts = notMainAccounts.mapNotNull { accountDTO -> + val derivationIndex = accountDTO.derivationIndex.toDerivationIndex() + val tokensForAccount = unassignedTokens[derivationIndex] + if (tokensForAccount.isNullOrEmpty()) return@mapNotNull null + updatedMainAccount = updatedMainAccount.copy( + tokens = updatedMainAccount.tokens.orEmpty() - tokensForAccount, + ) + accountDTO.assignTokens(userWalletId, tokensForAccount) + } + + val updatedResponse = response.copy( + accounts = response.accounts.map { account -> + val assignAccount = assignedTokensAccounts.find { it.id == account.id } + when { + account.id == mainAccount.id -> updatedMainAccount + assignAccount != null -> assignAccount + else -> account + } + }, + ) + + store.updateData { updatedResponse } + + val userTokensResponse = updatedResponse.toUserTokensResponse() + userTokensSaver.pushWithRetryer( + userWalletId = userWalletId, + response = userTokensResponse, + onFailSend = { + val exception = IllegalStateException("Failed to push updated tokens after migration") + Timber.e(exception) + raise(exception) + }, + ) + return@either updatedResponse + } + override suspend fun migrate( userWalletId: UserWalletId, derivationIndex: DerivationIndex, @@ -113,6 +175,22 @@ internal class DefaultMainAccountTokensMigration( } } + private fun WalletAccountDTO.groupUnassignedTokens(): Map> = + this.tokens + .orEmpty() + .mapNotNull { token -> + val blockchain = Blockchain.fromNetworkId(token.networkId) ?: return@mapNotNull null + val derivationPath = token.derivationPath ?: return@mapNotNull null + val accountNode = AccountNodeRecognizer(blockchain) + .recognize(derivationPath) + ?: return@mapNotNull null + + if (accountNode == DerivationIndex.Main.value.toLong()) return@mapNotNull null + val derivationIndex = DerivationIndex(accountNode.toInt()).getOrNull() ?: return@mapNotNull null + derivationIndex to token.copy(accountId = null) + } + .groupBy({ it.first }, { it.second }) + private fun WalletAccountDTO.findUnassignedTokens( derivationIndex: DerivationIndex, ): List? { diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt index 1bfbb38da1..4f358b2d09 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt @@ -1,11 +1,13 @@ package com.tangem.data.account.fetcher +import arrow.core.right import com.google.common.truth.Truth import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.converter.createWalletAccountDTO import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher.FetchResult import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.UserTokensSaver @@ -35,6 +37,7 @@ class DefaultWalletAccountsFetcherTest { private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() private val accountsResponseStore: AccountsResponseStore = mockk() private val accountsResponseStoreFlow = MutableStateFlow(value = null) + private val tokensMigration: DefaultMainAccountTokensMigration = mockk(relaxed = true) private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler = mockk(relaxUnitFun = true) @@ -49,16 +52,19 @@ class DefaultWalletAccountsFetcherTest { defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory, eTagsStore = eTagsStore, dispatchers = TestingCoroutineDispatcherProvider(), + mainAccountTokensMigration = tokensMigration, ) private val userWalletId = UserWalletId("011") private val eTag = "etag" + private val migratedAccountsResponse = createGetWalletAccountsResponse(userWalletId) @BeforeAll fun setUp() { every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore every { accountsResponseStore.data } returns accountsResponseStoreFlow + coEvery { tokensMigration.migrate(userWalletId) } returns migratedAccountsResponse.right() coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag } @@ -134,6 +140,7 @@ class DefaultWalletAccountsFetcherTest { accountsResponseStore.updateData(any()) accountsResponseStoreFactory.create(userWalletId = userWalletId) accountsResponseStore.updateData(any()) + tokensMigration.migrate(userWalletId) } coVerify(inverse = true) { @@ -181,6 +188,7 @@ class DefaultWalletAccountsFetcherTest { eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = newETag) accountsResponseStoreFactory.create(userWalletId = userWalletId) accountsResponseStore.updateData(any()) + tokensMigration.migrate(userWalletId) } coVerify(inverse = true) { @@ -236,6 +244,7 @@ class DefaultWalletAccountsFetcherTest { pushWalletAccounts = any(), storeWalletAccounts = any(), ) + tokensMigration.migrate(userWalletId) } coVerify(inverse = true) { diff --git a/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt b/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt index 025b7d07bd..e4d0df9606 100644 --- a/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt @@ -1,5 +1,6 @@ package com.tangem.data.account.token +import arrow.core.right import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.converter.createWalletAccountDTO import com.tangem.data.account.store.AccountsResponseStore @@ -13,6 +14,7 @@ import com.tangem.datasource.local.accounts.AccountTokenMigrationStore import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.assertEither import com.tangem.test.core.assertEitherLeft import com.tangem.test.core.assertEitherRight import io.mockk.* @@ -214,6 +216,212 @@ class DefaultMainAccountTokensMigrationTest { } } + @Test + fun `migrate updates tokens for all accounts`() = runTest { + // Arrange + val unassignedToken1 = createBitcoin(accountIndex = 1) + val unassignedToken2 = createBitcoin(accountIndex = 2) + + val derivationIndex1 = DerivationIndex(1).getOrNull()!! + val derivationIndex2 = DerivationIndex(2).getOrNull()!! + + val mainAccount = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value, + derivationIndex = DerivationIndex.Main.value, + tokens = listOf( + createBitcoin(accountIndex = 0), + unassignedToken1, + unassignedToken2, + ), + ) + + val account1 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex1).value, + derivationIndex = derivationIndex1.value, + tokens = emptyList(), + ) + + val account2 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex2).value, + derivationIndex = derivationIndex2.value, + tokens = emptyList(), + ) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 3, + totalArchivedAccounts = 0, + ), + accounts = listOf(mainAccount, account1, account2), + unassignedTokens = emptyList(), + ) + + accountsResponseStoreFlow.value = response + + coEvery { accountsResponseStore.updateData(any()) } returns mockk() + + // Act + val actual = migration.migrate(userWalletId) + + val migratedResponse = response.copy( + accounts = listOf( + mainAccount.copy(tokens = mainAccount.tokens!! - unassignedToken1 - unassignedToken2), + account1.copy(tokens = listOf(unassignedToken1)), + account2.copy(tokens = listOf(unassignedToken2)), + ), + ) + + // Assert + assertEither(actual, migratedResponse.right()) + + coVerifySequence { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + accountsResponseStore.updateData(any()) + userTokensSaver.pushWithRetryer( + userWalletId = userWalletId, + response = migratedResponse.toUserTokensResponse(), + onFailSend = any(), + ) + } + } + + @Test + fun `migrate updates tokens only for one account`() = runTest { + // Arrange + val unassignedToken1 = createBitcoin(accountIndex = 1) + + val derivationIndex1 = DerivationIndex(1).getOrNull()!! + val derivationIndex2 = DerivationIndex(2).getOrNull()!! + + val mainAccount = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value, + derivationIndex = DerivationIndex.Main.value, + tokens = listOf( + createBitcoin(accountIndex = 0), + unassignedToken1, + ), + ) + + val account1 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex1).value, + derivationIndex = derivationIndex1.value, + tokens = emptyList(), + ) + + val account2 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex2).value, + derivationIndex = derivationIndex2.value, + tokens = emptyList(), + ) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 3, + totalArchivedAccounts = 0, + ), + accounts = listOf(mainAccount, account1, account2), + unassignedTokens = emptyList(), + ) + + accountsResponseStoreFlow.value = response + + coEvery { accountsResponseStore.updateData(any()) } returns mockk() + + // Act + val actual = migration.migrate(userWalletId) + + val migratedResponse = response.copy( + accounts = listOf( + mainAccount.copy(tokens = mainAccount.tokens!! - unassignedToken1), + account1.copy(tokens = listOf(unassignedToken1)), + account2, + ), + ) + + // Assert + assertEither(actual, migratedResponse.right()) + + coVerifySequence { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + accountsResponseStore.updateData(any()) + userTokensSaver.pushWithRetryer( + userWalletId = userWalletId, + response = migratedResponse.toUserTokensResponse(), + onFailSend = any(), + ) + } + } + + @Test + fun `migrate all skips when no unassigned tokens`() = runTest { + // Arrange + val derivationIndex1 = DerivationIndex(1).getOrNull()!! + val derivationIndex2 = DerivationIndex(2).getOrNull()!! + + val mainAccount = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value, + derivationIndex = DerivationIndex.Main.value, + tokens = listOf( + createBitcoin(accountIndex = 0), + ), + ) + + val account1 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex1).value, + derivationIndex = derivationIndex1.value, + tokens = emptyList(), + ) + + val account2 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex2).value, + derivationIndex = derivationIndex2.value, + tokens = emptyList(), + ) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 3, + totalArchivedAccounts = 0, + ), + accounts = listOf(mainAccount, account1, account2), + unassignedTokens = emptyList(), + ) + + accountsResponseStoreFlow.value = response + + // Act + val actual = migration.migrate(userWalletId) + + // Assert + assertEither(actual, response.right()) + + coVerifySequence { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + coVerify(inverse = true) { + userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any()) + } + } + private fun createBitcoin(accountIndex: Int): UserTokensResponse.Token { return UserTokensResponse.Token( id = "ne", From 6dc6e28205d160036cc9333ec46d194f861c0264 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 12:59:42 +0200 Subject: [PATCH 118/172] Updated on 2026-08-14 --- .../domain/models/staking/YieldBalanceItem.kt | 3 +++ .../converters/YieldBalancesConverter.kt | 10 +++++++++ .../ui/StakingInitialInfoContent.kt | 6 +++++ .../TokenDetailsStakingInfoConverter.kt | 22 +++++++++++++++++++ 4 files changed, 41 insertions(+) diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/YieldBalanceItem.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/YieldBalanceItem.kt index c8dbe3b3d9..198e8a00f7 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/YieldBalanceItem.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/YieldBalanceItem.kt @@ -111,6 +111,8 @@ sealed interface RewardBlockType { data object NoRewards : RewardBlockType data object Rewards : RewardBlockType data object RewardsRequirementsError : RewardBlockType + data object EthereumEarnedRewards : RewardBlockType + sealed interface RewardUnavailable : RewardBlockType { data object DefaultRewardUnavailable : RewardUnavailable data object SolanaRewardUnavailable : RewardUnavailable @@ -124,6 +126,7 @@ sealed interface RewardBlockType { get() = when (this) { is NoRewards, is RewardUnavailable, + is EthereumEarnedRewards, -> false is RewardsRequirementsError, is Rewards, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt index 5e73aad950..7725be5eee 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt @@ -83,6 +83,16 @@ internal class YieldBalancesConverter( val blockchainId = cryptoCurrencyStatus.currency.network.rawId val isCoin = cryptoCurrencyStatus.currency.id.isCoin + val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool + if (p2pEthPoolBalance != null) { + val hasEarnedRewards = !p2pEthPoolBalance.totalRewards.isZero() + return if (hasEarnedRewards) { + RewardBlockType.EthereumEarnedRewards + } else { + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + } + } + val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit val rewards = stakeKitBalance?.balance?.items ?.filter { it.type == BalanceType.REWARDS && !it.amount.isZero() } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt index 61c54c78ea..d0604549f9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt @@ -243,6 +243,12 @@ private fun StakingRewardBlock( append(reward.rewardsCrypto.orMaskWithStars(isBalanceHidden)) } to TangemTheme.colors.text.primary1 } + RewardBlockType.EthereumEarnedRewards -> { + resourceReference( + id = R.string.staking_details_autocompound_rewards_earned, + formatArgs = wrappedList(reward.rewardsCrypto.orMaskWithStars(isBalanceHidden)), + ) to TangemTheme.colors.text.primary1 + } RewardBlockType.RewardUnavailable.DefaultRewardUnavailable -> { resourceReference(R.string.staking_details_auto_claiming_rewards_daily_text) to TangemTheme.colors.text.tertiary diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt index 81f0f9314e..ba6a44c2be 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt @@ -175,8 +175,16 @@ internal class TokenDetailsStakingInfoConverter( private fun getRewardText(status: CryptoCurrencyStatus, stakingRewardAmount: BigDecimal?): TextReference { val blockchainId = status.currency.network.rawId val isCoin = status.currency.id.isCoin + val stakingBalance = status.value.stakingBalance val rewardBlockType = when { + stakingBalance is StakingBalance.Data.P2PEthPool -> { + if (stakingBalance.totalRewards.isNullOrZero()) { + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + } else { + RewardBlockType.EthereumEarnedRewards + } + } isStakingRewardUnavailable(blockchainId, isCoin) -> { RewardBlockType.RewardUnavailable.DefaultRewardUnavailable } @@ -189,6 +197,20 @@ internal class TokenDetailsStakingInfoConverter( RewardBlockType.RewardUnavailable.DefaultRewardUnavailable, RewardBlockType.RewardUnavailable.SolanaRewardUnavailable, -> TextReference.EMPTY + RewardBlockType.EthereumEarnedRewards -> { + val cryptoRewardAmount = (stakingBalance as? StakingBalance.Data.P2PEthPool)?.totalRewards + resourceReference( + R.string.staking_details_autocompound_rewards_earned, + wrappedList( + cryptoRewardAmount.format { + crypto( + symbol = status.currency.symbol, + decimals = status.currency.decimals, + ) + }, + ), + ) + } RewardBlockType.RewardsRequirementsError, RewardBlockType.Rewards, -> resourceReference( From 3b2a867959a47305c69f4a79d39a937b46680d98 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 16:01:13 +0500 Subject: [PATCH 119/172] Updated on 2026-08-14 --- .../core/analytics/models/AnalyticsParam.kt | 2 + .../analytics/MarketsDataAnalyticsEvent.kt | 5 +- .../news/repository/DefaultNewsRepository.kt | 62 +++++++++++++------ .../domain/news/repository/NewsRepository.kt | 6 +- .../news/usecase/ObserveNewsDetailsUseCase.kt | 3 +- .../news/usecase/ToggleArticleLikedUseCase.kt | 5 +- .../analytics/MarketTokenAnalyticsEvent.kt | 47 ++++++++++++++ .../feed/analytics/FeedAnalyticsEvent.kt | 5 +- .../details/MarketsTokenDetailsModel.kt | 30 +++++++++ .../model/news/details/NewsDetailsModel.kt | 37 ++++++++++- .../analytics/NewsDetailsAnalyticsEvent.kt | 18 ++++++ .../list/analytics/NewsListAnalyticsEvent.kt | 5 +- .../components/TokenMarketDetailsBody.kt | 29 ++++++--- .../preview/MarketsTokenDetailsPreview.kt | 4 ++ .../detailed/state/MarketsTokenDetailsUM.kt | 2 + 15 files changed, 213 insertions(+), 47 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/analytics/MarketTokenAnalyticsEvent.kt diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index cf64bb4d14..f8528755c7 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -1,5 +1,7 @@ package com.tangem.core.analytics.models +const val IS_NOT_HTTP_ERROR = "Is not http error" + sealed class AnalyticsParam { sealed class CardBalanceState(val value: String) { diff --git a/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt b/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt index dc94cd9ceb..56fef028a1 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt @@ -2,6 +2,7 @@ package com.tangem.data.markets.analytics import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR sealed interface MarketsDataAnalyticsEvent { @@ -82,8 +83,4 @@ sealed interface MarketsDataAnalyticsEvent { Custom("Custom"), Unknown("Unknown"), } - - private companion object { - const val IS_NOT_HTTP_ERROR = "Is not http error" - } } \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt index 9a60bf6729..ec3ad96779 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -1,5 +1,9 @@ package com.tangem.data.news.repository +import arrow.core.Either +import arrow.core.flatten +import arrow.core.left +import arrow.core.right import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.fold @@ -90,7 +94,7 @@ internal class DefaultNewsRepository( .combine(newsLikedStore.getAll()) { articles, likedFlags -> articles.map { article -> article.copy( - isLiked = isNewsLiked(article.id), + isLiked = likedFlags[article.id] == true, ) } } @@ -99,9 +103,10 @@ internal class DefaultNewsRepository( } } - override suspend fun fetchDetailedArticles(newsIds: Collection, language: String?) { - fetchDetailedArticlesInternal(newsIds = newsIds, language = language) - } + override suspend fun fetchDetailedArticles( + newsIds: Collection, + language: String?, + ): Either, Unit> = fetchDetailedArticlesInternal(newsIds = newsIds, language = language) override suspend fun fetchTrendingNews(limit: Int, language: String?) { fetchAndStoreTrendingNews(limit = limit, language = language) @@ -145,10 +150,9 @@ internal class DefaultNewsRepository( return newsLikedStore.getSync()[articleId] == true } - override suspend fun toggleNewsLiked(articleId: Int): Boolean = withContext(dispatchers.io) { + override suspend fun toggleNewsLiked(articleId: Int) = withContext(dispatchers.io) { val isNewsLikedValue = !isNewsLiked(articleId) newsLikedStore.updateLiked(listOf(articleId), isNewsLikedValue) - isNewsLikedValue } private fun updateViewedStatusForNewsBatch( @@ -162,9 +166,12 @@ internal class DefaultNewsRepository( ) } - private suspend fun fetchDetailedArticlesInternal(newsIds: Collection, language: String?) = + private suspend fun fetchDetailedArticlesInternal( + newsIds: Collection, + language: String?, + ): Either, Unit> = Either.catch { withContext(dispatchers.io) { - if (newsIds.isEmpty()) return@withContext + if (newsIds.isEmpty()) return@withContext Unit.right() val uniqueIds = newsIds.distinct() val idsToFetch = buildList { @@ -174,26 +181,42 @@ internal class DefaultNewsRepository( } } - if (idsToFetch.isEmpty()) return@withContext + if (idsToFetch.isEmpty()) return@withContext Unit.right() val fetchedArticles = supervisorScope { idsToFetch.map { newsId -> async { - newsApi.getNewsDetails(newsId = newsId, language = language) - .getOrThrow() - .toDomainDetailedArticle( - isLiked = isNewsLiked(newsId), - ) + Either.catch { + newsApi.getNewsDetails(newsId = newsId, language = language) + .getOrThrow() + .toDomainDetailedArticle( + isLiked = isNewsLiked(newsId), + ) + }.mapLeft { + newsId to it + } } }.awaitAll() } - if (fetchedArticles.isNotEmpty()) { - newsDetailsStore.store( - articles = fetchedArticles.associateBy(DetailedArticle::id), - ) - } + fetchedArticles + .filterIsInstance>() + .map { it.value } + .let { articles -> + if (articles.isNotEmpty()) { + newsDetailsStore.store( + articles = articles.associateBy(DetailedArticle::id), + ) + } + } + + val errors = fetchedArticles + .filterIsInstance>>() + .associate { it.value.first to it.value.second } + + if (errors.isEmpty()) Unit.right() else errors.left() } + }.mapLeft { t -> mapOf(GLOBAL_ERROR_ID to t) }.flatten() private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?) { return withContext(dispatchers.io) { @@ -378,5 +401,6 @@ internal class DefaultNewsRepository( private const val INITIAL_BATCH_KEY = 0 private const val FIRST_PAGE = 1 private const val TRENDING_NEWS_KEY = "trending_news" + private const val GLOBAL_ERROR_ID = -1 } } \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt index cf906001f2..44266d3935 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt @@ -1,5 +1,6 @@ package com.tangem.domain.news.repository +import arrow.core.Either import com.tangem.domain.models.news.ArticleCategory import com.tangem.domain.models.news.DetailedArticle import com.tangem.domain.models.news.ShortArticle @@ -37,7 +38,7 @@ interface NewsRepository { /** * Fetches and caches detailed articles for provided ids in parallel. */ - suspend fun fetchDetailedArticles(newsIds: Collection, language: String?) + suspend fun fetchDetailedArticles(newsIds: Collection, language: String?): Either, Unit> /** * Fetch list of trending news by limit and with correct locale and store it in runtime data store. @@ -64,7 +65,6 @@ interface NewsRepository { /** * Toggle news liked state - * @return an actual liked state of the article */ - suspend fun toggleNewsLiked(articleId: Int): Boolean + suspend fun toggleNewsLiked(articleId: Int) } \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt index 373456dce2..06d9678f90 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt @@ -24,7 +24,6 @@ class ObserveNewsDetailsUseCase( /** * Prefetches the given article ids (can be called with current + next ids for pager preloading). */ - suspend fun prefetch(newsIds: Collection, language: String?): Either = Either.catch { + suspend fun prefetch(newsIds: Collection, language: String?): Either, Unit> = repository.fetchDetailedArticles(newsIds, language) - } } \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ToggleArticleLikedUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ToggleArticleLikedUseCase.kt index 90cac29915..dfccde2c5b 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/ToggleArticleLikedUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ToggleArticleLikedUseCase.kt @@ -1,5 +1,6 @@ package com.tangem.domain.news.usecase +import arrow.core.Either import com.tangem.domain.news.repository.NewsRepository class ToggleArticleLikedUseCase(private val repository: NewsRepository) { @@ -7,7 +8,7 @@ class ToggleArticleLikedUseCase(private val repository: NewsRepository) { /** * Toggle an article liked state. */ - suspend fun toggleLiked(articleId: Int): Boolean { - return repository.toggleNewsLiked(articleId) + suspend fun toggleLiked(articleId: Int) = Either.catch { + repository.toggleNewsLiked(articleId) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/analytics/MarketTokenAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/analytics/MarketTokenAnalyticsEvent.kt new file mode 100644 index 0000000000..255e31fb5b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/analytics/MarketTokenAnalyticsEvent.kt @@ -0,0 +1,47 @@ +package com.tangem.features.feed.components.market.details.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE +import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR +import com.tangem.core.analytics.models.OneTimePerSessionEvent + +internal sealed class MarketTokenAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "CoinPage", event = event, params = params) { + + data class TokenNewsViewed( + private val token: String, + ) : MarketTokenAnalyticsEvent( + event = "Token News Viewed", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + ), + ), OneTimePerSessionEvent { + override val oneTimeEventId: String = event + token + } + + data class TokenNewsCarouselScrolled( + private val token: String, + ) : MarketTokenAnalyticsEvent( + event = "Token News Carousel Scrolled", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + ), + ), OneTimePerSessionEvent { + override val oneTimeEventId: String = event + token + } + + data class TokenNewsLoadError( + private val code: Int?, + private val message: String, + ) : MarketTokenAnalyticsEvent( + event = "Token News Load Error", + params = mapOf( + ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(), + ERROR_MESSAGE to message, + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt index cd869f6455..7c9792e701 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt @@ -5,6 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE +import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR import com.tangem.core.analytics.models.OneTimePerSessionEvent internal sealed class FeedAnalyticsEvent( @@ -89,8 +90,4 @@ internal sealed class FeedAnalyticsEvent( ) class TokenSearchedClicked : FeedAnalyticsEvent(event = "Token Searched Clicked") - - private companion object { - const val IS_NOT_HTTP_ERROR = "Is not http error" - } } \ No newline at end of file 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 2750d9d6c3..f1c5bb2319 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 @@ -22,6 +22,7 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.core.ui.format.bigdecimal.price +import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains @@ -36,6 +37,7 @@ import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent +import com.tangem.features.feed.components.market.details.analytics.MarketTokenAnalyticsEvent import com.tangem.features.feed.impl.R import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.feed.model.market.details.converter.DescriptionConverter @@ -242,6 +244,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( relatedNews = MarketsTokenDetailsUM.RelatedNews( articles = persistentListOf(), onArticledClicked = {}, + onFirstVisible = {}, + onScroll = {}, ), ), ) @@ -322,10 +326,36 @@ internal class MarketsTokenDetailsModel @Inject constructor( /* preselectedIds */ relatedNews.map { it.id }, ) }, + onFirstVisible = { + analyticsEventHandler.send( + MarketTokenAnalyticsEvent.TokenNewsViewed( + token = params.token.symbol, + ), + ) + }, + onScroll = { + analyticsEventHandler.send( + MarketTokenAnalyticsEvent.TokenNewsCarouselScrolled( + token = params.token.symbol, + ), + ) + }, ), ) } } + }.onLeft { throwable -> + val (code, message) = if (throwable is ApiResponseError.HttpException) { + throwable.code.numericCode to throwable.message.orEmpty() + } else { + null to "" + } + analyticsEventHandler.send( + MarketTokenAnalyticsEvent.TokenNewsLoadError( + code = code, + message = message, + ), + ) } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt index 5dba83b983..91f9fa30d2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt @@ -3,11 +3,13 @@ package com.tangem.features.feed.model.news.details import androidx.compose.runtime.Stable import arrow.core.getOrElse import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener +import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.GetTokenMarketInfoUseCase @@ -33,6 +35,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import timber.log.Timber import java.util.Locale import javax.inject.Inject @@ -129,7 +132,9 @@ internal class NewsDetailsModel @Inject constructor( private fun onLikeClick(articleId: Int) { modelScope.launch { - toggleArticleLikedUseCase.toggleLiked(articleId) + toggleArticleLikedUseCase + .toggleLiked(articleId) + .onLeft { Timber.e(it) } analyticsEventHandler.send(NewsDetailsAnalyticsEvent.NewsLikeClicked(articleId)) } } @@ -202,7 +207,35 @@ internal class NewsDetailsModel @Inject constructor( observeNewsDetailsUseCase.prefetch( newsIds = params.preselectedArticlesId, language = currentLanguage, - ).mapLeft { + ).onLeft { errors -> + errors.onEach { (newsId, throwable) -> + when { + // an article is opened from deeplink and is not found + params.screenSource == AnalyticsParam.ScreensSources.NewsLink.value && + newsId == params.articleId && + throwable is ApiResponseError.HttpException && + throwable.code == ApiResponseError.HttpException.Code.NOT_FOUND + -> { + analyticsEventHandler.send( + NewsDetailsAnalyticsEvent.NewsLinkMismatch( + newsId = params.articleId, + ), + ) + } + // global request executing error + newsId < 0 -> { + Timber.e(throwable) + } + else -> { + analyticsEventHandler.send( + NewsDetailsAnalyticsEvent.NewsArticleLoadError( + newsId = newsId, + ), + ) + } + } + } + }.mapLeft { stateFactory.createErrorState() } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/analytics/NewsDetailsAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/analytics/NewsDetailsAnalyticsEvent.kt index 73b92d193e..128235303e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/analytics/NewsDetailsAnalyticsEvent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/analytics/NewsDetailsAnalyticsEvent.kt @@ -41,4 +41,22 @@ internal sealed class NewsDetailsAnalyticsEvent( ), OneTimePerSessionEvent { override val oneTimeEventId: String = event + newsId } + + data class NewsArticleLoadError( + private val newsId: Int, + ) : NewsDetailsAnalyticsEvent( + event = "News Article Load Error", + params = mapOf( + "News Id" to newsId.toString(), + ), + ) + + data class NewsLinkMismatch( + private val newsId: Int, + ) : NewsDetailsAnalyticsEvent( + event = "News Link Mismatch", + params = mapOf( + "News Id" to newsId.toString(), + ), + ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/analytics/NewsListAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/analytics/NewsListAnalyticsEvent.kt index efb0ae3ea6..407b1d06b4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/analytics/NewsListAnalyticsEvent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/analytics/NewsListAnalyticsEvent.kt @@ -3,6 +3,7 @@ package com.tangem.features.feed.model.news.list.analytics import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE +import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR internal sealed class NewsListAnalyticsEvent( event: String, @@ -28,8 +29,4 @@ internal sealed class NewsListAnalyticsEvent( "Selected Categories" to categoryId.toString(), ), ) - - private companion object { - const val IS_NOT_HTTP_ERROR = "Is not http error" - } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt index 764fcff180..434ba73d53 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -3,7 +3,7 @@ package com.tangem.features.feed.ui.market.detailed.components import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -11,9 +11,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onFirstVisible import androidx.compose.ui.unit.dp import com.tangem.common.ui.news.ArticleCard -import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.items.DescriptionItem @@ -24,6 +24,8 @@ import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM.RelatedNews +private const val FOURTH_ITEM_INDEX = 3 + @Suppress("CanBeNonNullable") // TODO will be removed after [REDACTED_JIRA] internal fun LazyListScope.tokenMarketDetailsBody( state: MarketsTokenDetailsUM.Body, @@ -215,7 +217,11 @@ private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { Column( modifier = Modifier .fillMaxWidth() - .padding(bottom = 32.dp, top = 20.dp), + .padding(bottom = 32.dp, top = 20.dp) + .onFirstVisible( + minFractionVisible = 0.5f, + callback = relatedNews.onFirstVisible, + ), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( @@ -231,14 +237,23 @@ private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { horizontalArrangement = Arrangement.spacedBy(12.dp), state = listState, ) { - items( + itemsIndexed( items = relatedNews.articles, - key = ArticleConfigUM::id, - ) { article -> + key = { index, article -> article.id }, + ) { index, article -> + val articleModifier = if (index == FOURTH_ITEM_INDEX) { + Modifier.onFirstVisible( + minFractionVisible = 0.5f, + callback = relatedNews.onScroll, + ) + } else { + Modifier + } + ArticleCard( articleConfigUM = article, onArticleClick = { relatedNews.onArticledClicked(article.id) }, - modifier = Modifier + modifier = articleModifier .height(164.dp) .width(216.dp), colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), 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 964ccd4dad..2c578fbe68 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 @@ -46,6 +46,8 @@ internal object MarketsTokenDetailsPreview { relatedNews = MarketsTokenDetailsUM.RelatedNews( articles = persistentListOf(), onArticledClicked = {}, + onFirstVisible = {}, + onScroll = {}, ), ) @@ -136,6 +138,8 @@ internal object MarketsTokenDetailsPreview { relatedNews = MarketsTokenDetailsUM.RelatedNews( articles = persistentListOf(), onArticledClicked = {}, + onFirstVisible = {}, + onScroll = {}, ), ) } \ 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 a1155099a5..e150d95894 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 @@ -76,5 +76,7 @@ internal data class MarketsTokenDetailsUM( data class RelatedNews( val articles: ImmutableList, val onArticledClicked: (id: Int) -> Unit, + val onFirstVisible: () -> Unit, + val onScroll: () -> Unit, ) } \ No newline at end of file From cf0659d20728af1b131b83966958e269c362fba1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 17:49:10 +0400 Subject: [PATCH 120/172] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 24 +- .../com/tangem/common/routing/AppRoute.kt | 11 +- .../supply/DefaultYieldSupplyRepository.kt | 72 ++---- .../supply/models/YieldSupplyEnterStatus.kt | 6 - .../supply/models/YieldSupplyPendingStatus.kt | 6 + .../yield/supply/YieldSupplyRepository.kt | 39 ++- .../usecase/YieldSupplyEnterStatusUseCase.kt | 23 +- .../YieldSupplyEnterStatusUseCaseTest.kt | 242 ++++++++++++++++++ .../impl/model/TokenActionsHandler.kt | 45 +--- .../impl/model/TokenActionsHandler.kt | 45 +--- .../intents/WalletContentClickIntents.kt | 35 +-- .../router/DefaultWalletRouter.kt | 14 +- .../presentation/router/InnerWalletRouter.kt | 7 +- .../supply/api/YieldSupplyEntryComponent.kt | 17 ++ .../supply/api/entry/YieldSupplyEntryRoute.kt | 24 ++ .../entry/DefaultYieldSupplyEntryComponent.kt | 124 +++++++++ .../impl/entry/di/YieldSupplyEntryModule.kt | 34 +++ .../impl/entry/model/YieldSupplyEntryModel.kt | 90 +++++++ .../impl/main/model/YieldSupplyModel.kt | 97 ++----- .../model/YieldSupplyStartEarningModel.kt | 19 +- .../model/YieldSupplyStopEarningModel.kt | 16 +- gradle/tangem_dependencies.toml | 2 +- .../WalletManagerFactoryCreator.kt | 3 + 23 files changed, 679 insertions(+), 316 deletions(-) delete mode 100644 domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldSupplyEnterStatus.kt create mode 100644 domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldSupplyPendingStatus.kt create mode 100644 domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt create mode 100644 features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyEntryComponent.kt create mode 100644 features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/entry/YieldSupplyEntryRoute.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/DefaultYieldSupplyEntryComponent.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/di/YieldSupplyEntryModule.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 83d4c9f04b..f45812a062 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -45,8 +45,7 @@ import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Par import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.wallet.WalletEntryComponent import com.tangem.features.walletconnect.components.WalletConnectEntryComponent -import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent -import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent +import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent import com.tangem.tap.features.details.ui.appcurrency.api.AppCurrencySelectorComponent import com.tangem.tap.features.details.ui.appsettings.api.AppSettingsComponent import com.tangem.tap.features.details.ui.cardsettings.api.CardSettingsComponent @@ -118,8 +117,7 @@ internal class ChildFactory @Inject constructor( private val tangemPayDetailsContainerComponentFactory: TangemPayDetailsContainerComponent.Factory, private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, private val kycComponentFactory: KycComponent.Factory, - private val yieldSupplyPromoComponentFactory: YieldSupplyPromoComponent.Factory, - private val yieldSupplyActiveComponentFactory: YieldSupplyActiveComponent.Factory, + private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory, private val hotWalletFeatureToggles: HotWalletFeatureToggles, private val feedEntryComponentFactory: FeedEntryComponent.Factory, private val feedFeatureToggle: FeedFeatureToggle, @@ -706,25 +704,15 @@ internal class ChildFactory @Inject constructor( componentFactory = kycComponentFactory, ) } - is AppRoute.YieldSupplyPromo -> { + is AppRoute.YieldSupplyEntry -> { createComponentChild( context = context, - params = YieldSupplyPromoComponent.Params( - userWalletId = route.userWalletId, - currency = route.cryptoCurrency, - apy = route.apy, - ), - componentFactory = yieldSupplyPromoComponentFactory, - ) - } - is AppRoute.YieldSupplyActive -> { - createComponentChild( - context = context, - params = YieldSupplyActiveComponent.Params( + params = YieldSupplyEntryComponent.Params( userWalletId = route.userWalletId, cryptoCurrency = route.cryptoCurrency, + apy = route.apy, ), - componentFactory = yieldSupplyActiveComponentFactory, + componentFactory = yieldSupplyEntryComponentFactory, ) } is AppRoute.NewsDetails -> { diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index ae5ccdd4d1..f70bb78a58 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -456,18 +456,11 @@ sealed class AppRoute(val path: String) : Route { data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc") @Serializable - data class YieldSupplyPromo( + data class YieldSupplyEntry( val userWalletId: UserWalletId, val cryptoCurrency: CryptoCurrency, val apy: String, - ) : AppRoute(path = "/yield_supply_promo/${userWalletId.stringValue}/${cryptoCurrency.symbol}") - - @Serializable - data class YieldSupplyActive( - val userWalletId: UserWalletId, - val cryptoCurrency: CryptoCurrency, - val apy: String, - ) : AppRoute(path = "/yield_supply_active/${userWalletId.stringValue}/${cryptoCurrency.symbol}") + ) : AppRoute(path = "/yield_supply_entry/${userWalletId.stringValue}/${cryptoCurrency.symbol}") @Serializable data class NewsDetails(val newsId: Int) : AppRoute(path = "/news_details/$newsId") diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt index d7faa6176a..a2f8f3700f 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt @@ -1,6 +1,7 @@ package com.tangem.data.yield.supply import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.TransactionStatus import com.tangem.blockchain.yieldsupply.YieldSupplyProvider import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.toBlockchain @@ -19,19 +20,16 @@ import com.tangem.datasource.local.preferences.utils.store import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.models.YieldMarketToken -import com.tangem.domain.yield.supply.models.YieldSupplyEnterStatus +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.runSuspendCatching import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -import timber.log.Timber import java.util.concurrent.ConcurrentHashMap internal class DefaultYieldSupplyRepository( @@ -43,7 +41,7 @@ internal class DefaultYieldSupplyRepository( private val appPreferencesStore: AppPreferencesStore, ) : YieldSupplyRepository { - private val statusMap: MutableMap = ConcurrentHashMap() + private val statusMap: MutableMap = ConcurrentHashMap() override suspend fun getCachedMarkets(): List? = withContext(dispatchers.io) { val cache = store.getSyncOrNull().orEmpty() @@ -126,23 +124,39 @@ internal class DefaultYieldSupplyRepository( ).getOrThrow().isActive } - override suspend fun saveTokenProtocolStatus( + override suspend fun saveTokenProtocolPendingStatus( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, - yieldSupplyEnterStatus: YieldSupplyEnterStatus?, + yieldSupplyPendingStatus: YieldSupplyPendingStatus?, ) { val key = getTokenProtocolStatusKey(userWalletId, cryptoCurrency) - if (yieldSupplyEnterStatus != null) { - statusMap[key] = yieldSupplyEnterStatus + if (yieldSupplyPendingStatus != null) { + statusMap[key] = yieldSupplyPendingStatus } else { statusMap.remove(key) } } - override fun getTokenProtocolStatus( + override suspend fun getPendingTxHashes( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): List { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + network = cryptoCurrencyStatus.currency.network, + ) ?: return emptyList() + + return walletManager.wallet.recentTransactions + .filter { it.status == TransactionStatus.Unconfirmed } + .map { + it.hash.orEmpty() + } + } + + override fun getTokenProtocolPendingStatus( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, - ): YieldSupplyEnterStatus? { + ): YieldSupplyPendingStatus? { return statusMap[getTokenProtocolStatusKey(userWalletId, cryptoCurrency)] } @@ -153,32 +167,6 @@ internal class DefaultYieldSupplyRepository( } } - override suspend fun getTokenPendingStatus( - userWalletId: UserWalletId, - cryptoCurrencyStatus: CryptoCurrencyStatus, - ): YieldSupplyEnterStatus? = runSuspendCatching { - val cryptoCurrency = cryptoCurrencyStatus.currency - val walletManager = walletManagersFacade.getOrCreateWalletManager( - userWalletId = userWalletId, - blockchain = cryptoCurrency.network.toBlockchain(), - derivationPath = cryptoCurrency.network.derivationPath.value, - ) ?: error("Wallet manager not found") - - val pendingTxs = cryptoCurrencyStatus.value.pendingTransactions - - val yieldAddress = walletManager.calculateYieldModuleAddress() - val hasRecentYieldEnterTxs = pendingTxs.hasYieldEnterTransactions(yieldAddress) - val hasRecentYieldExitTxs = pendingTxs.hasYieldExitTransactions() - - when { - hasRecentYieldEnterTxs -> YieldSupplyEnterStatus.Enter - hasRecentYieldExitTxs -> YieldSupplyEnterStatus.Exit - else -> null - } - }.onFailure { exception -> - Timber.w(exception, "Failed to get pending yield supply status") - }.getOrNull() - override fun getShouldShowYieldPromoBanner(): Flow { return appPreferencesStore.get(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, true) } @@ -187,16 +175,6 @@ internal class DefaultYieldSupplyRepository( appPreferencesStore.store(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, shouldShow) } - private fun Set.hasYieldEnterTransactions(yieldAddress: String) = any { - it.type == TxInfo.TransactionType.YieldSupply.Enter || - it.type == TxInfo.TransactionType.Approve && - (it.interactionAddressType as? TxInfo.InteractionAddressType.Contract)?.address == yieldAddress - } - - private fun Set.hasYieldExitTransactions() = any { - it.type == TxInfo.TransactionType.YieldSupply.Exit - } - private fun getTokenProtocolStatusKey(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String = "${userWalletId}_${cryptoCurrency.id.value}" } \ No newline at end of file diff --git a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldSupplyEnterStatus.kt b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldSupplyEnterStatus.kt deleted file mode 100644 index 4ecdb12732..0000000000 --- a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldSupplyEnterStatus.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.domain.yield.supply.models - -enum class YieldSupplyEnterStatus { - Enter, - Exit, -} \ No newline at end of file diff --git a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldSupplyPendingStatus.kt b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldSupplyPendingStatus.kt new file mode 100644 index 0000000000..ec24a8c5d7 --- /dev/null +++ b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldSupplyPendingStatus.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.yield.supply.models + +sealed class YieldSupplyPendingStatus(open val txIds: List) { + data class Enter(override val txIds: List) : YieldSupplyPendingStatus(txIds) + data class Exit(override val txIds: List) : YieldSupplyPendingStatus(txIds) +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt index 9a73322ca8..7e371b0a14 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt @@ -4,7 +4,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.yield.supply.models.YieldMarketToken -import com.tangem.domain.yield.supply.models.YieldSupplyEnterStatus +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData import kotlinx.coroutines.flow.Flow @@ -72,14 +72,26 @@ interface YieldSupplyRepository { * * @param userWalletId the wallet the action was performed with * @param cryptoCurrency the currency or token the action relates to - * @param yieldSupplyEnterStatus the last action intent: [YieldSupplyEnterStatus.Enter] or [YieldSupplyEnterStatus.Exit] + * @param yieldSupplyPendingStatus the last action intent: [YieldSupplyPendingStatus.Enter] or [YieldSupplyPendingStatus.Exit] */ - suspend fun saveTokenProtocolStatus( + suspend fun saveTokenProtocolPendingStatus( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, - yieldSupplyEnterStatus: YieldSupplyEnterStatus?, + yieldSupplyPendingStatus: YieldSupplyPendingStatus?, ) + /** + * Retrieve the hashes of pending (unconfirmed) transactions for the given wallet and currency. + * + * @param userWalletId the wallet to query + * @param cryptoCurrencyStatus the currency status containing network information + * @return list of transaction hashes that are still unconfirmed, or an empty list if none exist + */ + suspend fun getPendingTxHashes( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): List + /** * Get the last saved user‑initiated yield protocol action for the given wallet and currency, if any. * @@ -91,23 +103,10 @@ interface YieldSupplyRepository { * @param cryptoCurrency the currency or token to query * @return the last action intent or null if nothing has been recorded */ - fun getTokenProtocolStatus(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): YieldSupplyEnterStatus? - - /** - * Get the pending status of the yield protocol action for the given wallet and currency, if any. - * - * Used to determine whether the UI should display an intermediate "processing" state - * until the protocol status retrieved from backend reflects the change. The value is - * transient (in‑memory only) and is not persisted across app restarts. - * - * @param userWalletId the wallet to query - * @param cryptoCurrencyStatus the currency status to query - * @return the pending action intent or null if nothing has been recorded - */ - suspend fun getTokenPendingStatus( + fun getTokenProtocolPendingStatus( userWalletId: UserWalletId, - cryptoCurrencyStatus: CryptoCurrencyStatus, - ): YieldSupplyEnterStatus? + cryptoCurrency: CryptoCurrency, + ): YieldSupplyPendingStatus? fun getShouldShowYieldPromoBanner(): Flow diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCase.kt index 35de1341bb..726f1cd3df 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCase.kt @@ -4,21 +4,36 @@ import arrow.core.Either import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.yield.supply.YieldSupplyRepository -import com.tangem.domain.yield.supply.models.YieldSupplyEnterStatus +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus class YieldSupplyEnterStatusUseCase( private val yieldSupplyRepository: YieldSupplyRepository, ) { - operator fun invoke( + suspend operator fun invoke( userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, - ): Either { + ): Either { return Either.catch { - yieldSupplyRepository.getTokenProtocolStatus( + val status = yieldSupplyRepository.getTokenProtocolPendingStatus( userWalletId, cryptoCurrencyStatus.currency, ) + val pendingTxHashes = yieldSupplyRepository + .getPendingTxHashes(userWalletId, cryptoCurrencyStatus) + .toSet() + val hasPendingTx = status?.txIds?.any { it in pendingTxHashes } == true + + if (hasPendingTx) { + status + } else { + yieldSupplyRepository.saveTokenProtocolPendingStatus( + userWalletId, + cryptoCurrencyStatus.currency, + null, + ) + null + } } } } \ No newline at end of file diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt new file mode 100644 index 0000000000..54955b5312 --- /dev/null +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt @@ -0,0 +1,242 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.google.common.truth.Truth.assertThat +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.wallet.UserWalletId +import com.tangem.domain.yield.supply.YieldSupplyRepository +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +class YieldSupplyEnterStatusUseCaseTest { + + private val yieldSupplyRepository: YieldSupplyRepository = mockk() + + private lateinit var useCase: YieldSupplyEnterStatusUseCase + + private val userWalletId = UserWalletId("abcdef012345") + + @BeforeEach + fun setUp() { + useCase = YieldSupplyEnterStatusUseCase( + yieldSupplyRepository = yieldSupplyRepository, + ) + } + + @Test + fun `GIVEN pending tx exists in status WHEN invoke THEN returns status`() = runTest { + val token = createToken() + val cryptoStatus = createStatus(token) + val pendingTxHash = "0xabc123" + val status = YieldSupplyPendingStatus.Enter(txIds = listOf(pendingTxHash)) + + coEvery { + yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) + } returns status + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus) + } returns listOf(pendingTxHash) + + val result = useCase(userWalletId, cryptoStatus) + + assertThat(result.isRight()).isTrue() + val value = (result as Either.Right).value + assertThat(value).isEqualTo(status) + } + + @Test + fun `GIVEN no pending tx matches status WHEN invoke THEN clears status and returns null`() = runTest { + val token = createToken() + val cryptoStatus = createStatus(token) + val status = YieldSupplyPendingStatus.Enter(txIds = listOf("0xabc123")) + + coEvery { + yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) + } returns status + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus) + } returns listOf("0xdifferent") + coEvery { + yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null) + } returns Unit + + val result = useCase(userWalletId, cryptoStatus) + + assertThat(result.isRight()).isTrue() + val value = (result as Either.Right).value + assertThat(value).isNull() + coVerify { yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null) } + } + + @Test + fun `GIVEN status is null WHEN invoke THEN returns null`() = runTest { + val token = createToken() + val cryptoStatus = createStatus(token) + + coEvery { + yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) + } returns null + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus) + } returns emptyList() + coEvery { + yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null) + } returns Unit + + val result = useCase(userWalletId, cryptoStatus) + + assertThat(result.isRight()).isTrue() + val value = (result as Either.Right).value + assertThat(value).isNull() + } + + @Test + fun `GIVEN empty pending tx list WHEN invoke THEN clears status and returns null`() = runTest { + val token = createToken() + val cryptoStatus = createStatus(token) + val status = YieldSupplyPendingStatus.Enter(txIds = listOf("0xabc123")) + + coEvery { + yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) + } returns status + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus) + } returns emptyList() + coEvery { + yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null) + } returns Unit + + val result = useCase(userWalletId, cryptoStatus) + + assertThat(result.isRight()).isTrue() + val value = (result as Either.Right).value + assertThat(value).isNull() + coVerify { yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null) } + } + + @Test + fun `GIVEN repository throws exception WHEN invoke THEN returns left with error`() = runTest { + val token = createToken() + val cryptoStatus = createStatus(token) + val exception = RuntimeException("Network error") + + coEvery { + yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) + } throws exception + + val result = useCase(userWalletId, cryptoStatus) + + assertThat(result.isLeft()).isTrue() + val error = (result as Either.Left).value + assertThat(error).isEqualTo(exception) + } + + @Test + fun `GIVEN exit status with pending tx WHEN invoke THEN returns status`() = runTest { + val token = createToken() + val cryptoStatus = createStatus(token) + val pendingTxHash = "0xexit456" + val status = YieldSupplyPendingStatus.Exit(txIds = listOf(pendingTxHash)) + + coEvery { + yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) + } returns status + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus) + } returns listOf(pendingTxHash) + + val result = useCase(userWalletId, cryptoStatus) + + assertThat(result.isRight()).isTrue() + val value = (result as Either.Right).value + assertThat(value).isEqualTo(status) + } + + @Test + fun `GIVEN multiple tx ids with one matching WHEN invoke THEN returns status`() = runTest { + val token = createToken() + val cryptoStatus = createStatus(token) + val matchingTxHash = "0xmatch" + val status = YieldSupplyPendingStatus.Enter(txIds = listOf("0xfirst", matchingTxHash, "0xlast")) + + coEvery { + yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) + } returns status + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus) + } returns listOf(matchingTxHash) + + val result = useCase(userWalletId, cryptoStatus) + + assertThat(result.isRight()).isTrue() + val value = (result as Either.Right).value + assertThat(value).isEqualTo(status) + } + + private fun createToken(): CryptoCurrency.Token { + val rawNetworkId = "ethereum" + val derivationPath = Network.DerivationPath.None + val network = Network( + id = Network.ID(value = rawNetworkId, derivationPath = derivationPath), + backendId = rawNetworkId, + name = rawNetworkId, + currencySymbol = rawNetworkId.take(3).uppercase(), + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + 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(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId), + ), + network = network, + name = "TEST_TOKEN", + symbol = "TTK", + decimals = 18, + iconUrl = null, + isCustom = false, + contractAddress = "0xToken", + ) + } + + private fun createStatus(token: CryptoCurrency.Token): CryptoCurrencyStatus { + return CryptoCurrencyStatus( + currency = token, + value = CryptoCurrencyStatus.Custom( + amount = BigDecimal.ZERO, + fiatAmount = BigDecimal.ZERO, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x0000000000000000000000000000000000000000", + type = NetworkAddress.Address.Type.Primary, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt index 0762d2602e..55d52cea7d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt @@ -20,8 +20,6 @@ import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.model.TokenActionsState -import com.tangem.domain.tokens.model.details.NavigationAction -import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM import com.tangem.features.feed.impl.R @@ -43,7 +41,6 @@ internal class TokenActionsHandler @AssistedInject constructor( private val isDemoCardUseCase: IsDemoCardUseCase, private val messageSender: UiMessageSender, private val shareManager: ShareManager, - private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase, ) { private val disabledActionsInDemoMode = buildSet { @@ -186,41 +183,13 @@ internal class TokenActionsHandler @AssistedInject constructor( val yieldSupplyApy = cryptoCurrencyData.actions.filterIsInstance() .firstOrNull()?.apy ?: return - val (userWalletId, cryptoCurrencyStatus) = cryptoCurrencyData.let { currencyData -> - currencyData.userWallet.walletId to currencyData.status - } - val tokenEnterStatus = yieldSupplyEnterStatusUseCase(userWalletId, cryptoCurrencyStatus).getOrNull() - val isActiveYield = cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true - - when { - tokenEnterStatus != null -> router.push( - AppRoute.CurrencyDetails( - userWalletId = userWalletId, - currency = cryptoCurrencyStatus.currency, - navigationAction = NavigationAction.YieldSupply( - isActive = isActiveYield, - ), - ), - ) - isActiveYield -> { - router.push( - AppRoute.YieldSupplyActive( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - apy = yieldSupplyApy, - ), - ) - } - else -> { - router.push( - AppRoute.YieldSupplyPromo( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - apy = yieldSupplyApy, - ), - ) - } - } + router.push( + AppRoute.YieldSupplyEntry( + userWalletId = cryptoCurrencyData.userWallet.walletId, + cryptoCurrency = cryptoCurrencyData.status.currency, + apy = yieldSupplyApy, + ), + ) } @AssistedFactory diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt index 7e6523b992..853080d794 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt @@ -20,8 +20,6 @@ import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.model.TokenActionsState -import com.tangem.domain.tokens.model.details.NavigationAction -import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase import com.tangem.features.markets.impl.R import com.tangem.features.markets.portfolio.impl.loader.PortfolioData import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM @@ -43,7 +41,6 @@ internal class TokenActionsHandler @AssistedInject constructor( private val isDemoCardUseCase: IsDemoCardUseCase, private val messageSender: UiMessageSender, private val shareManager: ShareManager, - private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase, ) { private val disabledActionsInDemoMode = buildSet { @@ -186,41 +183,13 @@ internal class TokenActionsHandler @AssistedInject constructor( val yieldSupplyApy = cryptoCurrencyData.actions.filterIsInstance() .firstOrNull()?.apy ?: return - val (userWalletId, cryptoCurrencyStatus) = cryptoCurrencyData.let { currencyData -> - currencyData.userWallet.walletId to currencyData.status - } - val tokenEnterStatus = yieldSupplyEnterStatusUseCase(userWalletId, cryptoCurrencyStatus).getOrNull() - val isActiveYield = cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true - - when { - tokenEnterStatus != null -> router.push( - AppRoute.CurrencyDetails( - userWalletId = userWalletId, - currency = cryptoCurrencyStatus.currency, - navigationAction = NavigationAction.YieldSupply( - isActive = isActiveYield, - ), - ), - ) - isActiveYield -> { - router.push( - AppRoute.YieldSupplyActive( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - apy = yieldSupplyApy, - ), - ) - } - else -> { - router.push( - AppRoute.YieldSupplyPromo( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - apy = yieldSupplyApy, - ), - ) - } - } + router.push( + AppRoute.YieldSupplyEntry( + userWalletId = cryptoCurrencyData.userWallet.walletId, + cryptoCurrency = cryptoCurrencyData.status.currency, + apy = yieldSupplyApy, + ), + ) } @AssistedFactory diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index 5b3b279808..b3f825d821 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -22,7 +22,6 @@ import com.tangem.domain.tokens.model.TokenActionsState import com.tangem.domain.tokens.model.details.NavigationAction import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase -import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplySetShouldShowMainPromoUseCase import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender @@ -103,7 +102,6 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val hotWalletFeatureToggles: HotWalletFeatureToggles, private val accountDependencies: AccountDependencies, - private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase, private val yieldSupplySetShouldShowMainPromoUseCase: YieldSupplySetShouldShowMainPromoUseCase, private val tokenListAnalyticsSender: TokenListAnalyticsSender, ) : BaseWalletClickIntents(), WalletContentClickIntents { @@ -199,7 +197,6 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( is NavigationAction.YieldSupply -> openYieldSupply( userWalletId = userWalletId, cryptoCurrencyStatus = currencyStatus, - navigationAction = navigationAction, apy = apy, ) } @@ -242,32 +239,12 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( accountDependencies.expandedAccountsHolder.collapseAccount(account.accountId) } - private fun openYieldSupply( - userWalletId: UserWalletId, - cryptoCurrencyStatus: CryptoCurrencyStatus, - navigationAction: NavigationAction.YieldSupply, - apy: String, - ) { - modelScope.launch { - val tokenEnterStatus = yieldSupplyEnterStatusUseCase(userWalletId, cryptoCurrencyStatus).getOrNull() - when { - tokenEnterStatus != null -> router.openTokenDetails( - userWalletId = userWalletId, - currencyStatus = cryptoCurrencyStatus, - navigationAction = navigationAction, - ) - navigationAction.isActive -> router.openYieldSupplyActiveScreen( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - apy = apy, - ) - else -> router.openYieldSupplyPromoScreen( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - apy = apy, - ) - } - } + private fun openYieldSupply(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, apy: String) { + router.openYieldSupplyEntryScreen( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + apy = apy, + ) } private fun sendApyLabelClickAnalytics(navigationAction: NavigationAction, currencyStatus: CryptoCurrencyStatus) { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index 934d258f18..49f35c817d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -133,19 +133,9 @@ internal class DefaultWalletRouter @Inject constructor( ) } - override fun openYieldSupplyActiveScreen(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, apy: String) { + override fun openYieldSupplyEntryScreen(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, apy: String) { router.push( - AppRoute.YieldSupplyActive( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrency, - apy = apy, - ), - ) - } - - override fun openYieldSupplyPromoScreen(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, apy: String) { - router.push( - AppRoute.YieldSupplyPromo( + AppRoute.YieldSupplyEntry( userWalletId = userWalletId, cryptoCurrency = cryptoCurrency, apy = apy, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index c423b80481..afad2d5fe7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -75,9 +75,6 @@ internal interface InnerWalletRouter { onWarningAcknowledged: (TokenAction) -> Unit, ) - /** Open yield supply active screen */ - fun openYieldSupplyActiveScreen(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, apy: String) - - /** Open yield supply promo screen */ - fun openYieldSupplyPromoScreen(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, apy: String) + /** Open yield supply entry screen */ + fun openYieldSupplyEntryScreen(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, apy: String) } \ No newline at end of file diff --git a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyEntryComponent.kt b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyEntryComponent.kt new file mode 100644 index 0000000000..150e34aa92 --- /dev/null +++ b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyEntryComponent.kt @@ -0,0 +1,17 @@ +package com.tangem.features.yield.supply.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId + +interface YieldSupplyEntryComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, + val apy: String, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/entry/YieldSupplyEntryRoute.kt b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/entry/YieldSupplyEntryRoute.kt new file mode 100644 index 0000000000..030660a4a2 --- /dev/null +++ b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/entry/YieldSupplyEntryRoute.kt @@ -0,0 +1,24 @@ +package com.tangem.features.yield.supply.api.entry + +import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.models.currency.CryptoCurrency + +/** + * Route for switching yield supply promo and active flows. + */ +sealed class YieldSupplyEntryRoute : Route { + + /** Initial empty route with no UI */ + data object Empty : YieldSupplyEntryRoute() + + /** Route to yield supply promo screen */ + data class Promo( + val cryptoCurrency: CryptoCurrency, + val apy: String, + ) : YieldSupplyEntryRoute() + + /** Route to yield supply active screen */ + data class Active( + val cryptoCurrency: CryptoCurrency, + ) : YieldSupplyEntryRoute() +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/DefaultYieldSupplyEntryComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/DefaultYieldSupplyEntryComponent.kt new file mode 100644 index 0000000000..0d5f50a0c7 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/DefaultYieldSupplyEntryComponent.kt @@ -0,0 +1,124 @@ +package com.tangem.features.yield.supply.impl.entry + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.tangem.core.decompose.context.AppComponentContext +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.features.yield.supply.api.YieldSupplyActiveComponent +import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent +import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent +import com.tangem.features.yield.supply.api.entry.YieldSupplyEntryRoute +import com.tangem.features.yield.supply.impl.entry.model.YieldSupplyEntryModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultYieldSupplyEntryComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: YieldSupplyEntryComponent.Params, + private val yieldSupplyPromoComponentFactory: YieldSupplyPromoComponent.Factory, + private val yieldSupplyActiveComponentFactory: YieldSupplyActiveComponent.Factory, +) : YieldSupplyEntryComponent, AppComponentContext by appComponentContext { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val model: YieldSupplyEntryModel = getOrCreateModel( + params = params, + router = innerRouter, + ) + + private val childStack = childStack( + key = "yieldSupplyEntryStack", + source = stackNavigation, + serializer = null, + initialConfiguration = model.initialRoute, + handleBackButton = true, + childFactory = { configuration, factoryContext -> + getChildComponent( + configuration = configuration, + factoryContext = childByContext( + componentContext = factoryContext, + router = innerRouter, + ), + ) + }, + ) + + @Composable + override fun Content(modifier: Modifier) { + val childStackValue by childStack.subscribeAsState() + + Children( + stack = childStackValue, + modifier = modifier, + animation = stackAnimation { fade() }, + ) { child -> + child.instance.Content(Modifier.fillMaxSize()) + } + } + + private fun getChildComponent( + configuration: YieldSupplyEntryRoute, + factoryContext: AppComponentContext, + ): ComposableContentComponent = when (configuration) { + is YieldSupplyEntryRoute.Empty -> EmptyComponent + is YieldSupplyEntryRoute.Promo -> yieldSupplyPromoComponentFactory.create( + context = factoryContext, + params = YieldSupplyPromoComponent.Params( + userWalletId = params.userWalletId, + currency = configuration.cryptoCurrency, + apy = configuration.apy, + ), + ) + is YieldSupplyEntryRoute.Active -> yieldSupplyActiveComponentFactory.create( + context = factoryContext, + params = YieldSupplyActiveComponent.Params( + userWalletId = params.userWalletId, + cryptoCurrency = configuration.cryptoCurrency, + ), + ) + } + + private object EmptyComponent : ComposableContentComponent { + @Composable + override fun Content(modifier: Modifier) { + Box(modifier = modifier) + } + } + + private fun onChildBack() { + if (childStack.value.backStack.isEmpty() || + childStack.value.backStack.last().configuration is YieldSupplyEntryRoute.Empty + ) { + router.pop() + } else { + stackNavigation.pop() + } + } + + @AssistedFactory + interface Factory : YieldSupplyEntryComponent.Factory { + override fun create( + context: AppComponentContext, + params: YieldSupplyEntryComponent.Params, + ): DefaultYieldSupplyEntryComponent + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/di/YieldSupplyEntryModule.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/di/YieldSupplyEntryModule.kt new file mode 100644 index 0000000000..0b8df9a27f --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/di/YieldSupplyEntryModule.kt @@ -0,0 +1,34 @@ +package com.tangem.features.yield.supply.impl.entry.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent +import com.tangem.features.yield.supply.impl.entry.DefaultYieldSupplyEntryComponent +import com.tangem.features.yield.supply.impl.entry.model.YieldSupplyEntryModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface YieldSupplyEntryBindsModule { + @Binds + @Singleton + fun provideYieldSupplyEntryComponentFactory( + impl: DefaultYieldSupplyEntryComponent.Factory, + ): YieldSupplyEntryComponent.Factory +} + +@Module +@InstallIn(ModelComponent::class) +internal interface YieldSupplyEntryModelModule { + + @Binds + @IntoMap + @ClassKey(YieldSupplyEntryModel::class) + fun provideYieldSupplyEntryModel(impl: YieldSupplyEntryModel): Model +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt new file mode 100644 index 0000000000..2bd65b5bf0 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt @@ -0,0 +1,90 @@ +package com.tangem.features.yield.supply.impl.entry.model + +import com.tangem.common.routing.AppRoute +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.navigation.Router +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.model.details.NavigationAction +import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase +import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent +import com.tangem.features.yield.supply.api.entry.YieldSupplyEntryRoute +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@ModelScoped +internal class YieldSupplyEntryModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase, + private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, +) : Model() { + + private val params = paramsContainer.require() + + val initialRoute: YieldSupplyEntryRoute = YieldSupplyEntryRoute.Empty + + init { + navigateToInitialRoute() + } + + private fun navigateToInitialRoute() { + val userWalletId = params.userWalletId + val cryptoCurrency = params.cryptoCurrency + modelScope.launch(dispatchers.default) { + getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWalletId = userWalletId, + cryptoCurrencyId = cryptoCurrency.id, + ).onLeft { error -> + Timber.e("Failed to get CryptoCurrencyStatus: $error") + router.pop() + }.onRight { cryptoCurrencyStatus -> + val route = getInitialRoute(cryptoCurrencyStatus) + if (route != null) { + router.replaceCurrent(route) + } + } + } + } + + private suspend fun getInitialRoute(cryptoCurrencyStatus: CryptoCurrencyStatus): YieldSupplyEntryRoute? { + val userWalletId = params.userWalletId + val cryptoCurrency = params.cryptoCurrency + val token = cryptoCurrency as? CryptoCurrency.Token + if (token == null) { + router.pop() + return null + } + + val tokenEnterStatus = yieldSupplyEnterStatusUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).getOrNull() + val isActiveYield = cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true + + if (tokenEnterStatus != null) { + router.replaceCurrent( + AppRoute.CurrencyDetails( + userWalletId = userWalletId, + currency = token, + navigationAction = NavigationAction.YieldSupply( + isActive = isActiveYield, + ), + ), + ) + return null + } + + return if (isActiveYield) { + YieldSupplyEntryRoute.Active(cryptoCurrency = token) + } else { + YieldSupplyEntryRoute.Promo(cryptoCurrency = token, apy = params.apy) + } + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index 17fba6e4c0..bbca4eb5ec 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -2,8 +2,6 @@ package com.tangem.features.yield.supply.impl.main.model import arrow.core.getOrElse import com.tangem.common.routing.AppRoute -import android.os.SystemClock -import com.tangem.common.routing.AppRoute.YieldSupplyPromo import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -25,8 +23,7 @@ import com.tangem.domain.models.yield.supply.YieldSupplyStatus import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase -import com.tangem.domain.yield.supply.YieldSupplyRepository -import com.tangem.domain.yield.supply.models.YieldSupplyEnterStatus +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import com.tangem.domain.yield.supply.usecase.* import com.tangem.features.yield.supply.api.YieldSupplyComponent import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics @@ -63,7 +60,7 @@ internal class YieldSupplyModel @Inject constructor( private val yieldSupplyIsAvailableUseCase: YieldSupplyIsAvailableUseCase, private val yieldSupplyActivateUseCase: YieldSupplyActivateUseCase, private val yieldSupplyDeactivateUseCase: YieldSupplyDeactivateUseCase, - private val yieldSupplyRepository: YieldSupplyRepository, + private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase, private val yieldSupplyMinAmountUseCase: YieldSupplyMinAmountUseCase, private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase, ) : Model(), YieldSupplyClickIntents { @@ -76,11 +73,11 @@ internal class YieldSupplyModel @Inject constructor( private val cryptoCurrency = params.cryptoCurrency private var appCurrency: AppCurrency = AppCurrency.Default var userWallet: UserWallet by Delegates.notNull() + private var latestCryptoCurrencyStatus: CryptoCurrencyStatus? = null private val fetchCurrencyJobHolder = JobHolder() private val loadStatusJobHolder = JobHolder() - private var lastStatusCheckTimestamp = 0L private val isFirstCryptoCurrencyStatusEmission = AtomicBoolean(true) init { @@ -116,6 +113,7 @@ internal class YieldSupplyModel @Inject constructor( ).onEach { maybeCryptoCurrency -> maybeCryptoCurrency.fold( ifRight = { cryptoCurrencyStatus -> + latestCryptoCurrencyStatus = cryptoCurrencyStatus if (isFirstCryptoCurrencyStatusEmission.compareAndSet(true, false)) { sendInfoAboutProtocolStatus(cryptoCurrencyStatus) } @@ -152,96 +150,55 @@ internal class YieldSupplyModel @Inject constructor( } override fun onStartEarningClick() { - val apy = when (val yieldSupplyUM = uiState.value) { - is YieldSupplyUM.Available -> yieldSupplyUM.apy - is YieldSupplyUM.Content -> yieldSupplyUM.apy - else -> "" - } - appRouter.push( - YieldSupplyPromo( - userWalletId = params.userWalletId, - cryptoCurrency = params.cryptoCurrency, - apy = apy, - ), - ) + navigateToYieldSupplyEntry() } override fun onActiveClick() { + navigateToYieldSupplyEntry() + } + + private fun navigateToYieldSupplyEntry() { + val cryptoCurrencyStatus = latestCryptoCurrencyStatus ?: return val apy = when (val yieldSupplyUM = uiState.value) { is YieldSupplyUM.Available -> yieldSupplyUM.apy is YieldSupplyUM.Content -> yieldSupplyUM.apy else -> "" } appRouter.push( - AppRoute.YieldSupplyActive( + AppRoute.YieldSupplyEntry( userWalletId = params.userWalletId, - cryptoCurrency = params.cryptoCurrency, + cryptoCurrency = cryptoCurrencyStatus.currency, apy = apy, ), ) } - @Suppress("MaximumLineLength") private fun onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus: CryptoCurrencyStatus) = modelScope.launch( dispatchers.default, ) { - val yieldSupplyStatus = cryptoCurrencyStatus.value.yieldSupplyStatus - val tokenProtocolStatus = yieldSupplyRepository.getTokenProtocolStatus( - userWallet.walletId, - cryptoCurrency, - ) - val tokenPendingStatus = yieldSupplyRepository.getTokenPendingStatus( - userWallet.walletId, - cryptoCurrencyStatus, - ) - - val isActive = yieldSupplyStatus?.isActive == true val isCryptoCurrencyStatusFromCache = cryptoCurrencyStatus.value.sources.networkSource != StatusSource.ACTUAL val processing = uiState.value is YieldSupplyUM.Processing - Timber.d( - "YIELD " + - "yieldSupplyStatus $yieldSupplyStatus " + - "tokenProtocolStatus $tokenProtocolStatus " + - "tokenPendingStatus $tokenPendingStatus " + - "isActive $isActive " + - "processing $processing " + - "isCryptoCurrencyStatusFromCache $isCryptoCurrencyStatusFromCache", - ) if (isCryptoCurrencyStatusFromCache && processing) { return@launch } - when { - tokenProtocolStatus != null && tokenPendingStatus != null -> { - showProcessing(tokenPendingStatus) - lastStatusCheckTimestamp = 0L - } - tokenProtocolStatus == YieldSupplyEnterStatus.Exit && isActive || - tokenProtocolStatus == YieldSupplyEnterStatus.Enter && !isActive -> { - if (lastStatusCheckTimestamp != 0L) { - if (SystemClock.elapsedRealtime() - lastStatusCheckTimestamp > MAX_STATUS_CHECK_LIMIT) { - loadStatus(cryptoCurrencyStatus) - lastStatusCheckTimestamp = 0L - } else { - showProcessing(tokenProtocolStatus) - } - } else { - showProcessing(tokenProtocolStatus) - lastStatusCheckTimestamp = SystemClock.elapsedRealtime() - } - } - else -> { - loadStatus(cryptoCurrencyStatus) - lastStatusCheckTimestamp = 0L - } + val pendingStatus = yieldSupplyEnterStatusUseCase( + userWalletId = userWallet.walletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).getOrNull() + + if (pendingStatus != null) { + showProcessing(pendingStatus) + } else { + loadStatus(cryptoCurrencyStatus) } }.saveIn(loadStatusJobHolder) - private fun showProcessing(status: YieldSupplyEnterStatus) { + private fun showProcessing(status: YieldSupplyPendingStatus) { uiState.update { when (status) { - YieldSupplyEnterStatus.Enter -> YieldSupplyUM.Processing.Enter - YieldSupplyEnterStatus.Exit -> YieldSupplyUM.Processing.Exit + is YieldSupplyPendingStatus.Enter -> YieldSupplyUM.Processing.Enter + is YieldSupplyPendingStatus.Exit -> YieldSupplyUM.Processing.Exit } } fetchCurrencyWithDelay() @@ -249,11 +206,6 @@ internal class YieldSupplyModel @Inject constructor( private suspend fun loadStatus(cryptoCurrencyStatus: CryptoCurrencyStatus) { val yieldSupplyStatus = cryptoCurrencyStatus.value.yieldSupplyStatus - yieldSupplyRepository.saveTokenProtocolStatus( - userWalletId = userWallet.walletId, - cryptoCurrency = cryptoCurrency, - yieldSupplyEnterStatus = null, - ) if (yieldSupplyStatus?.isActive == true) { loadActiveState( cryptoCurrencyStatus = cryptoCurrencyStatus, @@ -385,6 +337,5 @@ internal class YieldSupplyModel @Inject constructor( private companion object { const val PROCESSING_UPDATE_DELAY = 10_000L - const val MAX_STATUS_CHECK_LIMIT = 10_000L } } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt index 6a2d3d2597..144e0dc546 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt @@ -21,7 +21,7 @@ import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.yield.supply.YieldSupplyRepository -import com.tangem.domain.yield.supply.models.YieldSupplyEnterStatus +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import com.tangem.domain.yield.supply.usecase.* import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.R @@ -245,18 +245,21 @@ internal class YieldSupplyStartEarningModel @Inject constructor( }, ) }, - ifRight = { - onStartEarningTransactionSuccess(yieldSupplyFeeUM) + ifRight = { txsData -> + onStartEarningTransactionSuccess(yieldSupplyFeeUM, txsData) }, ) } } - private suspend fun onStartEarningTransactionSuccess(yieldSupplyFeeUM: YieldSupplyFeeUM.Content) { - yieldSupplyRepository.saveTokenProtocolStatus( - userWalletId, - cryptoCurrency, - YieldSupplyEnterStatus.Enter, + private suspend fun onStartEarningTransactionSuccess( + yieldSupplyFeeUM: YieldSupplyFeeUM.Content, + txsData: List, + ) { + yieldSupplyRepository.saveTokenProtocolPendingStatus( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + yieldSupplyPendingStatus = YieldSupplyPendingStatus.Enter(txsData), ) val event = AnalyticsParam.TxSentFrom.Earning( blockchain = cryptoCurrency.network.name, diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt index f327d6c82a..3a0b8b4398 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt @@ -21,7 +21,7 @@ import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.yield.supply.INCREASE_GAS_LIMIT_FOR_SUPPLY import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.increaseGasLimitBy -import com.tangem.domain.yield.supply.models.YieldSupplyEnterStatus +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import com.tangem.domain.yield.supply.usecase.YieldSupplyDeactivateUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyStopEarningUseCase import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics @@ -167,18 +167,18 @@ internal class YieldSupplyStopEarningModel @Inject constructor( ) params.callback.onTransactionProgress(false) }, - ifRight = { - onStopEarningTransactionSuccess() + ifRight = { txData -> + onStopEarningTransactionSuccess(txData) }, ) } } - private suspend fun onStopEarningTransactionSuccess() { - yieldSupplyRepository.saveTokenProtocolStatus( - userWallet.walletId, - cryptoCurrency, - YieldSupplyEnterStatus.Exit, + private suspend fun onStopEarningTransactionSuccess(txId: String) { + yieldSupplyRepository.saveTokenProtocolPendingStatus( + userWalletId = userWallet.walletId, + cryptoCurrency = cryptoCurrency, + yieldSupplyPendingStatus = YieldSupplyPendingStatus.Exit(listOf(txId)), ) analytics.send( YieldSupplyAnalytics.FundsWithdrawn( diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index fdc2399973..4dbe65cafc 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-1365" +tangemBlockchainSdk = "develop-1366" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt index 0e7eda2f34..a4f5188846 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt @@ -36,6 +36,9 @@ internal class WalletManagerFactoryCreator @Inject constructor( accountCreator = accountCreator, featureToggles = BlockchainFeatureToggles( isYieldSupplyEnabled = featureTogglesManager.isFeatureEnabled("YIELD_SUPPLY_FEATURE_ENABLED"), + isPendingTransactionsEnabled = featureTogglesManager.isFeatureEnabled( + "YIELD_SUPPLY_PENDING_TRANSACTIONS_ENABLED", + ), ), blockchainDataStorage = blockchainDataStorage, loggers = listOf(blockchainSDKLogger), From 2913838440c072e308cd8fbb79b48bfc1dd8f8c9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 16:50:38 +0300 Subject: [PATCH 121/172] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 4dbe65cafc..c2c690a2b3 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-1366" +tangemBlockchainSdk = "develop-1369" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 349969a2e3266eb43a1a0867680f583f255bf10c Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 15:05:01 +0100 Subject: [PATCH 122/172] Updated on 2026-08-14 --- .../ui/news/details/NewsDetailsContent.kt | 191 ++++++++++-------- 1 file changed, 102 insertions(+), 89 deletions(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt index f3ab2009a2..2aad7230ef 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt @@ -142,108 +142,121 @@ private fun ArticleDetail( modifier: Modifier = Modifier, ) { val density = LocalDensity.current - LazyColumn( - modifier = modifier, - contentPadding = PaddingValues(bottom = 56.dp + WindowInsets.navigationBars.getBottom(density).dp), - ) { - item("content") { - ArticleHeader( - title = article.title, - createdAt = article.createdAt.resolveReference(), - score = article.score, - tags = article.tags, - modifier = Modifier - .padding(top = 16.dp) - .padding(horizontal = 16.dp), - ) + val background = LocalMainBottomSheetColor.current.value + val pagerHeight = 32.dp + val contentPadding = pagerHeight + 56.dp + with(density) { + WindowInsets.navigationBars.getBottom(this).div(this.density) + }.dp - if (article.shortContent.isNotEmpty()) { - QuickRecap( - content = article.shortContent, + Box(modifier = modifier) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = contentPadding), + ) { + item("content") { + ArticleHeader( + title = article.title, + createdAt = article.createdAt.resolveReference(), + score = article.score, + tags = article.tags, modifier = Modifier - .padding(top = 32.dp) + .padding(top = 16.dp) .padding(horizontal = 16.dp), ) - } - Text( - text = article.content, - style = TangemTheme.typography.body1, - color = TangemTheme.colors.text.primary1, - modifier = Modifier - .padding(top = 16.dp) - .padding(horizontal = 16.dp), - ) - - SpacerH(24.dp) - - if (article.isLiked) { - PrimaryButtonIconStart( - modifier = Modifier.padding(horizontal = 16.dp), - iconResId = R.drawable.ic_heart_20, - text = stringResourceSafe(R.string.news_like), - size = TangemButtonSize.RoundedAction, - onClick = { onLikeClick() }, - ) - } else { - SecondaryButtonIconStart( - modifier = Modifier.padding(horizontal = 16.dp), - iconResId = R.drawable.ic_heart_20, - text = stringResourceSafe(R.string.news_like), - size = TangemButtonSize.RoundedAction, - onClick = { onLikeClick() }, - ) - } - - RelatedTokensBlock( - relatedTokensUM = relatedTokensUM, - onItemClick = when (relatedTokensUM) { - is RelatedTokensUM.Content -> relatedTokensUM.onTokenClick - else -> null - }, - modifier = Modifier.padding(horizontal = 16.dp), - ) - - if (article.sources.isNotEmpty()) { - SpacerH(24.dp) - Row( - modifier = Modifier.padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResourceSafe(R.string.news_sources), - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, - ) - Text( - text = "${article.sources.size}", - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.tertiary, + if (article.shortContent.isNotEmpty()) { + QuickRecap( + content = article.shortContent, + modifier = Modifier + .padding(top = 32.dp) + .padding(horizontal = 16.dp), ) } - } - } - if (article.sources.isNotEmpty()) { - item("sources") { - LazyRow( - modifier = Modifier.padding(vertical = 12.dp), - state = rememberLazyListState(), - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - items( - items = article.sources, - key = SourceUM::id, - ) { source -> - SourceItem( - source = source, - modifier = Modifier.fillParentMaxHeight(), + Text( + text = article.content, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding(top = 16.dp) + .padding(horizontal = 16.dp), + ) + + SpacerH(24.dp) + + if (article.isLiked) { + PrimaryButtonIconStart( + modifier = Modifier.padding(horizontal = 16.dp), + iconResId = R.drawable.ic_heart_20, + text = stringResourceSafe(R.string.news_like), + size = TangemButtonSize.RoundedAction, + onClick = { onLikeClick() }, + ) + } else { + SecondaryButtonIconStart( + modifier = Modifier.padding(horizontal = 16.dp), + iconResId = R.drawable.ic_heart_20, + text = stringResourceSafe(R.string.news_like), + size = TangemButtonSize.RoundedAction, + onClick = { onLikeClick() }, + ) + } + + RelatedTokensBlock( + relatedTokensUM = relatedTokensUM, + onItemClick = when (relatedTokensUM) { + is RelatedTokensUM.Content -> relatedTokensUM.onTokenClick + else -> null + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + if (article.sources.isNotEmpty()) { + SpacerH(24.dp) + Row( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResourceSafe(R.string.news_sources), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = "${article.sources.size}", + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.tertiary, ) } } } + + if (article.sources.isNotEmpty()) { + item("sources") { + LazyRow( + modifier = Modifier.padding(vertical = 12.dp), + state = rememberLazyListState(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = article.sources, + key = SourceUM::id, + ) { source -> + SourceItem( + source = source, + modifier = Modifier.fillParentMaxHeight(), + ) + } + } + } + } } + BottomFade( + modifier = Modifier + .align(Alignment.BottomCenter), + backgroundColor = background, + ) } } From 61a1a25067a9e8d9b5670b057026c9565be44c9d Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 15:06:01 +0100 Subject: [PATCH 123/172] Updated on 2026-08-14 --- .../com/tangem/common/ui/news/ArticleCard.kt | 7 +- .../ui/components/label/entity/LabelUM.kt | 1 + .../tangem/features/feed/ui/feed/FeedList.kt | 167 ++++++++++-------- .../feed/ui/market/list/MarketsList.kt | 2 +- 4 files changed, 98 insertions(+), 79 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt index d73e4e8486..06cf750dac 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt @@ -174,7 +174,12 @@ private fun Tags(tags: ImmutableList, modifier: Modifier = Modifier) { val expandIndicator = remember { ContextualFlowRowOverflow.expandIndicator { val remainingItems = tags.size - shownItemCount - Label(state = LabelUM(TextReference.Str("${StringsSigns.PLUS}$remainingItems"))) + Label( + state = LabelUM( + text = TextReference.Str("${StringsSigns.PLUS}$remainingItems"), + maxLines = 1, + ), + ) } } ContextualFlowRow( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt index 131469bd96..1a6f10a087 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt @@ -12,6 +12,7 @@ data class LabelUM( @DrawableRes val icon: Int? = null, val onIconClick: (() -> Unit)? = null, val onClick: (() -> Unit)? = null, + val maxLines: Int = Int.MAX_VALUE, ) @Immutable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index 4a5825783f..5cf729b8ba 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -1,6 +1,9 @@ package com.tangem.features.feed.ui.feed -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* @@ -60,7 +63,7 @@ internal fun FeedListHeader(feedListSearchBar: FeedListSearchBar, modifier: Modi modifier = modifier .drawBehind { drawRect(background) } .padding(horizontal = 16.dp) - .padding(bottom = 12.dp), + .padding(bottom = 8.dp), ) } @@ -70,6 +73,7 @@ internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) { AnimatedContent( modifier = modifier, targetState = state.globalState, + transitionSpec = { fadeIn() togetherWith fadeOut() }, ) { animatedState -> when (animatedState) { is GlobalFeedState.Loading -> { @@ -84,6 +88,7 @@ internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) { FeedListGlobalError( onRetryClick = animatedState.onRetryClicked, modifier = Modifier.drawBehind { drawRect(background) }, + currentDate = state.currentDate, ) } is GlobalFeedState.Content -> { @@ -130,83 +135,67 @@ private fun FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) { modifier = modifier .fillMaxSize() .verticalScroll(rememberScrollState()) + .padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) .drawBehind { drawRect(background) }, ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(WindowInsets.navigationBars.asPaddingValues()), - ) { - SpacerH(20.dp) + DateBlock(state.currentDate) + SpacerH(32.dp) - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - text = stringResourceSafe(R.string.feed_market_and_news), - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.primary1, - ) - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - text = state.currentDate, - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.tertiary, - ) + MarketBlock( + marketChart = state.marketChartConfig.marketCharts[SortByTypeUM.Rating], + feedListCallbacks = state.feedListCallbacks, + ) - SpacerH(32.dp) + NewsBlock( + news = state.news, + feedListCallbacks = state.feedListCallbacks, + trendingArticle = state.trendingArticle, + ) - AnimatedVisibility(state.marketChartConfig.marketCharts[SortByTypeUM.Rating] != null) { - val marketChart = remember(state.marketChartConfig.marketCharts[SortByTypeUM.Rating]) { - state.marketChartConfig.marketCharts[SortByTypeUM.Rating] - } - if (marketChart != null) { - MarketBlock( - marketChart = marketChart, - feedListCallbacks = state.feedListCallbacks, - ) - } - } - - NewsBlock( - news = state.news, - feedListCallbacks = state.feedListCallbacks, - trendingArticle = state.trendingArticle, - ) - - MarketPulseBlock( - marketChartConfig = state.marketChartConfig, - feedListCallbacks = state.feedListCallbacks, - ) - } + MarketPulseBlock( + marketChartConfig = state.marketChartConfig, + feedListCallbacks = state.feedListCallbacks, + ) } } @Composable -private fun MarketBlock(marketChart: MarketChartUM, feedListCallbacks: FeedListCallbacks) { - Column(modifier = Modifier.fillMaxWidth()) { - Header( - title = { - Text( - text = stringResourceSafe(R.string.markets_common_title), - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, - ) - }, - onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) }, - ) +private fun MarketBlock(marketChart: MarketChartUM?, feedListCallbacks: FeedListCallbacks) { + AnimatedContent( + targetState = marketChart, + label = "MarketBlockChartAnimation", + transitionSpec = { fadeIn() togetherWith fadeOut() }, + ) { currentChart -> + when (currentChart) { + is MarketChartUM.Content, + MarketChartUM.Loading, + is MarketChartUM.LoadingError, + -> { + Column(modifier = Modifier.fillMaxWidth()) { + Header( + title = { + Text( + text = stringResourceSafe(R.string.markets_common_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + }, + onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) }, + ) - SpacerH(12.dp) + SpacerH(12.dp) - Charts( - onItemClick = feedListCallbacks.onMarketItemClick, - modifier = Modifier.padding(horizontal = 16.dp), - marketChart = marketChart, - ) + Charts( + onItemClick = feedListCallbacks.onMarketItemClick, + modifier = Modifier.padding(horizontal = 16.dp), + marketChart = currentChart, + ) - SpacerH(32.dp) + SpacerH(32.dp) + } + } + null -> Unit + } } } @@ -490,16 +479,19 @@ private fun FilterChip(sortByTypeUM: SortByTypeUM, isSelected: Boolean, onClick: } @Composable -private fun FeedListGlobalError(onRetryClick: () -> Unit, modifier: Modifier = Modifier) { +private fun FeedListGlobalError(onRetryClick: () -> Unit, currentDate: String, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value - Box( - modifier = modifier - .fillMaxSize() - .drawBehind { drawRect(background) } - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - UnableToLoadData(onRetryClick = onRetryClick) + Column(modifier) { + DateBlock(currentDate) + Box( + modifier = Modifier + .fillMaxSize() + .drawBehind { drawRect(background) } + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData(onRetryClick = onRetryClick) + } } } @@ -519,7 +511,6 @@ private fun NewsErrorBlock(onRetryClick: () -> Unit) { onSeeAllClick = {}, ) SpacerH(12.dp) - BlockCard( modifier = Modifier.padding(horizontal = 16.dp), colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), @@ -531,9 +522,31 @@ private fun NewsErrorBlock(onRetryClick: () -> Unit) { .padding(vertical = 35.dp, horizontal = 10.dp), ) } + SpacerH(32.dp) } } +@Composable +private fun ColumnScope.DateBlock(currentDate: String) { + SpacerH(20.dp) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + text = stringResourceSafe(R.string.feed_market_and_news), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + text = currentDate, + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.tertiary, + ) +} + private const val DEFAULT_CHART_SIZE_IN_MARKET = 5 private const val FOURTH_ITEM_INDEX = 3 private const val GRADIENT_START = 0f diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt index d9194f707f..368afe2910 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt @@ -83,7 +83,7 @@ internal fun TopBarWithSearch( modifier = Modifier .drawBehind { drawRect(background) } .padding(horizontal = 16.dp) - .padding(bottom = 12.dp), + .padding(bottom = 8.dp), state = marketsSearchBar.searchBarUM, colors = TangemSearchBarDefaults.defaultTextFieldColors.copy( focusedContainerColor = TangemTheme.colors.field.focused, From b3bc56ca0373c9d63f85f411dd9993ebef153c6d Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 17:43:34 +0300 Subject: [PATCH 124/172] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index c2c690a2b3..eef0c9e1fc 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-1369" +tangemBlockchainSdk = "develop-1370" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 52cc0be3cb6d89686ba294de0e87b18d3a276bd5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sat, 17 Jan 2026 12:58:46 +0300 Subject: [PATCH 125/172] Updated on 2026-08-14 --- .../gasless/EstimateFeeForGaslessTxUseCase.kt | 8 +- .../gasless/EstimateFeeForTokenUseCase.kt | 51 +- .../usecase/gasless/TokenFeeCalculator.kt | 6 +- .../usecase/gasless/TokenFeeCalculatorTest.kt | 6 +- .../GetExplorerTransactionUrlUseCase.kt | 4 + .../api/analytics/CommonSendAnalyticEvents.kt | 1 + .../send/v2/api/params/FeeSelectorParams.kt | 1 + .../ui/{FeeBlock.kt => FeeBlockSuccess.kt} | 2 +- .../DefaultFeeSelectorBlockComponent.kt | 4 + .../FeeSelectorTokenSelectedTransformer.kt | 1 + .../success/ui/SendConfirmSuccessContent.kt | 4 +- .../success/ui/NFTSendSuccessContent.kt | 4 +- .../confirm/model/SendWithSwapConfirmModel.kt | 5 +- .../feature/swap/domain/SwapInteractor.kt | 52 +- .../feature/swap/domain/SwapInteractorImpl.kt | 650 ++++++++++++------ .../feature/swap/domain/api/SwapRepository.kt | 7 +- .../swap/domain/models/ExpressDataError.kt | 0 .../swap/domain/models/ExpressException.kt | 0 .../feature/swap/domain/models/SwapAmount.kt | 0 .../domain/models/domain/ExchangeStatus.kt | 0 .../models/domain/ExpressTransactionModel.kt | 0 .../domain/models/domain/LeastTokenInfo.kt | 0 .../swap/domain/models/domain/NetworkInfo.kt | 0 .../models/domain/PairsWithProviders.kt | 0 .../domain/models/domain/PermissionOptions.kt | 0 .../models/domain/PreparedSwapConfigState.kt | 0 .../swap/domain/models/domain/QuoteModel.kt | 0 .../domain/SavedLastSwappedCryptoCurrency.kt | 0 .../domain/SavedSwapTransactionListModel.kt | 0 .../domain/models/domain/SwapApproveType.kt | 0 .../domain/models/domain/SwapDataModel.kt | 0 .../swap/domain/models/domain/SwapFeeState.kt | 0 .../domain/models/domain/SwapPairLeast.kt | 0 .../swap/domain/models/ui/AmountFormatter.kt | 0 .../swap/domain/models/ui/SwapState.kt | 41 +- .../domain/models/ui/SwapTransactionState.kt | 0 .../models/ui/TokensDataStateExpress.kt | 0 features/swap/impl/build.gradle.kts | 6 + .../feature/swap/DefaultSwapComponent.kt | 108 ++- .../SwapFeeSelectorBlockComponent.kt | 99 +++ .../tangem/feature/swap/model/SwapModel.kt | 237 ++++++- .../swap/model/SwapNotificationsFactory.kt | 11 +- .../swap/model/SwapProcessDataState.kt | 2 +- .../swap/models/SwapSuccessStateHolder.kt | 2 +- .../tangem/feature/swap/models/UiActions.kt | 2 +- .../tangem/feature/swap/ui/StateBuilder.kt | 5 +- .../com/tangem/feature/swap/ui/SwapScreen.kt | 16 +- .../feature/swap/ui/SwapScreenContent.kt | 12 +- .../feature/swap/ui/SwapSuccessScreen.kt | 19 +- 49 files changed, 1039 insertions(+), 327 deletions(-) rename features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/{FeeBlock.kt => FeeBlockSuccess.kt} (98%) rename features/swap/domain/{api => }/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt (87%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/ExpressDataError.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/ExpressException.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/SwapAmount.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/domain/PairsWithProviders.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt (80%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt (100%) rename features/swap/domain/{models => }/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt (100%) create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt index 3d375e367d..b43a368305 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt @@ -44,12 +44,12 @@ class EstimateFeeForGaslessTxUseCase( suspend operator fun invoke( userWallet: UserWallet, amount: BigDecimal, - cryptoCurrencyStatus: CryptoCurrencyStatus, + sendingTokenCurrencyStatus: CryptoCurrencyStatus, ): Either { return either { catch( block = { - val network = cryptoCurrencyStatus.currency.network + val network = sendingTokenCurrencyStatus.currency.network val nativeCurrency = currenciesRepository.getNetworkCoin( userWalletId = userWallet.walletId, networkId = network.id, @@ -60,7 +60,7 @@ class EstimateFeeForGaslessTxUseCase( estimateFeeUseCase.invoke( userWallet = userWallet, amount = amount, - cryptoCurrencyStatus = cryptoCurrencyStatus, + cryptoCurrencyStatus = sendingTokenCurrencyStatus, ).fold( ifLeft = { raise(it) }, ifRight = { fee -> @@ -77,7 +77,7 @@ class EstimateFeeForGaslessTxUseCase( val initialFee = tokenFeeCalculator.estimateInitialFee( userWallet = userWallet, amount = amount, - tokenCurrencyStatus = cryptoCurrencyStatus, + txTokenCurrencyStatus = sendingTokenCurrencyStatus, ).bind() selectFeePaymentStrategy( diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt index a3165f0349..567aa6a486 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt @@ -6,10 +6,7 @@ import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.extensions.Result -import com.tangem.domain.demo.DemoTransactionSender import com.tangem.domain.demo.models.DemoConfig -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet @@ -19,10 +16,8 @@ import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError.GaslessError -import com.tangem.domain.transaction.error.mapToFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.raiseIllegalStateError -import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.walletmanager.WalletManagersFacade import java.math.BigDecimal @@ -43,38 +38,23 @@ class EstimateFeeForTokenUseCase( suspend operator fun invoke( userWallet: UserWallet, - tokenCurrencyStatus: CryptoCurrencyStatus, + feeTokenCurrencyStatus: CryptoCurrencyStatus, + sendingTokenCurrencyStatus: CryptoCurrencyStatus, amount: BigDecimal, ): Either { return either { catch( block = { - val token = tokenCurrencyStatus.currency + val token = feeTokenCurrencyStatus.currency if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) { - raise(GetFeeError.GaslessError.NetworkIsNotSupported) + raise(GaslessError.NetworkIsNotSupported) } - val amountData = amount.convertToSdkAmount(tokenCurrencyStatus) - val result = if (userWallet is UserWallet.Cold && - demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) - ) { - demoTransactionSender(userWallet, token).estimateFee( - amount = amountData, - destination = "", - ) - } else { - walletManagersFacade.estimateFee( - amount = amountData, - userWalletId = userWallet.walletId, - network = token.network, - ) - } - - val initialTxFee = when (result) { - is Result.Success -> result.data - is Result.Failure -> raise(result.mapToFeeError()) - null -> raise(GetFeeError.UnknownError) - } + val initialTxFee = tokenFeeCalculator.estimateInitialFee( + userWallet = userWallet, + amount = amount, + txTokenCurrencyStatus = sendingTokenCurrencyStatus, + ).bind() val initialFeeEth = initialTxFee.normal as? Fee.Ethereum ?: raiseIllegalStateError( @@ -101,7 +81,7 @@ class EstimateFeeForTokenUseCase( tokenFeeCalculator.calculateTokenFee( walletManager = walletManager, - tokenForPayFeeStatus = tokenCurrencyStatus, + tokenForPayFeeStatus = feeTokenCurrencyStatus, nativeCurrencyStatus = nativeCurrencyStatus, initialFee = initialFeeEth, ).bind() @@ -126,15 +106,4 @@ class EstimateFeeForTokenUseCase( ?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported") return ethereumWalletManager } - - private suspend fun demoTransactionSender( - userWallet: UserWallet, - cryptoCurrency: CryptoCurrency, - ): DemoTransactionSender { - return DemoTransactionSender( - walletManagersFacade - .getOrCreateWalletManager(userWallet.walletId, cryptoCurrency.network) - ?: error("WalletManager is null"), - ) - } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt index c7d9a57ea7..803a21668b 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt @@ -61,11 +61,11 @@ internal class TokenFeeCalculator( suspend fun estimateInitialFee( userWallet: UserWallet, amount: BigDecimal, - tokenCurrencyStatus: CryptoCurrencyStatus, + txTokenCurrencyStatus: CryptoCurrencyStatus, ): Either { return either { - val network = tokenCurrencyStatus.currency.network - val amountData = amount.convertToSdkAmount(tokenCurrencyStatus) + val network = txTokenCurrencyStatus.currency.network + val amountData = amount.convertToSdkAmount(txTokenCurrencyStatus) val result = if (userWallet is UserWallet.Cold && demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) ) { diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt index a5ba8d69b6..e3a91a642c 100644 --- a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt @@ -129,7 +129,7 @@ class TokenFeeCalculatorTest { val result = tokenFeeCalculator.estimateInitialFee( userWallet = mockUserWallet, amount = amount, - tokenCurrencyStatus = tokenStatus, + txTokenCurrencyStatus = tokenStatus, ) // Then @@ -154,7 +154,7 @@ class TokenFeeCalculatorTest { val result = tokenFeeCalculator.estimateInitialFee( userWallet = mockUserWallet, amount = amount, - tokenCurrencyStatus = tokenStatus, + txTokenCurrencyStatus = tokenStatus, ) // Then @@ -173,7 +173,7 @@ class TokenFeeCalculatorTest { val result = tokenFeeCalculator.estimateInitialFee( userWallet = mockUserWallet, amount = amount, - tokenCurrencyStatus = tokenStatus, + txTokenCurrencyStatus = tokenStatus, ) // Then diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetExplorerTransactionUrlUseCase.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetExplorerTransactionUrlUseCase.kt index dcbec88d76..a9a2f27737 100644 --- a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetExplorerTransactionUrlUseCase.kt +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetExplorerTransactionUrlUseCase.kt @@ -14,6 +14,10 @@ class GetExplorerTransactionUrlUseCase( return either { catch( block = { + if (txHash.isEmpty()) { + raise(TxStatusError.EmptyUrlError) + } + repository.getTxExploreUrl(txHash, networkId).ifEmpty { raise(TxStatusError.EmptyUrlError) } diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/analytics/CommonSendAnalyticEvents.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/analytics/CommonSendAnalyticEvents.kt index 82527c66bf..d50adee6f6 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/analytics/CommonSendAnalyticEvents.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/analytics/CommonSendAnalyticEvents.kt @@ -186,6 +186,7 @@ sealed class CommonSendAnalyticEvents( enum class CommonSendSource(val analyticsName: String) { Send("Send"), + Swap("Swap"), SendWithSwap("Send&Swap"), WalletConnect("WalletConnect"), NFT("NFT"), diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt index 8a6a6edf3e..77877cf76e 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt @@ -37,6 +37,7 @@ sealed class FeeSelectorParams { override val feeDisplaySource: FeeDisplaySource, override val analyticsCategoryName: String, override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, + val bottomSheetShown: (Boolean) -> Unit = {}, ) : FeeSelectorParams() data class FeeSelectorDetailsParams( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/FeeBlock.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/FeeBlockSuccess.kt similarity index 98% rename from features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/FeeBlock.kt rename to features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/FeeBlockSuccess.kt index 473a7b235e..c9db856ad1 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/FeeBlock.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/FeeBlockSuccess.kt @@ -19,7 +19,7 @@ import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.impl.R @Composable -internal fun FeeBlock(feeSelectorUM: FeeSelectorUM) { +fun FeeBlockSuccess(feeSelectorUM: FeeSelectorUM) { if (feeSelectorUM !is FeeSelectorUM.Content) return val feeExtraInfo = feeSelectorUM.feeExtraInfo val feeFiatRateUM = feeSelectorUM.feeFiatRateUM diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt index 5da1137ef5..baf95a222c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt @@ -65,6 +65,10 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor( ) init { + bottomSheetSlot.subscribe { + params.bottomSheetShown(it.child != null) + } + model.uiState .onEach { onResult(it) } .launchIn(componentScope) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt index bb7d3d5df7..f11d9c8dd9 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt @@ -13,6 +13,7 @@ class FeeSelectorTokenSelectedTransformer( override fun transform(prevState: FeeSelectorUM): FeeSelectorUM { return if (prevState is FeeSelectorUM.Content) { prevState.copy( + isPrimaryButtonEnabled = false, selectedFeeItem = FeeItem.Loading, feeItems = persistentListOf(FeeItem.Loading), feeExtraInfo = prevState.feeExtraInfo.copy( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/ui/SendConfirmSuccessContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/ui/SendConfirmSuccessContent.kt index 7d07f291bd..04905ad82d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/ui/SendConfirmSuccessContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/ui/SendConfirmSuccessContent.kt @@ -21,7 +21,7 @@ import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.toPx import com.tangem.core.ui.utils.toTimeFormat import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationBlockComponent -import com.tangem.features.send.v2.common.ui.FeeBlock +import com.tangem.features.send.v2.common.ui.FeeBlockSuccess import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.impl.R import com.tangem.features.send.v2.send.ui.state.SendUM @@ -106,7 +106,7 @@ private fun SuccessContent( onClick = {}, ) destinationBlockComponent.Content(modifier = Modifier) - FeeBlock(feeSelectorUM = sendUM.feeSelectorUM) + FeeBlockSuccess(feeSelectorUM = sendUM.feeSelectorUM) SpacerH(16.dp) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/ui/NFTSendSuccessContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/ui/NFTSendSuccessContent.kt index b5e13f0e06..2f18427779 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/ui/NFTSendSuccessContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/ui/NFTSendSuccessContent.kt @@ -21,7 +21,7 @@ import com.tangem.core.ui.utils.toPx import com.tangem.core.ui.utils.toTimeFormat import com.tangem.features.nft.component.NFTDetailsBlockComponent import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationBlockComponent -import com.tangem.features.send.v2.common.ui.FeeBlock +import com.tangem.features.send.v2.common.ui.FeeBlockSuccess import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.impl.R import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM @@ -109,7 +109,7 @@ private fun SuccessContent( } nftDetailsBlockComponent.Content(modifier = Modifier) destinationBlockComponent.Content(modifier = Modifier) - FeeBlock(feeSelectorUM = nftSendUM.feeSelectorUM) + FeeBlockSuccess(feeSelectorUM = nftSendUM.feeSelectorUM) SpacerH(16.dp) } } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 62e8af81b4..6ad374ecef 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -263,13 +263,14 @@ internal class SendWithSwapConfirmModel @Inject constructor( estimateFeeForTokenUseCase( amount = amountValue, userWallet = params.userWallet, - tokenCurrencyStatus = maybeToken, + feeTokenCurrencyStatus = maybeToken, + sendingTokenCurrencyStatus = primaryCurrencyStatus, ) } else { estimateFeeForGaslessTxUseCase( amount = amountValue, userWallet = params.userWallet, - cryptoCurrencyStatus = primaryCurrencyStatus, + sendingTokenCurrencyStatus = primaryCurrencyStatus, ) } } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 6f4381b8a6..d8ba2cc67b 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -1,10 +1,14 @@ package com.tangem.feature.swap.domain +import arrow.core.Either +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount import com.tangem.feature.swap.domain.models.domain.PermissionOptions @@ -37,7 +41,7 @@ interface SwapInteractor { * @param providers list of providers to find quote * @param amountToSwap amount you want to swap * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) - * @param selectedFee selected fee to swap + * @param txFeeSealedState selected fee to swap * @return */ @Suppress("LongParameterList") @@ -50,7 +54,7 @@ interface SwapInteractor { providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, - selectedFee: FeeType = FeeType.NORMAL, + txFeeSealedState: TxFeeSealedState, ): Map /** @@ -132,6 +136,50 @@ interface SwapInteractor { averageDuration: Int? = null, ) + /** + * Loads fee for swap transaction + * + * @param fromToken token from which want to swap + * @param fromAccount account from which swap will be made + * @param toToken token that receive after swap + * @param toAccount account to which receive token after swap + * @param amount amount you want to swap + * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) + * @param selectedFeeToken selected token to pay fee or null to pay fee with coin + */ + @Suppress("LongParameterList") + suspend fun loadFeeForSwapTransaction( + fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, + amount: String, + reduceBalanceBy: BigDecimal, + provider: SwapProvider, + selectedFeeToken: CryptoCurrencyStatus?, + ): Either + + /** + * Loads fee for swap transaction + * + * @param fromToken token from which want to swap + * @param fromAccount account from which swap will be made + * @param toToken token that receive after swap + * @param toAccount account to which receive token after swap + * @param amount amount you want to swap + * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) + */ + @Suppress("LongParameterList") + suspend fun loadFeeForSwapTransaction( + fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, + amount: String, + reduceBalanceBy: BigDecimal, + provider: SwapProvider, + ): Either + interface Factory { fun create(selectedWalletId: UserWalletId): SwapInteractor } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 72a86eaec1..286471b0a0 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -3,6 +3,7 @@ package com.tangem.feature.swap.domain import android.util.Base64 import arrow.core.Either import arrow.core.getOrElse +import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper import com.tangem.blockchain.common.Amount @@ -42,10 +43,14 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.* +import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase +import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase +import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase +import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.usecase.GetUserWalletUseCase -import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount @@ -55,6 +60,7 @@ import com.tangem.feature.swap.domain.models.ui.* import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES import com.tangem.lib.crypto.UserWalletManager import com.tangem.lib.crypto.models.ProxyAmount +import com.tangem.utils.coroutines.runSuspendCatching import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -87,6 +93,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val initialToCurrencyResolver: InitialToCurrencyResolver, private val validateTransactionUseCase: ValidateTransactionUseCase, private val estimateFeeUseCase: EstimateFeeUseCase, + private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase, + private val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase, + private val getFeeForTokenUseCase: GetFeeForTokenUseCase, + private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, private val getFeeUseCase: GetFeeUseCase, private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, @@ -385,7 +395,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, - selectedFee: FeeType, + txFeeSealedState: TxFeeSealedState, ): Map { Timber.i( """ @@ -394,7 +404,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( |- toToken: $toToken |- providers: $providers |- amountToSwap: $amountToSwap - |- selectedFee: $selectedFee + |- selectedFee: $txFeeSealedState """.trimIndent(), ) @@ -416,7 +426,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toToken = toToken, toAccount = toAccount, provider = provider, - selectedFee = selectedFee, + txFeeSealedState = txFeeSealedState, amount = amount, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, expressOperationType = ExpressOperationType.SWAP, @@ -429,7 +439,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toToken = toToken, toAccount = toAccount, provider = provider, - selectedFee = selectedFee, + txFeeSealedState = txFeeSealedState, amount = amount, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, expressOperationType = ExpressOperationType.SWAP, @@ -447,7 +457,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( amount = amount, reduceBalanceBy = reduceBalanceBy, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - selectedFee = selectedFee, + txFeeSealedState = txFeeSealedState, ) } } @@ -462,7 +472,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toToken: CryptoCurrencyStatus, toAccount: Account.CryptoPortfolio?, provider: SwapProvider, - selectedFee: FeeType, + txFeeSealedState: TxFeeSealedState, amount: SwapAmount, isBalanceWithoutFeeEnough: Boolean, expressOperationType: ExpressOperationType, @@ -517,7 +527,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toToken = toToken, toAccount = toAccount, amount = amount, - selectedFee = selectedFee, + txFeeSealedState = txFeeSealedState, expressOperationType = expressOperationType, ) } else { @@ -532,10 +542,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( networkId = networkId, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - txFee = TxFeeState.Empty, - transactionFee = null, + txFeeSealedState = txFeeSealedState, includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex - selectedFee = selectedFee, ) } } @@ -547,7 +555,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toToken: CryptoCurrencyStatus, toAccount: Account.CryptoPortfolio?, provider: SwapProvider, - selectedFee: FeeType, + txFeeSealedState: TxFeeSealedState, amount: SwapAmount, isBalanceWithoutFeeEnough: Boolean, expressOperationType: ExpressOperationType, @@ -574,7 +582,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toToken = toToken, toAccount = toAccount, amount = amount, - selectedFee = selectedFee, + txFeeSealedState = txFeeSealedState, expressOperationType = expressOperationType, ) } else { @@ -589,10 +597,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( networkId = networkId, isAllowedToSpend = true, isBalanceWithoutFeeEnough = false, - txFee = TxFeeState.Empty, - transactionFee = null, + txFeeSealedState = txFeeSealedState, includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex - selectedFee = selectedFee, ) } } @@ -607,7 +613,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( amount: SwapAmount, reduceBalanceBy: BigDecimal, isBalanceWithoutFeeEnough: Boolean, - selectedFee: FeeType, + txFeeSealedState: TxFeeSealedState, ): Pair { return provider to loadCexQuoteData( networkId = networkId, @@ -620,22 +626,34 @@ internal class SwapInteractorImpl @AssistedInject constructor( isAllowedToSpend = true, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, provider = provider, - selectedFee = selectedFee, + txFeeSealedState = txFeeSealedState, ) } private suspend fun manageWarnings( fromTokenStatus: CryptoCurrencyStatus, amount: SwapAmount, - feeState: TxFeeState, - selectedFee: FeeType, + txFeeSealed: TxFeeSealedState?, includeFeeInAmount: IncludeFeeInAmount, ): CryptoCurrencyCheck { - val fee = when (feeState) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> feeState.getFeeByType(selectedFee).feeValue - is TxFeeState.SingleFeeState -> feeState.fee.feeValue - } + val fee = when (txFeeSealed) { + is TxFeeSealedState.Component -> { + if (txFeeSealed.txFee.selectedToken?.currency is CryptoCurrency.Token) { + BigDecimal.ZERO + } else { + txFeeSealed.txFee.fee.amount.value + } + } + is TxFeeSealedState.Legacy -> { + when (val feeState = txFeeSealed.txFeeState) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> feeState.getFeeByType(txFeeSealed.selectedFee).fee.amount.value + is TxFeeState.SingleFeeState -> feeState.fee.fee.amount.value + } + } + null -> BigDecimal.ZERO + } ?: BigDecimal.ZERO + val balanceAfterTransaction = getCoinBalanceAfterTransaction( fromTokenStatus = fromTokenStatus, amount = amount, @@ -703,7 +721,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageTransactionValidationWarnings( fromToken: CryptoCurrencyStatus, amount: SwapAmount, - feeState: TxFeeState, + txFeeSealedState: TxFeeSealedState, userWalletId: UserWalletId, ): Throwable? { val currency = fromToken.currency @@ -712,13 +730,20 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (blockchain == Blockchain.Stellar) { return null } + val feeValue = when (txFeeSealedState) { + is TxFeeSealedState.Component -> txFeeSealedState.txFee.fee.amount.value + is TxFeeSealedState.Legacy -> { + when (val feeState = txFeeSealedState.txFeeState) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> feeState.normalFee.fee.amount.value + is TxFeeState.SingleFeeState -> feeState.fee.fee.amount.value + } + } + } + val fee = Fee.Common( amount = Amount( - value = when (feeState) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> feeState.normalFee.feeValue - is TxFeeState.SingleFeeState -> feeState.fee.feeValue - }, + value = feeValue, blockchain = blockchain, ), ) @@ -793,7 +818,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (isSolana(networkId)) { onSwapSolanaDex( provider = swapProvider, - networkId = currencyToSend.currency.network.backendId, swapData = requireNotNull(swapData), currencyToSendStatus = currencyToSend, currencyToGetStatus = currencyToGet, @@ -805,7 +829,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (fee == null) return SwapTransactionState.Error.UnknownError onSwapDex( provider = swapProvider, - networkId = currencyToSend.currency.network.backendId, swapData = requireNotNull(swapData), currencyToSendStatus = currencyToSend, currencyToGetStatus = currencyToGet, @@ -819,50 +842,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } - // override suspend fun updateQuotesStateWithSelectedFee( - // state: SwapState.QuotesLoadedState, - // selectedFee: FeeType, - // fromToken: CryptoCurrencyStatus, - // amountToSwap: String, - // reduceBalanceBy: BigDecimal, - // ): SwapState.QuotesLoadedState { - // val amountDecimal = toBigDecimalOrNull(amountToSwap) - // if (amountDecimal == null || amountDecimal.signum() == 0) { - // return state - // } - // val amount = SwapAmount(amountDecimal, fromToken.currency.decimals) - // val includeFeeInAmount = getIncludeFeeInAmount( - // networkId = fromToken.currency.network.backendId, - // txFee = state.txFee, - // amount = amount, - // reduceBalanceBy = reduceBalanceBy, - // fromToken = fromToken.currency, - // selectedFee = selectedFee, - // ) - // val fee = when (val txFee = state.txFee) { - // TxFeeState.Empty -> BigDecimal.ZERO - // is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeIncludeOtherNativeFee - // is TxFeeState.SingleFeeState -> txFee.fee.feeIncludeOtherNativeFee - // } - // val feeState = getFeeState( - // fee = fee, - // spendAmount = amount, - // networkId = fromToken.currency.network.backendId, - // fromTokenStatus = fromToken, - // ) - // return state.copy( - // permissionState = PermissionDataState.Empty, - // preparedSwapConfigState = state.preparedSwapConfigState.copy( - // feeState = feeState, - // isBalanceEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough, - // includeFeeInAmount = includeFeeInAmount, - // ), - // ) - // } - private suspend fun onSwapDex( provider: SwapProvider, - networkId: String, swapData: SwapDataModel, currencyToSendStatus: CryptoCurrencyStatus, currencyToGetStatus: CryptoCurrencyStatus, @@ -874,7 +855,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" } val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) - val derivationPath = currencyToSendStatus.currency.network.derivationPath.value val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX val dataToSign = dexTransaction.txData val amountToSend = createNativeAmountForDex(txValue, currencyToSendStatus.currency.network) @@ -893,14 +873,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( return handleSwapResult( provider = provider, - networkId = networkId, swapData = swapData, currencyToSendStatus = currencyToSendStatus, currencyToGetStatus = currencyToGetStatus, fromAccount = fromAccount, toAccount = toAccount, amount = amount, - derivationPath = derivationPath, txData = txData, payInAddress = getPayoutAddress(txData), ) @@ -908,7 +886,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun onSwapSolanaDex( provider: SwapProvider, - networkId: String, swapData: SwapDataModel, currencyToSendStatus: CryptoCurrencyStatus, currencyToGetStatus: CryptoCurrencyStatus, @@ -920,20 +897,17 @@ internal class SwapInteractorImpl @AssistedInject constructor( val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val txDataBase64 = requireNotNull(dexTransaction?.txData) { "txData is null" } val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) - val derivationPath = currencyToSendStatus.currency.network.derivationPath.value val compiledTransaction = TransactionData.Compiled( value = TransactionData.Compiled.Data.Bytes(Base64.decode(txDataBase64, Base64.NO_WRAP)), ) return handleSwapResult( provider = provider, - networkId = networkId, swapData = swapData, currencyToSendStatus = currencyToSendStatus, currencyToGetStatus = currencyToGetStatus, fromAccount = fromAccount, toAccount = toAccount, amount = amount, - derivationPath = derivationPath, txData = compiledTransaction, payInAddress = swapData.transaction.txTo, ) @@ -941,14 +915,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun handleSwapResult( provider: SwapProvider, - networkId: String, swapData: SwapDataModel, currencyToSendStatus: CryptoCurrencyStatus, currencyToGetStatus: CryptoCurrencyStatus, fromAccount: Account.CryptoPortfolio?, toAccount: Account.CryptoPortfolio?, amount: SwapAmount, - derivationPath: String?, txData: TransactionData, payInAddress: String, ): SwapTransactionState { @@ -995,7 +967,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( currencyToGetStatus.currency.symbol, ), toAmountValue = swapData.toTokenAmount.value, - txHash = userWalletManager.getLastTransactionHash(networkId, derivationPath).orEmpty(), + txHash = txHash, timestamp = System.currentTimeMillis(), ) }, @@ -1082,10 +1054,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) { return SwapTransactionState.Error.UnknownError } - + val fee = requireNotNull(txFee) val txData = createTransferTransactionUseCase( amount = amount.value.convertToSdkAmount(currencyToSend), - fee = requireNotNull(txFee).fee, + fee = fee.fee, memo = exchangeDataCex.txExtraId, destination = exchangeDataCex.txTo, userWalletId = userWalletId, @@ -1099,13 +1071,33 @@ internal class SwapInteractorImpl @AssistedInject constructor( return SwapTransactionState.Error.UnknownError } - val result = sendTransactionUseCase( - txData = txData, - userWallet = userWallet, - network = currencyToSend.currency.network, - ) + val result = when (fee) { + is TxFee.FeeComponent -> { + if (fee.selectedToken?.currency is CryptoCurrency.Token && + fee.transactionFeeResult is TransactionFeeResult.LoadedExtended + ) { + createAndSendGaslessTransactionUseCase.invoke( + transactionData = txData, + userWallet = userWallet, + fee = fee.transactionFeeResult.fee, + ) + } else { + sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = currencyToSend.currency.network, + ) + } + } + is TxFee.Legacy -> { + sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = currencyToSend.currency.network, + ) + } + } - val derivationPath = currencyToSend.currency.network.derivationPath.value val cexNetworkAddress = currencyToSend.value.networkAddress val cexFromAddress = cexNetworkAddress?.defaultAddress?.value.orEmpty() return result.fold( @@ -1146,10 +1138,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( currencyToGet.currency.symbol, ), toAmountValue = exchangeData.toTokenAmount.value, - txHash = userWalletManager.getLastTransactionHash( - currencyToSend.currency.network.backendId, - derivationPath, - ).orEmpty(), + txHash = txHash, txExternalUrl = txExternalUrl, timestamp = timestamp, ) @@ -1194,6 +1183,113 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } + @Suppress("LongParameterList") + override suspend fun loadFeeForSwapTransaction( + fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, + amount: String, + reduceBalanceBy: BigDecimal, + provider: SwapProvider, + selectedFeeToken: CryptoCurrencyStatus?, + ): Either = either { + when (provider.type) { + ExchangeProviderType.DEX, + ExchangeProviderType.DEX_BRIDGE, + -> raise(GetFeeError.GaslessError.NetworkIsNotSupported) + ExchangeProviderType.CEX -> { + val amountDecimal = toBigDecimalOrNull(amount) + if (amountDecimal == null || amountDecimal.signum() == 0) { + raise(GetFeeError.UnknownError) + } + + return if (selectedFeeToken != null) { + estimateFeeForTokenUseCase( + userWallet = userWallet, + feeTokenCurrencyStatus = selectedFeeToken, + sendingTokenCurrencyStatus = fromToken, + amount = amountDecimal, + ) + } else { + estimateFeeForGaslessTxUseCase( + amount = amountDecimal, + userWallet = userWallet, + sendingTokenCurrencyStatus = fromToken, + ) + } + } + } + } + + override suspend fun loadFeeForSwapTransaction( + fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, + amount: String, + reduceBalanceBy: BigDecimal, + provider: SwapProvider, + ): Either = either { + return when (provider.type) { + ExchangeProviderType.DEX, + ExchangeProviderType.DEX_BRIDGE, + -> { + val fromNetworkAddress = fromToken.value.networkAddress + val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() + val toNetworkAddress = toToken.value.networkAddress + val dexToAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() + val amountBigDecimal = toBigDecimalOrNull(amount) + if (amountBigDecimal == null || amountBigDecimal.signum() == 0) { + raise(GetFeeError.UnknownError) + } + val swapAmount = SwapAmount(amountBigDecimal, fromToken.currency.decimals) + + repository.getExchangeData( + userWallet = userWallet, + fromContractAddress = fromToken.currency.getContractAddress(), + fromNetwork = fromToken.currency.network.backendId, + toContractAddress = toToken.currency.getContractAddress(), + fromAddress = dexFromAddress, + toNetwork = toToken.currency.network.backendId, + fromAmount = swapAmount.toStringWithRightOffset(), + fromDecimals = swapAmount.decimals, + toDecimals = toToken.currency.decimals, + providerId = provider.providerId, + rateType = RateType.FLOAT, + toAddress = dexToAddress, + refundAddress = fromToken.value.networkAddress?.defaultAddress?.value, + expressOperationType = ExpressOperationType.SWAP, + ).map { swapData -> + val networkId = fromToken.currency.network.backendId + val transaction = swapData.transaction as ExpressTransactionModel.DEX + + loadFeeForDex( + networkId = networkId, + transaction = transaction, + fromToken = fromToken, + ).getOrElse { raise(GetFeeError.UnknownError) } + }.mapLeft { + GetFeeError.UnknownError + } + } + ExchangeProviderType.CEX -> { + val amountDecimal = toBigDecimalOrNull(amount) + if (amountDecimal == null || amountDecimal.signum() == 0) { + raise(GetFeeError.UnknownError) + } + + estimateFeeUseCase.invoke( + amount = amountDecimal, + userWallet = userWallet, + cryptoCurrencyStatus = fromToken, + ).map { + it.patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_SEND) + } + } + } + } + private suspend fun storeLastCryptoCurrencyId(cryptoCurrency: CryptoCurrency) { swapTransactionRepository.storeLastSwappedCryptoCurrencyId( UserWalletId(userWalletManager.getWalletId()), @@ -1291,31 +1387,25 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider: SwapProvider, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, - selectedFee: FeeType, + txFeeSealedState: TxFeeSealedState, ): SwapState { val fromToken = fromTokenStatus.currency val toToken = toTokenStatus.currency return coroutineScope { - val txFeeResult = getUnhandledFee( - amount = amount.value, - userWallet = userWallet, - cryptoCurrencyStatus = fromTokenStatus, + val txFeeSealedStateUpdated = updateTxFeeStateIfNeededForCEX( + txFeeSealedState = txFeeSealedState, + amount = amount, + fromTokenStatus = fromTokenStatus, ) - val txFee = if (provider.type == ExchangeProviderType.CEX) { - getFeeForCex(txFeeResult, fromTokenStatus) - } else { - TxFeeState.Empty - } - val includeFeeInAmount = getIncludeFeeInAmount( networkId = networkId, - txFee = txFee, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromToken = fromToken, - selectedFee = selectedFee, + fromToken = fromTokenStatus, + txFeeSealedState = txFeeSealedStateUpdated, ) + val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) { includeFeeInAmount.amountSubtractFee } else { @@ -1346,14 +1436,39 @@ internal class SwapInteractorImpl @AssistedInject constructor( networkId = networkId, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - txFee = txFee, - transactionFee = txFeeResult.getOrNull(), + txFeeSealedState = txFeeSealedState, includeFeeInAmount = includeFeeInAmount, - selectedFee = selectedFee, ) } } + private suspend fun updateTxFeeStateIfNeededForCEX( + txFeeSealedState: TxFeeSealedState, + amount: SwapAmount, + fromTokenStatus: CryptoCurrencyStatus, + ): TxFeeSealedState { + return when (txFeeSealedState) { + is TxFeeSealedState.Component -> txFeeSealedState + is TxFeeSealedState.Legacy -> { + if (txFeeSealedState.txFeeState is TxFeeState.Empty) { + val txFeeResult = estimateFeeUseCase( + amount = amount.value, + userWallet = userWallet, + cryptoCurrencyStatus = fromTokenStatus, + ) + val txFee = getFeeForCex(txFeeResult, fromTokenStatus) + + TxFeeSealedState.Legacy( + txFeeState = txFee, + selectedFee = txFeeSealedState.selectedFee, + ) + } else { + txFeeSealedState + } + } + } + } + @Suppress("LongMethod") private suspend fun getQuotesState( provider: SwapProvider, @@ -1366,10 +1481,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( networkId: String, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, - txFee: TxFeeState, - transactionFee: TransactionFee?, + txFeeSealedState: TxFeeSealedState, includeFeeInAmount: IncludeFeeInAmount, - selectedFee: FeeType, ): SwapState { return quoteDataModel.fold( ifRight = { quoteModel -> @@ -1382,23 +1495,35 @@ internal class SwapInteractorImpl @AssistedInject constructor( fromTokenAmount = amount, toTokenAmount = quoteModel.toTokenAmount, swapData = null, - txFeeState = txFee, + txFeeSealedState = txFeeSealedState, provider = provider, ).copy( currencyCheck = manageWarnings( fromTokenStatus = fromToken, amount = amount, - feeState = txFee, - selectedFee = selectedFee, + txFeeSealed = txFeeSealedState, includeFeeInAmount = includeFeeInAmount, ), validationResult = manageTransactionValidationWarnings( fromToken = fromToken, amount = amount, - feeState = txFee, + txFeeSealedState = txFeeSealedState, userWalletId = userWalletId, ), - minAdaValue = (transactionFee?.normal as? Fee.CardanoToken)?.minAdaValue, + minAdaValue = when (txFeeSealedState) { + is TxFeeSealedState.Component -> { + (txFeeSealedState.txFee.fee as? Fee.CardanoToken)?.minAdaValue + } + is TxFeeSealedState.Legacy -> { + when (txFeeSealedState.txFeeState) { + TxFeeState.Empty -> null + is TxFeeState.MultipleFeeState -> + (txFeeSealedState.txFeeState.normalFee.fee as? Fee.CardanoToken)?.minAdaValue + is TxFeeState.SingleFeeState -> + (txFeeSealedState.txFeeState.fee.fee as? Fee.CardanoToken)?.minAdaValue + } + } + }, ) when (provider.type) { @@ -1423,11 +1548,17 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } ExchangeProviderType.CEX -> { - val fee = when (txFee) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue - is TxFeeState.SingleFeeState -> txFee.fee.feeValue + val fee = when (txFeeSealedState) { + is TxFeeSealedState.Component -> txFeeSealedState.txFee.fee.amount.value + is TxFeeSealedState.Legacy -> { + when (val txFee = txFeeSealedState.txFeeState) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> txFee.priorityFee.fee.amount.value + is TxFeeState.SingleFeeState -> txFee.fee.fee.amount.value + } + } } + val feeState = getFeeState( fee = fee, spendAmount = amount, @@ -1477,20 +1608,76 @@ internal class SwapInteractorImpl @AssistedInject constructor( return SwapState.SwapError(fromTokenSwapInfo, expressDataError, includeFeeInAmount) } - @Suppress("CyclomaticComplexMethod") + @Suppress("CyclomaticComplexMethod", "NestedBlockDepth", "CastNullableToNonNullableType") private suspend fun getIncludeFeeInAmount( networkId: String, - txFee: TxFeeState, + amount: SwapAmount, + reduceBalanceBy: BigDecimal, + fromToken: CryptoCurrencyStatus, + txFeeSealedState: TxFeeSealedState, + ): IncludeFeeInAmount { + return when (txFeeSealedState) { + is TxFeeSealedState.Component -> { + if (fromToken.currency.id == txFeeSealedState.txFee.selectedToken?.currency?.id) { + val fee = txFeeSealedState.txFee.fee.amount.value ?: BigDecimal.ZERO + if (txFeeSealedState.txFee.selectedToken.currency is CryptoCurrency.Coin) { + getIncludeFeeInAmountForNative( + networkId = networkId, + amount = amount, + reduceBalanceBy = reduceBalanceBy, + fromToken = fromToken.currency, + feeValue = fee, + ) + } else { + // we have a token selected for fee payment the same as sending token + val reducedBalance = fromToken.value.amount as BigDecimal - reduceBalanceBy + when { + amount.value > reducedBalance -> IncludeFeeInAmount.BalanceNotEnough + amount.value + fee <= reducedBalance -> IncludeFeeInAmount.Excluded + else -> { + if (fee < amount.value) { + IncludeFeeInAmount.Included( + amountSubtractFee = SwapAmount( + value = reducedBalance - fee, + decimals = fromToken.currency.decimals, + ), + ) + } else { + IncludeFeeInAmount.Excluded + } + } + } + } + } else { + IncludeFeeInAmount.Excluded + } + } + is TxFeeSealedState.Legacy -> { + val feeValue = when (val txFee = txFeeSealedState.txFeeState) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> txFee.getFeeByType( + txFeeSealedState.selectedFee, + ).feeIncludeOtherNativeFee + is TxFeeState.SingleFeeState -> txFee.fee.feeIncludeOtherNativeFee + } + getIncludeFeeInAmountForNative( + networkId = networkId, + amount = amount, + reduceBalanceBy = reduceBalanceBy, + fromToken = fromToken.currency, + feeValue = feeValue, + ) + } + } + } + + private suspend fun getIncludeFeeInAmountForNative( + networkId: String, amount: SwapAmount, reduceBalanceBy: BigDecimal, fromToken: CryptoCurrency, - selectedFee: FeeType, + feeValue: BigDecimal, ): IncludeFeeInAmount { - val feeValue = when (txFee) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> txFee.getFeeByType(selectedFee).feeIncludeOtherNativeFee - is TxFeeState.SingleFeeState -> txFee.fee.feeIncludeOtherNativeFee - } val feePaidCurrency = getFeePaidCurrency( currency = fromToken, ) @@ -1590,7 +1777,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toToken: CryptoCurrencyStatus, toAccount: Account.CryptoPortfolio?, amount: SwapAmount, - selectedFee: FeeType, + txFeeSealedState: TxFeeSealedState, expressOperationType: ExpressOperationType, ): SwapState { val fromNetworkAddress = fromToken.value.networkAddress @@ -1621,37 +1808,28 @@ internal class SwapInteractorImpl @AssistedInject constructor( ?.movePointLeft(nativeCoinDecimals) ?: BigDecimal.ZERO - val txFeeState = if (isSolana(networkId)) { - val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP) - - val formattedHash = getFormattedHash(transactionBytes) - - if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES && userWallet is UserWallet.Cold) { - return produceDexSwapDataError( - error = ExpressDataError.TooLargeSolanaTransactionError, - fromToken = fromToken, - fromAccount = fromAccount, - amount = amount, - ) - } - - getFeeDataForSolanaDexSwap( - network = fromToken.currency.network, - transactionBytes = transactionBytes, + val txFeeState = loadFeeForDex( + networkId = networkId, + transaction = transaction, + fromToken = fromToken, + ).getOrElse { error -> + return@fold produceDexSwapDataError( + error = error, + fromToken = fromToken, + fromAccount = fromAccount, + amount = amount, ) - .toTxFeeState(fromToken.currency, otherNativeFee) - } else { - getFeeDataForDexSwap( - network = fromToken.currency.network, - transaction = transaction, - fromToken = fromToken.currency, - ) - .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) - .toTxFeeState(fromToken.currency, otherNativeFee) - } + }.toTxFeeState(fromToken.currency, otherNativeFee) val includeFeeInAmount = IncludeFeeInAmount.Excluded // exclude for dex - val feeByPriority = selectFeeByType(feeType = selectedFee, txFeeState = txFeeState) + val feeByPriority = when (txFeeSealedState) { + is TxFeeSealedState.Component -> { + txFeeSealedState.txFee.fee.amount.value ?: BigDecimal.ZERO + } + is TxFeeSealedState.Legacy -> { + selectFeeByType(feeType = txFeeSealedState.selectedFee, txFeeState = txFeeState) + } + } val feeToCheckFunds = feeByPriority + (otherNativeFee ?: BigDecimal.ZERO) val isBalanceIncludeFeeEnough = isBalanceEnough(fromToken, amount, feeToCheckFunds) val feeState = getFeeState( @@ -1676,7 +1854,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( fromTokenAmount = amount, toTokenAmount = swapData.toTokenAmount, swapData = swapData, - txFeeState = txFeeState, + txFeeSealedState = txFeeSealedState, provider = provider, ) swapState.copy( @@ -1684,14 +1862,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( currencyCheck = manageWarnings( fromTokenStatus = fromToken, amount = amount, - feeState = txFeeState, - selectedFee = selectedFee, + txFeeSealed = txFeeSealedState, includeFeeInAmount = includeFeeInAmount, ), validationResult = manageTransactionValidationWarnings( fromToken = fromToken, amount = amount, - feeState = txFeeState, + txFeeSealedState = txFeeSealedState, userWalletId = userWalletId, ), preparedSwapConfigState = preparedSwapConfigState, @@ -1708,6 +1885,36 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } + private suspend fun loadFeeForDex( + networkId: String, + transaction: ExpressTransactionModel.DEX, + fromToken: CryptoCurrencyStatus, + ): Either = either { + if (isSolana(networkId)) { + val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP) + + val formattedHash = getFormattedHash(transactionBytes) + + if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES && userWallet is UserWallet.Cold) { + raise(ExpressDataError.TooLargeSolanaTransactionError) + } + + getFeeDataForSolanaDexSwap( + network = fromToken.currency.network, + transactionBytes = transactionBytes, + ) + } else { + getFeeDataForDexSwap( + network = fromToken.currency.network, + transaction = transaction, + fromToken = fromToken.currency, + ).map { fee -> + (fee as TransactionFeeResult.Loaded).fee + .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) + }.bind() + } + } + private suspend fun produceDexSwapDataError( error: ExpressDataError, fromToken: CryptoCurrencyStatus, @@ -1729,18 +1936,27 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } + @Suppress("CyclomaticComplexMethod") private suspend fun getFeeDataForDexSwap( network: Network, transaction: ExpressTransactionModel.DEX, fromToken: CryptoCurrency, - ): TransactionFee { - return try { + selectedToken: CryptoCurrencyStatus? = null, + ): Either = either { + val nativeBalance = userWalletManager.getNativeTokenBalance( + networkId = network.backendId, + derivationPath = fromToken.network.derivationPath.value, + ) ?: ProxyAmount.empty() + + // if native balance is zero - we can't calculate fee + if (nativeBalance.value.signum() == 0) { + raise(ExpressDataError.UnknownError) + } + + try { val txAmountValue = transaction.txValue ?: error("unable to get txValue") - val nativeBalance = userWalletManager.getNativeTokenBalance( - networkId = network.backendId, - derivationPath = fromToken.network.derivationPath.value, - ) ?: ProxyAmount.empty() val amountToSend = createNativeAmountForDex(txAmountValue, fromToken.network) + // transaction.txValue is always native coin if (nativeBalance.value < amountToSend.value) { error("It's impossible to calculate fee for nativeBalance.value < amountToSend.value") @@ -1758,17 +1974,27 @@ internal class SwapInteractorImpl @AssistedInject constructor( sourceAddress = transaction.txFrom, extras = extras, ) - getFeeUseCase( - transactionData = transactionData, - network = network, - userWallet = userWallet, - ).getOrNull() ?: error("unable to calculate fee") + if (selectedToken != null && selectedToken.currency is CryptoCurrency.Token) { + getFeeForTokenUseCase( + transactionData = transactionData, + token = selectedToken.currency, + userWallet = userWallet, + ).getOrNull()?.let { TransactionFeeResult.LoadedExtended(it) } + ?: error("unable to calculate fee for token") + } else { + getFeeUseCase( + transactionData = transactionData, + network = network, + userWallet = userWallet, + ).getOrNull()?.let { TransactionFeeResult.Loaded(it) } ?: error("unable to calculate fee") + } } catch (_: IllegalStateException) { getEthSpecificFeeUseCase( userWallet = userWallet, cryptoCurrency = fromToken, gasLimit = transaction.gas, - ).getOrNull() ?: error("can't get fee for getEthSpecificFeeUseCase") + ).getOrNull()?.let { TransactionFeeResult.Loaded(it) } + ?: error("can't get fee for getEthSpecificFeeUseCase") } } @@ -1784,7 +2010,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ).getOrNull() ?: error("unable to calculate fee") } - @Suppress("LongParameterList") + @Suppress("LongParameterList", "MaxChainedCallsOnSameLine") private suspend fun updateBalances( provider: SwapProvider, networkId: String, @@ -1795,7 +2021,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( fromTokenAmount: SwapAmount, toTokenAmount: SwapAmount, swapData: SwapDataModel?, - txFeeState: TxFeeState, + txFeeSealedState: TxFeeSealedState, ): SwapState.QuotesLoadedState { val fromToken = fromTokenStatus.currency val toToken = toTokenStatus.currency @@ -1823,8 +2049,18 @@ internal class SwapInteractorImpl @AssistedInject constructor( toRate = rates[toToken.id]?.toDouble() ?: 0.0, ), swapDataModel = swapData, - txFee = txFeeState, swapProvider = provider, + txFee = when (txFeeSealedState) { + is TxFeeSealedState.Component -> { + when (txFeeSealedState.txFee.transactionFeeResult) { + is TransactionFeeResult.Loaded -> + txFeeSealedState.txFee.transactionFeeResult.fee.toTxFeeState(fromToken, null) + is TransactionFeeResult.LoadedExtended -> + txFeeSealedState.txFee.transactionFeeResult.fee.transactionFee.toTxFeeState(fromToken, null) + } + } + is TxFeeSealedState.Legacy -> txFeeSealedState.txFeeState + }, minAdaValue = null, ) } @@ -1843,18 +2079,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) ?: TxFeeState.Empty } - private suspend fun getUnhandledFee( - amount: BigDecimal, - userWallet: UserWallet, - cryptoCurrencyStatus: CryptoCurrencyStatus, - ): Either { - return estimateFeeUseCase( - amount = amount, - userWallet = userWallet, - cryptoCurrencyStatus = cryptoCurrencyStatus, - ) - } - @Suppress("LongParameterList", "LongMethod") private suspend fun updatePermissionState( networkId: String, @@ -1929,8 +2153,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( val fee = when (feeState) { TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> feeState.normalFee.feeValue - is TxFeeState.SingleFeeState -> feeState.fee.feeValue + is TxFeeState.MultipleFeeState -> feeState.normalFee.fee.amount.value + is TxFeeState.SingleFeeState -> feeState.fee.fee.amount.value } val swapFeeState = getFeeState( fee = fee, @@ -1994,7 +2218,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) // endregion TxFeeState.MultipleFeeState( - normalFee = TxFee( + normalFee = TxFee.Legacy( feeValue = feeNormal, feeFiatFormatted = normalFiatValue, feeCryptoFormatted = normalCryptoFee, @@ -2005,7 +2229,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( feeType = FeeType.NORMAL, fee = this.normal, ), - priorityFee = TxFee( + priorityFee = TxFee.Legacy( feeValue = feePriority, feeFiatFormatted = priorityFiatValue, feeCryptoFormatted = priorityCryptoFee, @@ -2035,7 +2259,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) // endregion TxFeeState.SingleFeeState( - fee = TxFee( + fee = TxFee.Legacy( feeValue = this.normal.amount.value ?: BigDecimal.ZERO, feeFiatFormatted = normalFiatValue, feeCryptoFormatted = normalCryptoFee, @@ -2145,12 +2369,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): BigDecimal { return when (txFeeState) { TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.SingleFeeState -> txFeeState.fee.feeValue + is TxFeeState.SingleFeeState -> txFeeState.fee.fee.amount.value is TxFeeState.MultipleFeeState -> when (feeType) { - FeeType.NORMAL -> txFeeState.normalFee.feeValue - FeeType.PRIORITY -> txFeeState.priorityFee.feeValue + FeeType.NORMAL -> txFeeState.normalFee.fee.amount.value + FeeType.PRIORITY -> txFeeState.priorityFee.fee.amount.value } - } + } ?: BigDecimal.ZERO } private suspend fun isBalanceEnough( @@ -2378,4 +2602,24 @@ internal class SwapInteractorImpl @AssistedInject constructor( interface Factory : SwapInteractor.Factory { override fun create(selectedWalletId: UserWalletId): SwapInteractorImpl } +} + +sealed class TxFeeSealedState { + class Legacy(val txFeeState: TxFeeState, val selectedFee: FeeType) : TxFeeSealedState() + class Component(val txFee: TxFee.FeeComponent) : TxFeeSealedState() + + fun getTxFeeStateOrNull() = when (this) { + is Component -> null + is Legacy -> txFeeState + } +} + +sealed class TransactionFeeResult { + class Loaded(val fee: TransactionFee) : TransactionFeeResult() + class LoadedExtended(val fee: TransactionFeeExtended) : TransactionFeeResult() + + companion object { + fun from(fee: TransactionFee) = Loaded(fee) + fun from(fee: TransactionFeeExtended) = LoadedExtended(fee) + } } \ No newline at end of file diff --git a/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt similarity index 87% rename from features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt index 7473ad49ae..fedf814baa 100644 --- a/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt @@ -6,7 +6,12 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.swap.domain.models.ExpressDataError -import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel +import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo +import com.tangem.feature.swap.domain.models.domain.PairsWithProviders +import com.tangem.feature.swap.domain.models.domain.QuoteModel +import com.tangem.feature.swap.domain.models.domain.RateType +import com.tangem.feature.swap.domain.models.domain.SwapDataModel import java.math.BigDecimal interface SwapRepository { diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ExpressDataError.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ExpressDataError.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ExpressDataError.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ExpressDataError.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ExpressException.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ExpressException.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ExpressException.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ExpressException.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/SwapAmount.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/SwapAmount.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/SwapAmount.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/SwapAmount.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PairsWithProviders.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PairsWithProviders.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PairsWithProviders.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PairsWithProviders.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt diff --git a/features/swap/domain/models/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 similarity index 80% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 7c76557067..c735c821d4 100644 --- a/features/swap/domain/models/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 @@ -4,6 +4,7 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.* @@ -91,11 +92,11 @@ data class RequestApproveStateData( sealed class TxFeeState { data class MultipleFeeState( - val normalFee: TxFee, - val priorityFee: TxFee, + val normalFee: TxFee.Legacy, + val priorityFee: TxFee.Legacy, ) : TxFeeState() { - fun getFeeByType(feeType: FeeType): TxFee { + fun getFeeByType(feeType: FeeType): TxFee.Legacy { return when (feeType) { FeeType.NORMAL -> normalFee FeeType.PRIORITY -> priorityFee @@ -104,23 +105,33 @@ sealed class TxFeeState { } data class SingleFeeState( - val fee: TxFee, + val fee: TxFee.Legacy, ) : TxFeeState() data object Empty : TxFeeState() } -data class TxFee( - val feeValue: BigDecimal, - val feeFiatFormatted: String, - val feeCryptoFormatted: String, - val feeIncludeOtherNativeFee: BigDecimal, - val feeFiatFormattedWithNative: String, - val feeCryptoFormattedWithNative: String, - val cryptoSymbol: String, - val feeType: FeeType, - val fee: Fee, -) +sealed class TxFee { + abstract val fee: Fee + + data class FeeComponent( + override val fee: Fee, + val transactionFeeResult: TransactionFeeResult, + val selectedToken: CryptoCurrencyStatus?, + ) : TxFee() + + data class Legacy( + val feeValue: BigDecimal, + val feeFiatFormatted: String, + val feeCryptoFormatted: String, + val feeIncludeOtherNativeFee: BigDecimal, + val feeFiatFormattedWithNative: String, + val feeCryptoFormattedWithNative: String, + val cryptoSymbol: String, + val feeType: FeeType, + override val fee: Fee, + ) : TxFee() +} enum class FeeType { NORMAL, PRIORITY diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index ebb3536b3e..c31fc810fd 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -58,6 +58,8 @@ dependencies { implementation(projects.features.swap.domain.models) implementation(projects.features.wallet.api) implementation(projects.features.swap.api) + implementation(projects.features.sendV2.api) + implementation(projects.features.sendV2.impl) /** AndroidX */ implementation(deps.androidx.activity.compose) @@ -87,6 +89,10 @@ dependencies { implementation(deps.kotlin.serialization) implementation(deps.kotlin.immutable.collections) implementation(deps.timber) + implementation(deps.decompose.ext.compose) + + /** Tangem libs */ + implementation(tangemDeps.blockchain) /** DI */ implementation(deps.hilt.android) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 65c5f62d82..b391c87b53 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -3,17 +3,31 @@ package com.tangem.feature.swap import androidx.compose.animation.Crossfade import androidx.compose.foundation.background import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss import com.arkivanov.essenty.lifecycle.subscribe import com.tangem.common.ui.swapStoriesScreen.SwapStoriesScreen import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.model.SwapModel +import com.tangem.feature.swap.models.SwapCardState.SwapCardData import com.tangem.feature.swap.router.SwapNavScreen import com.tangem.feature.swap.ui.SwapScreen import com.tangem.feature.swap.ui.SwapSelectTokenScreen import com.tangem.feature.swap.ui.SwapSuccessScreen +import com.tangem.features.send.v2.api.SendFeatureToggles +import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents import com.tangem.features.swap.SwapComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -22,7 +36,9 @@ import dagger.assisted.AssistedInject @Suppress("UnusedPrivateMember") internal class DefaultSwapComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, - @Assisted params: SwapComponent.Params, + @Assisted private val params: SwapComponent.Params, + private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory, + private val sendFeatureToggles: SendFeatureToggles, ) : SwapComponent, AppComponentContext by appComponentContext { private val model: SwapModel = getOrCreateModel(params) @@ -34,8 +50,71 @@ internal class DefaultSwapComponent @AssistedInject constructor( ) } + val slotNavigation = SlotNavigation() + val childSlot = childSlot( + source = slotNavigation, + serializer = null, + childFactory = { config, context -> + createSwapFeeSelectorBlockComponent( + context = childByContext(context), + config = config, + ) + }, + ) + + private fun createSwapFeeSelectorBlockComponent( + context: AppComponentContext, + config: FeeSelectorConfig, + ): SwapFeeSelectorBlockComponent { + return swapFeeSelectorBlockComponentFactory.create( + context = context, + params = SwapFeeSelectorBlockComponent.Params( + repository = model.feeSelectorRepository, + userWalletId = params.userWalletId, + sendingCryptoCurrencyStatus = config.sendingCurrencyStatus, + feeCryptoCurrencyStatus = config.feeCurrencyStatus, + analyticsParams = SwapFeeSelectorBlockComponent.AnalyticsParams( + analyticsCategoryName = CommonSendAnalyticEvents.SEND_CATEGORY, + analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Swap, + ), + ), + ) + } + + data class FeeSelectorConfig( + val sendingCurrencyStatus: CryptoCurrencyStatus, + val feeCurrencyStatus: CryptoCurrencyStatus, + ) + + @Suppress("LongMethod") @Composable override fun Content(modifier: Modifier) { + if (sendFeatureToggles.isGaslessTransactionsEnabled) { + val sendCardData = model.uiState.sendCardData as? SwapCardData + val feePaidCryptoCurrency = model.dataState.feePaidCryptoCurrency + LaunchedEffect(sendCardData?.token?.currency, feePaidCryptoCurrency?.currency) { + val sendingCryptoCurrencyStatus = sendCardData?.token ?: run { + slotNavigation.dismiss() + return@LaunchedEffect + } + + val feeCurrencyStatus = feePaidCryptoCurrency ?: run { + slotNavigation.dismiss() + return@LaunchedEffect + } + + slotNavigation.activate( + FeeSelectorConfig( + sendingCurrencyStatus = sendingCryptoCurrencyStatus, + feeCurrencyStatus = feeCurrencyStatus, + ), + ) + } + } + + val feeSelectorChildStackState by childSlot.subscribeAsState() + val feeSelectorBlockComponent = feeSelectorChildStackState.child?.instance + Crossfade( modifier = Modifier.background(TangemTheme.colors.background.secondary), targetState = model.currentScreen, @@ -47,16 +126,30 @@ internal class DefaultSwapComponent @AssistedInject constructor( if (storiesConfig != null) { SwapStoriesScreen(config = storiesConfig) } else { - SwapScreen(stateHolder = model.uiState) + SwapScreen( + stateHolder = model.uiState, + feeSelectorBlockComponent = feeSelectorBlockComponent, + ) } } - SwapNavScreen.Main -> SwapScreen(stateHolder = model.uiState) + SwapNavScreen.Main -> SwapScreen( + stateHolder = model.uiState, + feeSelectorBlockComponent = feeSelectorBlockComponent, + ) SwapNavScreen.Success -> { val successState = model.uiState.successState + val feeSelectorState by model.feeSelectorRepository.state.collectAsStateWithLifecycle() if (successState != null) { - SwapSuccessScreen(state = successState, model.uiState.onBackClicked) + SwapSuccessScreen( + state = successState, + feeSelectorUM = feeSelectorState, + onBack = model.uiState.onBackClicked, + ) } else { - SwapScreen(stateHolder = model.uiState) + SwapScreen( + stateHolder = model.uiState, + feeSelectorBlockComponent = feeSelectorBlockComponent, + ) } } SwapNavScreen.SelectToken -> { @@ -64,7 +157,10 @@ internal class DefaultSwapComponent @AssistedInject constructor( if (tokenState != null) { SwapSelectTokenScreen(state = tokenState, onBack = model.uiState.onBackClicked) } else { - SwapScreen(stateHolder = model.uiState) + SwapScreen( + stateHolder = model.uiState, + feeSelectorBlockComponent = feeSelectorBlockComponent, + ) } } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt new file mode 100644 index 0000000000..19e1760238 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt @@ -0,0 +1,99 @@ +package com.tangem.feature.swap.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import arrow.core.Either +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.features.send.v2.api.FeeSelectorBlockComponent +import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.api.params.FeeSelectorParams +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class SwapFeeSelectorBlockComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: Params, + feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val feeSelectorBlockComponent = + feeSelectorBlockComponentFactory.create( + context = child("swapFeeSelectorBlock"), + params = FeeSelectorParams.FeeSelectorBlockParams( + state = params.repository.state.value, + userWalletId = params.userWalletId, + onLoadFee = params.repository::loadFee, + onLoadFeeExtended = if (params.repository is ModelRepositoryExtended) { + params.repository::loadFeeExtended + } else { + null + }, + feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen, + feeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.ExcludeLow, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, + cryptoCurrencyStatus = params.sendingCryptoCurrencyStatus, + analyticsCategoryName = params.analyticsParams.analyticsCategoryName, + analyticsSendSource = params.analyticsParams.analyticsSendSource, + bottomSheetShown = params.repository::choosingInProgress, + ), + onResult = params.repository::onResult, + ) + + init { + params.repository.state + .onEach(feeSelectorBlockComponent::updateState) + .launchIn(componentScope) + } + + @Composable + override fun Content(modifier: Modifier) { + feeSelectorBlockComponent.Content(modifier = modifier) + } + + interface ModelRepository { + val state: StateFlow + get() = MutableStateFlow(FeeSelectorUM.Loading) + + fun onResult(newState: FeeSelectorUM) + + suspend fun loadFee(): Either + + fun choosingInProgress(updatedState: Boolean) + } + + interface ModelRepositoryExtended : ModelRepository { + suspend fun loadFeeExtended( + selectedToken: CryptoCurrencyStatus? = null, + ): Either + } + + class AnalyticsParams( + val analyticsCategoryName: String, + val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, + ) + + class Params( + val userWalletId: UserWalletId, + val sendingCryptoCurrencyStatus: CryptoCurrencyStatus, + val feeCryptoCurrencyStatus: CryptoCurrencyStatus, + val analyticsParams: AnalyticsParams, + val repository: ModelRepository, + ) + + @AssistedFactory + interface Factory : ComponentFactory +} \ No newline at end of file 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 b887bb2de7..adc9b652e3 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 @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import arrow.core.Either import arrow.core.getOrElse +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.routing.AppRouter import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState.InProgress.getApproveTypeOrNull @@ -52,17 +53,23 @@ import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.analytics.StoriesEvents import com.tangem.feature.swap.analytics.SwapEvents +import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.domain.SwapInteractor +import com.tangem.feature.swap.domain.TransactionFeeResult +import com.tangem.feature.swap.domain.TxFeeSealedState import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.ExpressException import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.* +import com.tangem.feature.swap.models.SwapCardState import com.tangem.feature.swap.models.SwapStateHolder import com.tangem.feature.swap.models.UiActions import com.tangem.feature.swap.models.market.SwapMarketsListBatchFlowManager @@ -73,6 +80,9 @@ import com.tangem.feature.swap.router.SwapNavScreen import com.tangem.feature.swap.router.SwapRouter import com.tangem.feature.swap.ui.StateBuilder import com.tangem.feature.swap.utils.formatToUIRepresentation +import com.tangem.features.send.v2.api.SendFeatureToggles +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.swap.SwapComponent import com.tangem.features.swap.SwapFeatureToggles import com.tangem.utils.Provider @@ -124,6 +134,8 @@ internal class SwapModel @Inject constructor( private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase, private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, + private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, + private val sendFeatureToggles: SendFeatureToggles, private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, private val swapFeatureToggles: SwapFeatureToggles, ) : Model() { @@ -169,7 +181,7 @@ internal class SwapModel @Inject constructor( private val searchDebouncer = Debouncer() private val singleTaskScheduler = SingleTaskScheduler>() - private var dataState by mutableStateOf(SwapProcessDataState()) + var dataState by mutableStateOf(SwapProcessDataState()) var uiState: SwapStateHolder by mutableStateOf( stateBuilder.createInitialLoadingState( @@ -180,6 +192,8 @@ internal class SwapModel @Inject constructor( ) private set + val feeSelectorRepository = FeeSelectorRepository() + // shows currency order (direct - swap initial to selected, reversed = selected to initial) private var isOrderReversed = false private val lastAmount = mutableStateOf(INITIAL_AMOUNT) @@ -359,6 +373,7 @@ internal class SwapModel @Inject constructor( analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(hasAvailableTokens = isAnyAvailableTokens)) } + @Suppress("LongMethod") private fun initTokens(isReverseFromTo: Boolean) { modelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { @@ -394,6 +409,21 @@ internal class SwapModel @Inject constructor( isReverseFromTo = isReverseFromTo, ) + val fromCryptoCurrency = if (isOrderReversed) { + dataState.toCryptoCurrency + } else { + dataState.fromCryptoCurrency + } + + fromCryptoCurrency?.let { cryptoCurrency -> + dataState = dataState.copy( + feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoCurrency, + ).getOrNull(), + ) + } + (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> subscribeToCoinBalanceUpdates( userWalletId = userWalletId, @@ -524,6 +554,7 @@ internal class SwapModel @Inject constructor( reduceBalanceBy: BigDecimal, toProvidersList: List, isSilent: Boolean = false, + updateFeeBlock: Boolean = true, ) { singleTaskScheduler.cancelTask() if (!isSilent) { @@ -535,6 +566,7 @@ internal class SwapModel @Inject constructor( toAccount = toAccount, mainTokenId = initialCurrencyFrom.id.value, ) + feeSelectorRepository.state.value = FeeSelectorUM.Loading } singleTaskScheduler.scheduleTask( modelScope, @@ -546,11 +578,12 @@ internal class SwapModel @Inject constructor( amount = amount, reduceBalanceBy = reduceBalanceBy, toProvidersList = toProvidersList, + updateFeeBlock = updateFeeBlock, ), ) } - private fun startLoadingQuotesFromLastState(isSilent: Boolean = false) { + private fun startLoadingQuotesFromLastState(isSilent: Boolean = false, updateFeeBlock: Boolean = true) { val fromCurrency = dataState.fromCryptoCurrency val toCurrency = dataState.toCryptoCurrency val amount = dataState.amount @@ -564,6 +597,7 @@ internal class SwapModel @Inject constructor( isSilent = isSilent, reduceBalanceBy = dataState.reduceBalanceBy, toProvidersList = findSwapProviders(fromCurrency, toCurrency), + updateFeeBlock = updateFeeBlock, ) } } @@ -576,6 +610,7 @@ internal class SwapModel @Inject constructor( amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, + updateFeeBlock: Boolean = true, ): PeriodicTask> { return PeriodicTask( delay = UPDATE_DELAY, @@ -596,7 +631,7 @@ internal class SwapModel @Inject constructor( providers = toProvidersList, amountToSwap = amount, reduceBalanceBy = reduceBalanceBy, - selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL, + txFeeSealedState = getSelectedFeeState(), ) } }, @@ -612,12 +647,17 @@ internal class SwapModel @Inject constructor( tokenSwapInfoForProviders = successStates.entries .associate { it.key.providerId to it.value.toTokenInfo }, ) + if (updateFeeBlock) { + modelScope.launch { feeSelectorReloadTrigger.triggerUpdate() } + } } else { + feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError) Timber.e("Accidentally empty quotes list") } }, onError = { error -> Timber.e("Error when loading quotes: $error") + feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError) uiState = stateBuilder.addNotification(uiState, null) { startLoadingQuotesFromLastState() } }, ) @@ -657,7 +697,7 @@ internal class SwapModel @Inject constructor( swapProvider = provider, bestRatedProviderId = bestRatedProviderId, isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1, - selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL, + selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, isReverseSwapPossible = isReverseSwapPossible(), needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), hideFee = tangemPayInput?.isWithdrawal == true, @@ -799,8 +839,8 @@ internal class SwapModel @Inject constructor( } } - private fun updateOrSelectFee(state: SwapState.QuotesLoadedState): TxFee? { - val selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL + private fun updateOrSelectFee(state: SwapState.QuotesLoadedState): TxFee.Legacy? { + val selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL return when (val txFee = state.txFee) { TxFeeState.Empty -> null is TxFeeState.MultipleFeeState -> { @@ -827,7 +867,7 @@ internal class SwapModel @Inject constructor( return } val fromCurrency = requireNotNull(dataState.fromCryptoCurrency) - val fee = dataState.selectedFee + val fee = getSelectedFee() if (fee == null && tangemPayInput?.isWithdrawal != true) { makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) @@ -855,7 +895,10 @@ internal class SwapModel @Inject constructor( makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) return@onSuccess } - sendSuccessSwapEvent(fromCurrency.currency, fee.feeType) + sendSuccessSwapEvent( + fromCurrency.currency, + (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, + ) val url = getExplorerTransactionUrlUseCase( txHash = swapTransactionState.txHash, networkId = fromCurrency.currency.network.id, @@ -967,7 +1010,7 @@ internal class SwapModel @Inject constructor( private fun sendSuccessEvent() { val provider = dataState.selectedProvider ?: return - val fee = dataState.selectedFee?.feeType ?: return + val fee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL val fromCurrency = dataState.fromCryptoCurrency?.currency ?: return val toCurrency = dataState.toCryptoCurrency?.currency ?: return val fromDerivationIndex = dataState.fromAccount?.derivationIndex?.value @@ -1027,6 +1070,7 @@ internal class SwapModel @Inject constructor( }.onSuccess { swapTransactionState -> when (swapTransactionState) { is SwapTransactionState.TxSent -> { + // TODO [REDACTED_TASK_KEY] gasless analytics sendApproveSuccessEvent(fromToken, feeForPermission.feeType, approveType) updateWalletBalance() uiState = stateBuilder.loadingPermissionState(uiState) @@ -1242,12 +1286,14 @@ internal class SwapModel @Inject constructor( .onEach { (account, currencyStatus) -> Timber.d("${coin.id} balance is ${currencyStatus.value.amount ?: "null"}") - dataState = dataState.copy( - feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = currencyStatus, - ).getOrNull() ?: currencyStatus, - ) + if (isFromCurrency) { + dataState = dataState.copy( + feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = currencyStatus, + ).getOrNull() ?: currencyStatus, + ) + } uiState = when { isFromCurrency && currencyStatus.currency.id == dataState.fromCryptoCurrency?.currency?.id -> { @@ -1280,12 +1326,14 @@ internal class SwapModel @Inject constructor( .onEach { status -> Timber.d("${coin.id} balance is ${status.value.amount ?: "null"}") - dataState = dataState.copy( - feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = status, - ).getOrNull() ?: status, - ) + if (isFromCurrency) { + dataState = dataState.copy( + feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = status, + ).getOrNull() ?: status, + ) + } uiState = when { isFromCurrency && status.currency.id == dataState.fromCryptoCurrency?.currency?.id -> { @@ -1492,7 +1540,7 @@ internal class SwapModel @Inject constructor( uiState = stateBuilder.updateApproveType(uiState, approveType) }, onClickFee = { - val selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL + val selectedFee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL val txFeeState = dataState.getCurrentLoadedSwapState()?.txFee as? TxFeeState.MultipleFeeState ?: return@UiActions uiState = stateBuilder.showSelectFeeBottomSheet( @@ -1503,9 +1551,9 @@ internal class SwapModel @Inject constructor( uiState = stateBuilder.dismissBottomSheet(uiState) } }, - onSelectFeeType = { feeType -> + onSelectFeeType = { txFee -> uiState = stateBuilder.dismissBottomSheet(uiState) - dataState = dataState.copy(selectedFee = feeType) + dataState = dataState.copy(selectedFee = txFee) modelScope.launch(dispatchers.io) { startLoadingQuotesFromLastState(false) } @@ -1527,6 +1575,10 @@ internal class SwapModel @Inject constructor( val swapState = dataState.lastLoadedSwapStates[provider] val fromToken = dataState.fromCryptoCurrency if (provider != null && swapState != null && fromToken != null) { + modelScope.launch { + feeSelectorRepository.state.value = FeeSelectorUM.Loading + feeSelectorReloadTrigger.triggerUpdate() + } analyticsEventHandler.send(SwapEvents.ProviderChosen(provider)) uiState = stateBuilder.dismissBottomSheet(uiState) setupLoadedState( @@ -1860,7 +1912,11 @@ internal class SwapModel @Inject constructor( destinationAddress = transaction?.txTo.orEmpty(), tokenSymbol = fromCurrencyStatus.currency.symbol, amount = dataState.amount.orEmpty(), - fee = dataState.selectedFee?.feeCryptoFormatted.orEmpty(), + fee = when (val fee = getSelectedFee()) { + is TxFee.FeeComponent -> fee.fee.amount.value?.toString() + is TxFee.Legacy -> fee.feeCryptoFormatted + null -> "" + }, ), ) @@ -1900,6 +1956,137 @@ internal class SwapModel @Inject constructor( } } + private fun getSelectedFeeState(): TxFeeSealedState { + if (!sendFeatureToggles.isGaslessTransactionsEnabled) { + return TxFeeSealedState.Legacy( + txFeeState = TxFeeState.Empty, + selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL, + ) + } + + val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content + ?: return TxFeeSealedState.Legacy( + txFeeState = TxFeeState.Empty, + selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL, + ) + + val transactionFeeExtended = feeStateUM.feeExtraInfo.transactionFeeExtended + return TxFeeSealedState.Component( + txFee = TxFee.FeeComponent( + transactionFeeResult = transactionFeeExtended?.let { TransactionFeeResult.from(it) } + ?: TransactionFeeResult.from(feeStateUM.fees), + fee = feeStateUM.selectedFeeItem.fee, + selectedToken = feeStateUM.feeExtraInfo.feeCryptoCurrencyStatus, + ), + ) + } + + private fun getSelectedFee(): TxFee? { + if (!sendFeatureToggles.isGaslessTransactionsEnabled) { + return dataState.selectedFee + } + + val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content ?: return null + val transactionFeeExtended = feeStateUM.feeExtraInfo.transactionFeeExtended + + return TxFee.FeeComponent( + transactionFeeResult = transactionFeeExtended?.let { TransactionFeeResult.from(it) } + ?: TransactionFeeResult.from(feeStateUM.fees), + fee = feeStateUM.selectedFeeItem.fee, + selectedToken = feeStateUM.feeExtraInfo.feeCryptoCurrencyStatus, + ) + } + + inner class FeeSelectorRepository : SwapFeeSelectorBlockComponent.ModelRepositoryExtended { + + override val state = MutableStateFlow(FeeSelectorUM.Loading) + + override suspend fun loadFeeExtended( + selectedToken: CryptoCurrencyStatus?, + ): Either { + val sendCardData = + uiState.sendCardData as? SwapCardState.SwapCardData ?: return Either.Left(GetFeeError.UnknownError) + val receiveCardData = + uiState.receiveCardData as? SwapCardState.SwapCardData ?: return Either.Left(GetFeeError.UnknownError) + val fromToken = sendCardData.token ?: return Either.Left(GetFeeError.UnknownError) + val toToken = receiveCardData.token ?: return Either.Left(GetFeeError.UnknownError) + val selectedProvider = dataState.selectedProvider ?: return Either.Left(GetFeeError.UnknownError) + + if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) { + return Either.Left(GetFeeError.UnknownError) + } + + return swapInteractor.loadFeeForSwapTransaction( + fromToken = fromToken, + fromAccount = dataState.fromAccount, + toToken = toToken, + toAccount = dataState.toAccount, + provider = selectedProvider, + amount = lastAmount.value, + reduceBalanceBy = lastReducedBalanceBy.value, + selectedFeeToken = selectedToken, + ) + } + + override fun onResult(newState: FeeSelectorUM) { + state.value = newState + + // If fee currency is same as from currency, we need to reload quotes to update fee info + if (newState is FeeSelectorUM.Content && + dataState.fromCryptoCurrency?.currency?.id == newState.feeExtraInfo.feeCryptoCurrencyStatus.currency.id + ) { + // block swap button until fee is loaded + uiState = uiState.copy( + swapButton = uiState.swapButton.copy( + isEnabled = false, + ), + ) + modelScope.launch { + startLoadingQuotesFromLastState( + isSilent = true, + updateFeeBlock = false, + ) + } + } + } + + override suspend fun loadFee(): Either { + val sendCardData = + uiState.sendCardData as? SwapCardState.SwapCardData ?: return Either.Left(GetFeeError.UnknownError) + val receiveCardData = + uiState.receiveCardData as? SwapCardState.SwapCardData ?: return Either.Left(GetFeeError.UnknownError) + val fromToken = sendCardData.token ?: return Either.Left(GetFeeError.UnknownError) + val toToken = receiveCardData.token ?: return Either.Left(GetFeeError.UnknownError) + val selectedProvider = dataState.selectedProvider ?: return Either.Left(GetFeeError.UnknownError) + + if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) { + return Either.Left(GetFeeError.UnknownError) + } + + return swapInteractor.loadFeeForSwapTransaction( + fromToken = fromToken, + fromAccount = dataState.fromAccount, + toToken = toToken, + toAccount = dataState.toAccount, + provider = selectedProvider, + amount = lastAmount.value, + reduceBalanceBy = lastReducedBalanceBy.value, + ) + } + + override fun choosingInProgress(updatedState: Boolean) { + // We shouldn't load quotes while user is choosing fee + if (updatedState) { + singleTaskScheduler.cancelTask() + } else { + startLoadingQuotesFromLastState( + isSilent = true, + updateFeeBlock = false, + ) + } + } + } + private companion object { const val INITIAL_AMOUNT = "" const val UPDATE_DELAY = 10000L 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 eafe9d8abb..f7a1817cb3 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 @@ -15,6 +15,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount import com.tangem.feature.swap.domain.models.domain.SwapFeeState import com.tangem.feature.swap.domain.models.ui.* @@ -164,7 +165,7 @@ internal class SwapNotificationsFactory( addExistentialWarningNotification( existentialDeposit = quoteModel.currencyCheck?.existentialDeposit, - feeAmount = fee?.feeValue.orZero(), + feeAmount = fee?.fee?.amount?.value.orZero(), sendingAmount = amountToRequest.value, cryptoCurrencyStatus = fromCurrencyStatus, onReduceClick = { reduceBy, reduceByDiff, _ -> @@ -187,7 +188,7 @@ internal class SwapNotificationsFactory( if (!isCardano) { addDustWarningNotification( dustValue = quoteModel.currencyCheck?.dustValue, - feeValue = fee?.feeValue.orZero(), + feeValue = fee?.fee?.amount?.value.orZero(), sendingAmount = amountToRequest.value, cryptoCurrencyStatus = fromCurrencyStatus, feeCurrencyStatus = feeCryptoCurrencyStatus, @@ -275,7 +276,7 @@ internal class SwapNotificationsFactory( } } - private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee? { + private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee.Legacy? { return when (txFeeState) { TxFeeState.Empty -> null is TxFeeState.SingleFeeState -> txFeeState.fee @@ -296,7 +297,9 @@ internal class SwapNotificationsFactory( val shouldShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough && quoteModel.permissionState !is PermissionDataState.PermissionLoading && feeEnoughState.feeCurrency != fromToken - val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromToken.network) + + val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromToken.network) && + quoteModel.swapProvider.type == ExchangeProviderType.CEX if (shouldShowCoverWarning && !isGaslessAvailable) { add( SwapNotificationUM.Error.UnableToCoverFeeWarning( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt index d18df192c1..f352427c56 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt @@ -22,7 +22,7 @@ data class SwapProcessDataState( val reduceBalanceBy: BigDecimal = BigDecimal.ZERO, val approveDataModel: RequestApproveStateData? = null, val swapDataModel: SwapDataModel? = null, - val selectedFee: TxFee? = null, + val selectedFee: TxFee.Legacy? = null, val tokensDataState: TokensDataStateExpress? = null, val selectedProvider: SwapProvider? = null, val lastLoadedSwapStates: Map = emptyMap(), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt index 5156ce3e1e..1d50867cc6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt @@ -7,7 +7,7 @@ import com.tangem.core.ui.extensions.TextReference data class SwapSuccessStateHolder( val timestamp: Long, val txUrl: String, - val fee: TextReference, + val fee: TextReference?, val rate: TextReference, val shouldShowStatusButton: Boolean, val providerName: TextReference, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt index 25c2225451..7330e3ed67 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -24,7 +24,7 @@ data class UiActions( val onStoriesClose: (Int) -> Unit, val onRetryClick: () -> Unit, val onClickFee: () -> Unit, - val onSelectFeeType: (TxFee) -> Unit, + val onSelectFeeType: (TxFee.Legacy) -> Unit, val onProviderClick: (String) -> Unit, val onProviderSelect: (String) -> Unit, val onBuyClick: (CryptoCurrency) -> Unit, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index fcf5220ed6..89570857d7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -826,7 +826,6 @@ internal class StateBuilder( onStatusClick: () -> Unit, txUrl: String, ): SwapStateHolder { - val fee = requireNotNull(dataState.selectedFee) val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency) val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency) val fromAmount = swapTransactionState.fromAmountValue ?: BigDecimal.ZERO @@ -846,7 +845,9 @@ internal class StateBuilder( shouldShowStatusButton = shouldShowStatus, providerIcon = providerState.iconUrl, rate = providerState.subtitle, - fee = stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})"), + fee = dataState.selectedFee?.let { fee -> + stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})") + }, fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount), toTitle = getToCardAccountTitle(toAccount = dataState.toAccount), fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt index e474858740..78ad44f4f9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt @@ -1,24 +1,27 @@ package com.tangem.feature.swap.ui import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.WindowInsetsZero +import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.models.SwapStateHolder import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig import com.tangem.feature.swap.presentation.R @Composable -internal fun SwapScreen(stateHolder: SwapStateHolder) { +internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?) { BackHandler(onBack = stateHolder.onBackClicked) Scaffold( @@ -36,6 +39,17 @@ internal fun SwapScreen(stateHolder: SwapStateHolder) { SwapScreenContent( state = stateHolder, + feeBlock = if (feeSelectorBlockComponent != null) { + @Composable { modifier: Modifier -> + feeSelectorBlockComponent.Content( + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) + } + } else { + null + }, modifier = Modifier.padding(scaffoldPaddings), ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 3aa2c4d059..1c6eca0df3 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -49,7 +49,11 @@ import kotlinx.collections.immutable.persistentListOf @Suppress("LongMethod") @Composable -internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modifier) { +internal fun SwapScreenContent( + state: SwapStateHolder, + modifier: Modifier = Modifier, + feeBlock: @Composable ((Modifier) -> Unit)? = null, +) { val keyboard by keyboardAsState() Box( @@ -74,7 +78,11 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi ProviderItemBlock(state = state.providerState) - FeeItemBlock(state = state.fee) + if (feeBlock != null) { + feeBlock(Modifier.fillMaxWidth()) + } else { + FeeItemBlock(state = state.fee) + } if (state.notifications.isNotEmpty()) SwapNotifications(notifications = state.notifications) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt index 2d51569af0..9caf68f4c0 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt @@ -29,14 +29,16 @@ import com.tangem.core.ui.utils.toTimeFormat import com.tangem.feature.swap.models.SwapSuccessStateHolder import com.tangem.feature.swap.presentation.R import com.tangem.feature.swap.preview.SwapSuccessStatePreview +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.common.ui.FeeBlockSuccess @Composable -fun SwapSuccessScreen(state: SwapSuccessStateHolder, onBack: () -> Unit) { +fun SwapSuccessScreen(state: SwapSuccessStateHolder, feeSelectorUM: FeeSelectorUM?, onBack: () -> Unit) { Scaffold( modifier = Modifier.systemBarsPadding(), containerColor = TangemTheme.colors.background.secondary, content = { padding -> - SwapSuccessScreenContent(padding = padding, state = state) + SwapSuccessScreenContent(padding = padding, feeSelectorUM = feeSelectorUM, state = state) }, topBar = { AppBarWithBackButton( @@ -58,7 +60,11 @@ fun SwapSuccessScreen(state: SwapSuccessStateHolder, onBack: () -> Unit) { } @Composable -private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: PaddingValues) { +private fun SwapSuccessScreenContent( + state: SwapSuccessStateHolder, + feeSelectorUM: FeeSelectorUM?, + padding: PaddingValues, +) { Column( modifier = Modifier .fillMaxSize() @@ -101,7 +107,10 @@ private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: Pad .background(TangemTheme.colors.background.action), ) SpacerH16() - if (state.fee != TextReference.EMPTY) { + + if (feeSelectorUM != null) { + FeeBlockSuccess(feeSelectorUM) + } else if (state.fee != null && state.fee != TextReference.EMPTY) { InputRowDefault( title = TextReference.Res(R.string.common_network_fee_title), text = state.fee, @@ -205,7 +214,7 @@ private fun SwapSuccessScreenButtons( @Composable private fun Preview_Success() { TangemThemePreview { - SwapSuccessScreen(SwapSuccessStatePreview.state) {} + SwapSuccessScreen(SwapSuccessStatePreview.state, null) {} } } // endregion preview \ No newline at end of file From ba4e916cd08a2ccdadc1adeb6fb4299b99b36c97 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sat, 17 Jan 2026 15:15:04 +0300 Subject: [PATCH 126/172] Updated on 2026-08-14 --- core/res/src/main/res/values-es/strings.xml | 2 +- core/res/src/main/res/values-fr/strings.xml | 2 +- core/res/src/main/res/values-ja/strings.xml | 21 ++++++++++++++----- core/res/src/main/res/values-ru/strings.xml | 16 +++++++++++--- .../src/main/res/values-uk-rUA/strings.xml | 2 +- core/res/src/main/res/values/strings.xml | 7 +++++-- .../DefaultGaslessTransactionRepository.kt | 19 +++++++++++++++++ .../MockedGaslessTransactionRepository.kt | 4 ++++ data/wallet-manager/build.gradle.kts | 1 + .../DefaultWalletManagersFacade.kt | 3 +++ .../SdkTransactionHistoryItemConverter.kt | 2 ++ .../utils/SdkTransactionTypeConverter.kt | 18 ++++++++++++++++ .../src/main/assets/contract_methods.json | 5 +++++ .../tangem/domain/models/network/TxInfo.kt | 3 +++ .../GaslessTransactionRepository.kt | 2 ++ ...xHistoryItemToTransactionStateConverter.kt | 2 ++ .../converter/TxHistoryItemStateConverter.kt | 2 ++ 17 files changed, 98 insertions(+), 13 deletions(-) diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index fdd6015419..e80c01ac3c 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -1767,7 +1767,7 @@ Importa una billetera existente con su frase de recuperación. Importar billetera Introduzca la frase de recuperación - La copia de seguridad de su billetera se realizó con éxito. + Su billetera se ha importado con éxito. Importar billetera Importación completada Importar billetera diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 58a8d7eb70..ed570724fa 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1753,7 +1753,7 @@ Importez un portefeuille existant à l\'aide de votre seedphrase. Importer un wallet Entrez la seedphrase - Vous avez sauvegardé votre portefeuille avec succès. + Vous avez importé votre portefeuille avec succès. Importer un wallet Importation terminée Importer un portefeuille diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 0dbce5c791..6ac1337a55 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -317,6 +317,7 @@ いいえ アドレスがありません 未追加 + 利用できません 今はしない わかりました @@ -531,6 +532,10 @@ ID: %s 取引IDをコピーしました ポートフォリオ内のアセットをこのトークンと交換する + 速度を上げるほど承認は速くなりますが、\nネットワーク手数料も高くなります。%s + 速度を選択 + \nネットワーク手数料の支払いに使用するトークンを選択します。%s + トークンを選択 マーケット&ニュース Tangem AI いま話題 @@ -588,7 +593,7 @@ 新しいウォレットを作成 Tangemの高性能ハードウェアウォレットで、セキュリティをさらに強化しましょう。 ハードウェアウォレット - 現在のウォレットをTangemに移します。 + 現在のモバイルウォレットをTangemのコールドウォレットに変換します。 現在のウォレットをアップグレードする バックアップへ移動 アクセスコードを作成する前にウォレットをバックアップしてください。 @@ -613,7 +618,7 @@ 本当にこれを実行しますか? ウォレット削除 バックアップへ移動 - バックアップを表示 + リカバリーフレーズを表示 ウォレット削除 とにかく削除する このウォレットにはバックアップがあります。ウォレットを削除する前に、復元できることを確認してください。 @@ -804,7 +809,7 @@ クイックまとめ 関連ニュース 関連トークン - 情報源 + 関連ニュース 最新情報を入手 お使いのデバイスではNFCが使用できません NFTについて @@ -1239,6 +1244,7 @@ APR APY 報酬は毎日自動的にステーキング残高に蓄積されます。 + 報酬はステーキング残高に自動的に複利で加算されます。獲得した資金:%s 利用可能 平均報酬率 ステーキングの仕組み @@ -1491,10 +1497,15 @@ KYC手続きを中止しますか?いつでも再開できます。 プロフィールを確認できませんでした。ご不明な点があればサポートまでお問い合わせください。 申し訳ございませんが、本人確認を行うことができませんでした + KYCの認証に失敗しました KYC進行中 ステータスを表示 Tangem PayのKYC手続き進行中 以下のボタンから、現在のKYCステータスを確認するか、KYCをキャンセルできます。 + KYCが承認されませんでした + KYCブロックを非表示 + 申し訳ありません、確認できませんでした + あなたのプロフィール。 無料のTangem Visaバーチャルカードを入手 日常の支払いにUSDCを利用 カードをGET @@ -1698,7 +1709,7 @@ リカバリーフレーズを使って既存のウォレットをインポートします。 ウォレットをインポート リカバリーフレーズを入力 - ウォレットのバックアップが正常に完了しました。 + ウォレットのインポートに成功しました。 ウォレットをインポート インポート完了 ウォレットをインポート @@ -1713,7 +1724,7 @@ このアクセスコードはウォレットを保護し、ログインや取引の署名に使用されます。 アクセスコードの設定・変更 アクセスコードを変更する - ウォレットの受信取引とTangemの更新について通知を受け取る。 + ウォレットへの入金取引をプッシュ通知で受け取る。 現在、Huaweiデバイスではプッシュ通知が機能しない可能性があります。現在、解決策の検討に取り組んでおり、今後のアップデートで修正をリリースする予定です。ご理解のほどよろしくお願いいたします。 取引通知 アクセスコードを設定する diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 2f51bfad1e..21edba4fa6 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -39,10 +39,12 @@ Невозможно отредактировать аккаунт Аккаунт сохранен Аккаунт для наград + Аккаунт #%s — используется для генерации адреса. Добавить аккаунт Сохранить Название аккаунта - Имя аккаунта уже существует + Аккаунт с таким названием уже существует. Пожалуйста, выберите другое название. + Название аккаунта уже используется Аккаунт Новый аккаунт Добавить аккаунт @@ -254,6 +256,7 @@ Подтвердить Подключение Обратиться в поддержку + Связаться с поддержкой Продолжить Конвертировать Копировать @@ -379,6 +382,7 @@ Поддерживаемые сети Обменять Tangem + Tangem Кошелек условия участия Условиями использования На @@ -546,6 +550,9 @@ ID: %s ID транзакции скопирован Обменяйте любой актив в вашем портфеле на этот токен + Более высокая скорость означает более быстрое подтверждение, но и более высокую сетевую комиссию. %s + Выбрать скорость + Выберите какой токен будет использоваться для оплаты комиссии сети. %s Выбрать токен Рынок и Новости В тренде @@ -834,7 +841,7 @@ Резюме Связанные новости Связанные токены - Источники + Связанные новости Будьте в курсе Функция NFC недоступна на вашем устройстве О NFT @@ -1121,6 +1128,7 @@ Сбросить карту Я понимаю, что после выполнения этого действия у меня больше не будет доступа к текущему кошельку Я понимаю, что не смогу использовать эту карту для восстановления кода доступа на других картах текущего кошелька + Я понимаю, что полностью потеряю доступ к своей карте Tangem Pay и всем средствам на ней без возможности восстановления. Сброс к заводским настройкам приведет к полному удалению кошелька с выбранной карты или кольца. Вы не сможете восстановить текущий кошелек или использовать данную карту или кольцо для восстановления кода доступа. Сброс к заводским настройкам приведет к полному удалению кошелька с выбранной карты или кольца. Вы не сможете восстановить текущий кошелек. Все Tangem девайсы были сброшены. @@ -1467,6 +1475,7 @@ Карта заморожена Обратиться в поддержку Другое + Невозможно использовать на устройствах с root-доступом. Успешно завершено Отклонено В процессе @@ -1544,6 +1553,7 @@ Посмотреть статус KYC в процессе для Tangem Pay Вы можете посмотреть текущий статус KYC или отменить его + KYC отклонено Откройте бесплатную виртуальную карту Tangem Visa Оплачивайте ежедневные покупки в USDC Открыть карту @@ -1732,7 +1742,7 @@ Импортируйте существующий кошелек через вашу фразу восстановления. Имрортировать кошелек Ввести фразу восстановления - Вы успешно сделали бэкап вашего кошелька. + Вы успешно импортировали свой кошелек. Импортировать кошелек Импорт завершен Импортировать кошелек diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index fbf1149a65..e9f4049c3b 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1752,7 +1752,7 @@ Імпортуйте існуючий гаманець за допомогою сід-фрази. Імпортувати гаманець Введіть фразу відновлення - Ви успішно створили резервну копію свого гаманця. + Ви успішно імпортували свій гаманець. Імпортувати гаманець Імпорт завершено Імпортувати гаманець diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index d5b33d94e7..128a5d68c7 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -323,6 +323,7 @@ No No address Not Added + Not available Not now Now OK @@ -559,6 +560,7 @@ Tangem feedback Can\'t send a transaction Coin description error + Gasless transaction fee An error occurred An error occurred. Code: %s. Requires memo @@ -821,7 +823,7 @@ Quick recap Related News Related tokens - Sources + Related news Stay in the loop NFC is not available on your device About NFT @@ -989,6 +991,7 @@ Access code restore Identical cards Access code + You can have either a mobile wallet or a hardware wallet at one time. Upgrade your wallet or delete it from the app to start using a Tangem cold wallet. All offers Available with %s Providers facilitate transactions @@ -1774,7 +1777,7 @@ Import an existing wallet with your recovery phrase. Import wallet Enter recovery phrase - You successfully backed up your wallet. + You successfully import up your wallet. Import wallet Import completed Import wallet diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt index 3e75ce78f5..d99d586e16 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -16,6 +16,8 @@ import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.math.BigInteger @@ -26,6 +28,8 @@ class DefaultGaslessTransactionRepository( ) : GaslessTransactionRepository { private val supportedTokensState = MutableStateFlow>>(hashMapOf()) + private val allFeeRecipientAddress = mutableSetOf() + private val addressesMutex = Mutex() private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder() private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter() @@ -115,6 +119,21 @@ class DefaultGaslessTransactionRepository( return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}") } + override suspend fun getGaslessFeeAddresses(): Set { + return addressesMutex.withLock { + allFeeRecipientAddress.ifEmpty { + val allFeeAddresses = getAllFeeRecipientAddresses() + allFeeRecipientAddress.addAll(allFeeAddresses) + allFeeRecipientAddress + } + } + } + + private suspend fun getAllFeeRecipientAddresses(): Set { + // TODO Replace with other backend call to get all fee recipient addresses when available + return setOf(getTokenFeeReceiverAddress()) + } + private companion object { val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000") } diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt index dd3e5600f5..0a52be64e3 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt @@ -34,6 +34,10 @@ class MockedGaslessTransactionRepository( return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}") } + override suspend fun getGaslessFeeAddresses(): Set { + return setOf("0x2bD8AA05a92CcDaeDE5d42EB7D196ed07F8F8EF3") + } + override suspend fun getTokenFeeReceiverAddress(): String { return TOKEN_RECEIVER_ADDRESS } diff --git a/data/wallet-manager/build.gradle.kts b/data/wallet-manager/build.gradle.kts index 31bb6bc3c8..e703847de0 100644 --- a/data/wallet-manager/build.gradle.kts +++ b/data/wallet-manager/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(projects.domain.walletManager) implementation(projects.domain.demo) implementation(projects.domain.card) + implementation(projects.domain.transaction) api(projects.domain.models) /** Domain models */ diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index 842ce80393..c26cf7ee59 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -35,6 +35,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.domain.txhistory.models.PaginationWrapper import com.tangem.domain.txhistory.models.TxHistoryState @@ -61,6 +62,7 @@ internal class DefaultWalletManagersFacade @Inject constructor( private val userWalletsStore: UserWalletsStore, private val assetLoader: AssetLoader, private val dispatchers: CoroutineDispatcherProvider, + private val gaslessTransactionRepository: GaslessTransactionRepository, blockchainSDKFactory: BlockchainSDKFactory, ) : WalletManagersFacade { @@ -284,6 +286,7 @@ internal class DefaultWalletManagersFacade @Inject constructor( items = SdkTransactionHistoryItemConverter( smartContractMethods = readSmartContractMethods(), yieldSupplyAddresses = YIELD_SUPPLY_ADDRESSES, + gaslessFeeAddresses = gaslessTransactionRepository.getGaslessFeeAddresses(), ).convertList(itemsResult.data.items), ) is Result.Failure -> error(itemsResult.error.message ?: itemsResult.error.customMessage) diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt index 51d7e2fd6e..99f7e8fa99 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt @@ -9,11 +9,13 @@ import com.tangem.blockchain.transactionhistory.models.TransactionHistoryItem as internal class SdkTransactionHistoryItemConverter( smartContractMethods: Map, yieldSupplyAddresses: Set, + gaslessFeeAddresses: Set, ) : Converter { private val typeConverter by lazy { SdkTransactionTypeConverter( smartContractMethods = smartContractMethods, yieldSupplyAddresses = yieldSupplyAddresses, + gaslessFeeAddresses = gaslessFeeAddresses, ) } override fun convert(value: SdkTransactionHistoryItem): TxInfo = TxInfo( diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt index 5096b7fe26..44850b9ec5 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt @@ -13,8 +13,11 @@ import com.tangem.utils.converter.Converter internal class SdkTransactionTypeConverter( private val smartContractMethods: Map, private val yieldSupplyAddresses: Set, + gaslessFeeAddresses: Set, ) : Converter { + private val gaslessFeeAddressesLowercase: Set = gaslessFeeAddresses.map { it.lowercase() }.toSet() + override fun convert(value: TransactionHistoryItem): TxInfo.TransactionType { val (type, destination) = value.type to value.destinationType val source = value.sourceType @@ -125,8 +128,23 @@ internal class SdkTransactionTypeConverter( ) } "supplyTopUp" -> TxInfo.TransactionType.YieldSupply.Topup + "gaslessTransaction" -> getTypeForGaslessMethod(destination) null -> TxInfo.TransactionType.UnknownOperation else -> TxInfo.TransactionType.Operation(name = methodName.replaceFirstChar { it.titlecase() }) } ?: TxInfo.TransactionType.Operation(name = methodName?.replaceFirstChar { it.titlecase() }.orEmpty()) } + + private fun getTypeForGaslessMethod(destination: TransactionHistoryItem.DestinationType): TxInfo.TransactionType { + return when (destination) { + is TransactionHistoryItem.DestinationType.Multiple -> TxInfo.TransactionType.UnknownOperation + is TransactionHistoryItem.DestinationType.Single -> { + val address = destination.addressType.address.lowercase() + if (gaslessFeeAddressesLowercase.contains(address)) { + TxInfo.TransactionType.GaslessFee + } else { + TxInfo.TransactionType.UnknownOperation + } + } + } + } } \ No newline at end of file diff --git a/domain/legacy/src/main/assets/contract_methods.json b/domain/legacy/src/main/assets/contract_methods.json index 1ba70afcc7..5f421e8506 100644 --- a/domain/legacy/src/main/assets/contract_methods.json +++ b/domain/legacy/src/main/assets/contract_methods.json @@ -246,5 +246,10 @@ "info": "P2PEthPool", "source": "https://docs.p2p.org/", "name": "pooledUnstake" + }, + "0xaa03b617": { + "info": "GaslessTransactions", + "source": "https://github.com/tangem-developments/tangem-gasless-service", + "name": "gaslessTransaction" } } diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt index aae35a95f1..2e0c0b37b7 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt @@ -107,6 +107,9 @@ data class TxInfo( @Serializable data class Operation(val name: String) : TransactionType + @Serializable + data object GaslessFee : TransactionType + @Serializable sealed interface YieldSupply : TransactionType { diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt index 29d57171a6..2f7b061892 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt @@ -63,4 +63,6 @@ interface GaslessTransactionRepository { fun getBaseGasForTransaction(): BigInteger fun getChainIdForNetwork(network: Network): Int + + suspend fun getGaslessFeeAddresses(): Set } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt index 21045da86e..0c5e2cba45 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt @@ -63,6 +63,7 @@ internal class TxHistoryItemToTransactionStateConverter( is TransactionType.UnknownOperation, is TransactionType.YieldSupply.Send, TransactionType.YieldSupply.Topup, + TransactionType.GaslessFee, -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 } } @@ -104,6 +105,7 @@ internal class TxHistoryItemToTransactionStateConverter( ) } is TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation) + TransactionType.GaslessFee -> resourceReference(R.string.gasless_transaction_fee) } private fun TxInfo.extractSubtitle(): TextReference { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TxHistoryItemStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TxHistoryItemStateConverter.kt index b04f228e55..b679f01589 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TxHistoryItemStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TxHistoryItemStateConverter.kt @@ -73,6 +73,7 @@ internal class TxHistoryItemStateConverter( is TransactionType.UnknownOperation, is TransactionType.YieldSupply.Send, TransactionType.YieldSupply.Topup, + TransactionType.GaslessFee, -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 } } @@ -114,6 +115,7 @@ internal class TxHistoryItemStateConverter( ) } is TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation) + TransactionType.GaslessFee -> resourceReference(R.string.gasless_transaction_fee) } private fun TxInfo.extractSubtitle(): TextReference { From 767a995de809820d294b28bcb9cbaba9bc2a6575 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sat, 17 Jan 2026 17:12:01 +0300 Subject: [PATCH 127/172] Updated on 2026-08-14 --- .../presentation/environments/ui/EnvironmentTogglesScreen.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt index 3bfdec7578..84bcfc5fa8 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.RectangleShape 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.SpacerH24 import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -67,6 +68,7 @@ internal fun EnvironmentTogglesScreen(uiModel: EnvironmentTogglesScreenUM) { ) } } + SpacerH24() } @Composable From 14cbec446e89b7111014cf4c7ea3d3b4bde2a51e Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 18 Jan 2026 11:10:11 +0300 Subject: [PATCH 128/172] Updated on 2026-08-14 --- .../GetBalanceNotEnoughForFeeWarningUseCase.kt | 17 +++++++++-------- .../notifications/model/NotificationsModel.kt | 2 +- .../impl/presentation/model/StakingModel.kt | 5 +++-- .../model/YieldSupplyNotificationsModel.kt | 2 +- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt index 9f8fe6fc0c..102ad31f39 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt @@ -34,27 +34,28 @@ class GetBalanceNotEnoughForFeeWarningUseCase( fee: BigDecimal, userWalletId: UserWalletId, tokenStatus: CryptoCurrencyStatus, - coinStatus: CryptoCurrencyStatus, + feeStatus: CryptoCurrencyStatus, ): Either = Either.catch { withContext(dispatchers.io) { val feePaidCurrency = currenciesRepository.getFeePaidCurrency(userWalletId, tokenStatus.currency.network) - val coinBalance = coinStatus.value.amount ?: BigDecimal.ZERO + val feeTokenBalance = feeStatus.value.amount ?: BigDecimal.ZERO val isFeePaidByCoin = tokenStatus.currency is CryptoCurrency.Token val isFeePaidByToken = feePaidCurrency is FeePaidCurrency.Token && tokenStatus.currency.id != feePaidCurrency.tokenId - val isNetworkSupportGasless = currencyChecksRepository.isNetworkSupportedForGaslessTx( - coinStatus.currency.network, - ) + + val isFeePaidByGaslessToken = + currencyChecksRepository.isNetworkSupportedForGaslessTx(feeStatus.currency.network) && + feeStatus.currency is CryptoCurrency.Token val warning = when { feePaidCurrency is FeePaidCurrency.Coin && isFeePaidByCoin && - fee > coinBalance && - !isNetworkSupportGasless -> { + fee > feeTokenBalance && + !isFeePaidByGaslessToken -> { CryptoCurrencyWarning.BalanceNotEnoughForFee( tokenCurrency = tokenStatus.currency, - coinCurrency = coinStatus.currency, + coinCurrency = feeStatus.currency, ) } feePaidCurrency is FeePaidCurrency.Token && isFeePaidByToken && fee > feePaidCurrency.balance -> { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt index 7c4bc6ef9d..87cb0d5f97 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt @@ -235,7 +235,7 @@ internal class NotificationsModel @Inject constructor( fee = feeValue, userWalletId = userWalletId, tokenStatus = cryptoCurrencyStatus, - coinStatus = feeCryptoCurrencyStatus, + feeStatus = notificationData.feeCryptoCurrencyStatus, ).getOrNull() addExceedBalanceNotification( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index fa875ef820..cc46fde53c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -852,12 +852,13 @@ internal class StakingModel @Inject constructor( val amount = amountState?.amountTextField?.cryptoAmount?.value val fee = feeState?.fee?.amount?.value - val currencyWarning = if (feeCryptoCurrencyStatus != null && fee != null) { + val feeStatus = feeCryptoCurrencyStatus + val currencyWarning = if (feeStatus != null && fee != null) { getBalanceNotEnoughForFeeWarningUseCase( fee = fee, userWalletId = userWalletId, tokenStatus = cryptoCurrencyStatus, - coinStatus = feeCryptoCurrencyStatus ?: cryptoCurrencyStatus, + feeStatus = feeStatus, ).getOrNull() } else { null diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt index 9dc753015c..f6163ccc3c 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt @@ -54,7 +54,7 @@ internal class YieldSupplyNotificationsModel @Inject constructor( fee = data.feeValue.orZero(), userWalletId = params.userWalletId, tokenStatus = cryptoCurrencyStatus, - coinStatus = feeCryptoCurrencyStatus, + feeStatus = feeCryptoCurrencyStatus, ).getOrNull() addExceedsBalanceNotification( From ccd0ef33a2ba646a7e35cd990925d3d68346ff84 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 18 Jan 2026 12:26:42 +0300 Subject: [PATCH 129/172] Updated on 2026-08-14 --- .../transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt index 96435ad705..3883bbacf4 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -84,6 +84,7 @@ class GetAvailableFeeTokensUseCase( }.toSet() return userCurrenciesStatuses .asSequence() + .filter { it.value.yieldSupplyStatus == null } .filter { it.currency.network.id == network.id } .filter { currencyStatus -> val token = currencyStatus.currency From 8690dc97da8ef376f5a51ec99ab199509c1b7b64 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 18 Jan 2026 13:04:49 +0300 Subject: [PATCH 130/172] Updated on 2026-08-14 --- .../tangem/domain/transaction/error/GetFeeError.kt | 3 +++ .../transaction/usecase/gasless/TokenFeeCalculator.kt | 11 ++++++++--- .../usecase/gasless/TokenFeeCalculatorTest.kt | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt index 1e8b733d5c..3c8dfbdcfb 100644 --- a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt +++ b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt @@ -10,6 +10,9 @@ sealed class GetFeeError { data object SuiOneCoinRequired : BlockchainErrors() } + /** + * Gasless transaction related errors, model logic uses this errors types, don't remove or change them + */ sealed class GaslessError : GetFeeError() { data object NetworkIsNotSupported : GaslessError() data object NoSupportedTokensFound : GaslessError() diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt index 803a21668b..41e65be9d6 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt @@ -20,11 +20,11 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError.GaslessError -import com.tangem.domain.transaction.error.mapToFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.raiseIllegalStateError import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.extensions.isZero import java.math.BigDecimal import java.math.BigInteger import java.math.RoundingMode @@ -83,7 +83,7 @@ internal class TokenFeeCalculator( when (result) { is Result.Success -> result.data - is Result.Failure -> raise(result.mapToFeeError()) + is Result.Failure -> raise(GaslessError.DataError(result.error)) null -> raise(GetFeeError.UnknownError) } } @@ -96,6 +96,11 @@ internal class TokenFeeCalculator( initialFee: Fee.Ethereum, ): Either { return either { + // fast finish to skip calculations if no funds in token + if (tokenForPayFeeStatus.value.amount?.isZero() == true) { + raise(GaslessError.NotEnoughFunds) + } + val tokenForPayFee = tokenForPayFeeStatus.currency as? CryptoCurrency.Token ?: raiseIllegalStateError("only tokens are supported") @@ -114,7 +119,7 @@ internal class TokenFeeCalculator( ) val feeTransferGasLimit = when (feeTransferGasLimitResult) { - is Result.Failure -> raise(GetFeeError.DataError(feeTransferGasLimitResult.error)) + is Result.Failure -> raise(GaslessError.DataError(feeTransferGasLimitResult.error)) is Result.Success -> feeTransferGasLimitResult.data }.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT) diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt index e3a91a642c..3c60d6efea 100644 --- a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt @@ -298,7 +298,7 @@ class TokenFeeCalculatorTest { // Then assertTrue(result.isLeft()) result.onLeft { error -> - assertTrue(error is GetFeeError.DataError) + assertTrue(error is GetFeeError.GaslessError.DataError) } } From c024ba2836ee46df179a120e85a745e017087467 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 18 Jan 2026 13:28:32 +0300 Subject: [PATCH 131/172] Updated on 2026-08-14 --- .../core/ui/ds/row/token/TangemTokenRow.kt | 3 +- .../gasless/GetAvailableFeeTokensUseCase.kt | 5 +- .../speed/ui/FeeSpeedSelectorContent.kt | 1 + .../token/entity/FeeTokenItemState.kt | 8 +++ .../token/entity/FeeTokenSelectorUM.kt | 7 ++- .../token/model/FeeTokenForListConverter.kt | 23 +++++--- .../token/model/FeeTokenSelectorModel.kt | 4 +- .../token/ui/FeeTokenSelectorContent.kt | 52 +++++++++++++++++-- .../v2/feeselector/model/FeeSelectorLogic.kt | 46 +++++++++------- 9 files changed, 109 insertions(+), 40 deletions(-) create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenItemState.kt 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 8d904b5ce7..8f9c251ea1 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 @@ -101,8 +101,7 @@ fun TangemTokenRow( .testTag(TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK), ) }, - modifier = modifier - .tokenClickable(tokenRowUM = tokenRowUM), + modifier = modifier.tokenClickable(tokenRowUM = tokenRowUM), ) } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt index 3883bbacf4..0d2c825756 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -14,7 +14,6 @@ import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.raiseIllegalStateError -import java.math.BigDecimal class GetAvailableFeeTokensUseCase( private val gaslessTransactionRepository: GaslessTransactionRepository, @@ -88,9 +87,7 @@ class GetAvailableFeeTokensUseCase( .filter { it.currency.network.id == network.id } .filter { currencyStatus -> val token = currencyStatus.currency - token is CryptoCurrency.Token && - currencyStatus.value.amount?.let { amount -> amount > BigDecimal.ZERO } == true && - supportedGaslessTokens.contains(token.contractAddress.lowercase()) + token is CryptoCurrency.Token && supportedGaslessTokens.contains(token.contractAddress.lowercase()) } .toList() } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt index 8a6f57fb07..89a8ed3660 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt @@ -75,6 +75,7 @@ internal fun FeeSpeedSelectorContent( start = 16.dp, end = 16.dp, ), + horizontalAlignment = Alignment.CenterHorizontally, ) { Title(onLearnMoreClick = intents::onLearnMoreClick) SpacerH8() diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenItemState.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenItemState.kt new file mode 100644 index 0000000000..ffc1e2e2d0 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenItemState.kt @@ -0,0 +1,8 @@ +package com.tangem.features.send.v2.feeselector.component.token.entity + +import com.tangem.core.ui.components.token.state.TokenItemState + +internal data class FeeTokenItemState( + val state: TokenItemState, + val isAvailable: Boolean = true, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenSelectorUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenSelectorUM.kt index 5de55c5a70..e1fb00d6af 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenSelectorUM.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenSelectorUM.kt @@ -1,11 +1,10 @@ package com.tangem.features.send.v2.feeselector.component.token.entity -import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.features.send.v2.api.entity.FeeSelectorUM import kotlinx.collections.immutable.ImmutableList -data class FeeTokenSelectorUM( +internal data class FeeTokenSelectorUM( val parent: FeeSelectorUM.Content, - val selectedToken: TokenItemState, - val tokens: ImmutableList, + val selectedToken: FeeTokenItemState, + val tokens: ImmutableList, ) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenForListConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenForListConverter.kt index aeec5346f3..bcf449a9d1 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenForListConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenForListConverter.kt @@ -8,29 +8,36 @@ 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.CryptoCurrencyStatus +import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenItemState import com.tangem.features.send.v2.impl.R import com.tangem.utils.Provider import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isZero internal class FeeTokenForListConverter( private val appCurrencyProvider: Provider, private val onTokenClick: () -> Unit, -) : Converter { +) : Converter { - override fun convert(value: CryptoCurrencyStatus): TokenItemState { + override fun convert(value: CryptoCurrencyStatus): FeeTokenItemState { if (value.value !is CryptoCurrencyStatus.Loaded) { - return TokenItemState.Loading(id = value.currency.id.value) + return FeeTokenItemState(TokenItemState.Loading(id = value.currency.id.value)) } val appCurrency = appCurrencyProvider() + val isBalanceZero = value.value.amount?.isZero() == true val tokenItemConverter = TokenItemStateConverter( appCurrency = appCurrencyProvider(), - onItemClick = { _, _ -> onTokenClick() }, + onItemClick = if (isBalanceZero.not()) { + { _, _ -> onTokenClick() } + } else { + null + }, ) val state = tokenItemConverter.convert(value) - return when (state) { + val updatedState = when (state) { is TokenItemState.Content -> { val balance = value.value.fiatAmount.format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) @@ -46,10 +53,14 @@ internal class FeeTokenForListConverter( isAvailable = false, ), subtitle2State = null, - ) } else -> state } + + return FeeTokenItemState( + state = updatedState, + isAvailable = isBalanceZero.not(), + ) } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt index 4afcd80323..eb7e57e5b0 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt @@ -7,13 +7,13 @@ 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.navigation.url.UrlOpener -import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams import com.tangem.features.send.v2.feeselector.component.token.FeeTokenSelectorIntents +import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenItemState import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenSelectorUM import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents import com.tangem.utils.Provider @@ -78,7 +78,7 @@ internal class FeeTokenSelectorModel @Inject constructor( ) } - private fun convertToken(status: CryptoCurrencyStatus): TokenItemState { + private fun convertToken(status: CryptoCurrencyStatus): FeeTokenItemState { return FeeTokenForListConverter( appCurrencyProvider = Provider { appCurrency }, onTokenClick = { onTokenSelected(status) }, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt index 97f3d02076..76adedfcaf 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt @@ -9,8 +9,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.* import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration @@ -37,10 +39,12 @@ import com.tangem.domain.models.network.Network import com.tangem.features.send.v2.api.entity.* import com.tangem.features.send.v2.feeselector.component.token.FeeTokenSelectorIntents import com.tangem.features.send.v2.feeselector.component.token.StubFeeTokenSelectorIntents +import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenItemState import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenSelectorUM import com.tangem.features.send.v2.impl.R import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal @Composable @@ -55,6 +59,7 @@ internal fun FeeTokenSelectorContent( start = 16.dp, end = 16.dp, ), + horizontalAlignment = Alignment.CenterHorizontally, ) { Title(onLearnMoreClick = intents::onLearnMoreClick) SpacerH8() @@ -101,17 +106,27 @@ private fun Title(modifier: Modifier = Modifier, onLearnMoreClick: () -> Unit) { ) } +@Suppress("MagicNumber") @Composable internal fun TokenListContent(state: FeeTokenSelectorUM, modifier: Modifier = Modifier) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp), ) { - state.tokens.fastForEach { item -> + val (availableTokens, notAvailableTokens) = remember(state.tokens) { + state.tokens.partition { it.isAvailable }.let { pair -> + Pair( + first = pair.first.map { it.state }.toImmutableList(), + second = pair.second.map { it.state }.toImmutableList(), + ) + } + } + + availableTokens.fastForEach { item -> key(item.id) { TokenItem( modifier = Modifier - .selectedBorder(isSelected = state.selectedToken.id == item.id) + .selectedBorder(isSelected = state.selectedToken.state.id == item.id) .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) .background(color = TangemTheme.colors.background.action), state = item, @@ -119,6 +134,28 @@ internal fun TokenListContent(state: FeeTokenSelectorUM, modifier: Modifier = Mo ) } } + + if (notAvailableTokens.isNotEmpty()) { + Text( + modifier = Modifier.padding(start = 12.dp, top = 8.dp), + text = stringResourceSafe(R.string.common_not_available), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + + notAvailableTokens.fastForEach { item -> + key(item.id) { + TokenItem( + modifier = Modifier + .graphicsLayer { alpha = 0.5f } + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.action), + state = item, + isBalanceHidden = false, + ) + } + } + } } } @@ -209,10 +246,17 @@ private fun Preview() { onItemLongClick = {}, ) + val feeTokenItemState = FeeTokenItemState(tokenItemState) + val state = FeeTokenSelectorUM( parent = parentUM, - selectedToken = tokenItemState, - tokens = persistentListOf(tokenItemState, tokenItemState.copy(id = "2")), + selectedToken = feeTokenItemState, + tokens = persistentListOf( + feeTokenItemState, + feeTokenItemState.copy(state = tokenItemState.copy(id = "2")), + feeTokenItemState.copy(state = tokenItemState.copy(id = "3"), isAvailable = false), + feeTokenItemState.copy(state = tokenItemState.copy(id = "4"), isAvailable = false), + ), ) TangemThemePreview { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt index 42ffaa7515..8afabe494a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt @@ -1,6 +1,7 @@ package com.tangem.features.send.v2.feeselector.model import arrow.core.Either +import arrow.core.flatMap import arrow.core.getOrElse import arrow.core.raise.either import com.tangem.blockchain.common.AmountType @@ -209,25 +210,34 @@ internal class FeeSelectorLogic @AssistedInject constructor( @Suppress("UnreachableCode") private suspend fun callLoadFee(): Either { val extended = params.onLoadFeeExtended - return if (extended != null && sendFeatureToggles.isGaslessTransactionsEnabled) { - val selectedToken = (uiState.value as? FeeSelectorUM.Content)?.feeExtraInfo?.feeCryptoCurrencyStatus - ?.takeIf { it.currency is CryptoCurrency.Token } - - extended(selectedToken).fold( - ifLeft = { error -> - if (error is GetFeeError.GaslessError) { - params.onLoadFee().map { LoadedFeeResult.Basic(it) } - } else { - Either.Left(error) - } - }, - ifRight = { fee -> - populateExtendedFee(fee) - }, - ) - } else { - params.onLoadFee().map { LoadedFeeResult.Basic(it) } + if (extended == null || !sendFeatureToggles.isGaslessTransactionsEnabled) { + return params.onLoadFee().map { LoadedFeeResult.Basic(it) } } + + val selectedTokenOrNull = (uiState.value as? FeeSelectorUM.Content)?.feeExtraInfo?.feeCryptoCurrencyStatus + + if (selectedTokenOrNull?.currency is CryptoCurrency.Coin) { + return params.onLoadFee().flatMap { loadedFee -> + val feeExtended = TransactionFeeExtended( + transactionFee = loadedFee, + feeTokenId = selectedTokenOrNull.currency.id, + ) + populateExtendedFee(feeExtended) + } + } + + return extended(selectedTokenOrNull).fold( + ifLeft = { error -> + if (error is GetFeeError.GaslessError) { + params.onLoadFee().map { LoadedFeeResult.Basic(it) } + } else { + Either.Left(error) + } + }, + ifRight = { fee -> + populateExtendedFee(fee) + }, + ) } private suspend fun populateExtendedFee( From 96784ebd43cb4e70206e3aacca83db4416e64b86 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 18 Jan 2026 13:56:25 +0300 Subject: [PATCH 132/172] Updated on 2026-08-14 --- core/res/src/main/res/values-ja/strings.xml | 2 + core/res/src/main/res/values/strings.xml | 1 + .../send/v2/api/entity/FeeSelectorUM.kt | 1 + .../extended/ui/FeeExtendedSelectorContent.kt | 2 + .../speed/ui/FeeSpeedSelectorContent.kt | 8 +++ .../v2/feeselector/model/FeeSelectorLogic.kt | 52 +++++++++++-------- .../FeeSelectorErrorTransformer.kt | 7 +++ .../FeeSelectorTokenSelectedTransformer.kt | 1 + 8 files changed, 53 insertions(+), 21 deletions(-) diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 6ac1337a55..5197f96905 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -552,6 +552,7 @@ Tangemへのフィードバック 取引を送信できません コインの説明エラー + ガス代不要の取引手数料 エラーが発生しました エラーが発生しました。コード: %s 。 メモが必要 @@ -973,6 +974,7 @@ アクセスコードの復元 同一のカード アクセスコード + 同時に利用できるのは、モバイルウォレットまたはハードウェアウォレットのいずれか一方のみです。Tangemのコールドウォレットを使用するには、ウォレットをアップグレードするか、アプリから削除してください。 すべてのオファー %sで利用可能 プロバイダーが取引を促進します diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 128a5d68c7..a5e90a215a 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -560,6 +560,7 @@ Tangem feedback Can\'t send a transaction Coin description error + Not enough funds Gasless transaction fee An error occurred An error occurred. Code: %s. diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt index 3706fa2f92..9d92d11e1c 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt @@ -68,6 +68,7 @@ data class FeeExtraInfo( val feeCryptoCurrencyStatus: CryptoCurrencyStatus, val availableFeeCurrencies: ImmutableList? = null, val transactionFeeExtended: TransactionFeeExtended? = null, + val isNotEnoughFunds: Boolean = false, ) sealed class FeeNonce { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt index eef8cc085a..e084555a7b 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt @@ -98,6 +98,7 @@ fun FeeExtendedSelectorContent(state: FeeExtendedSelectorUM, modifier: Modifier }, ellipsizeOffset = fee.amount.currencySymbol.length, showSelectorIcon = isChooseSpeedAvailable, + isNotEnoughFunds = state.parent.feeExtraInfo.isNotEnoughFunds, isLoading = state.fee.isLoading(), ) } @@ -168,6 +169,7 @@ private fun Preview() { isFeeConvertibleToFiat = true, isTronToken = false, feeCryptoCurrencyStatus = cryptoCurrencyStatus, + isNotEnoughFunds = true, ), feeNonce = FeeNonce.None, feeFiatRateUM = FeeFiatRateUM( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt index 89a8ed3660..ef604613c9 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt @@ -328,6 +328,7 @@ internal fun RegularFeeItemContent( ellipsizeOffset: Int? = null, showSelectorIcon: Boolean = false, isLoading: Boolean = false, + isNotEnoughFunds: Boolean = false, ) { Column(modifier = modifier) { Row( @@ -353,6 +354,7 @@ internal fun RegularFeeItemContent( postDot = postDot, ellipsizeOffset = ellipsizeOffset, isLoading = isLoading, + isNotEnoughFunds = isNotEnoughFunds, ) if (showSelectorIcon) { @@ -376,6 +378,7 @@ private fun FeeDescription( postDot: TextReference? = null, ellipsizeOffset: Int? = null, isLoading: Boolean = false, + isNotEnoughFunds: Boolean = false, ) { Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( @@ -385,6 +388,11 @@ private fun FeeDescription( modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE), ) when { + isNotEnoughFunds -> Text( + text = stringResourceSafe(R.string.gasless_not_enough_funds_to_cover_token_fee), + color = TangemTheme.colors.text.warning, + style = TangemTheme.typography.caption1, + ) isLoading -> TextShimmer( modifier = Modifier.width(122.dp), style = TangemTheme.typography.caption1, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt index 8afabe494a..100d288151 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt @@ -3,6 +3,7 @@ package com.tangem.features.send.v2.feeselector.model import arrow.core.Either import arrow.core.flatMap import arrow.core.getOrElse +import arrow.core.left import arrow.core.raise.either import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.transaction.TransactionFee @@ -228,10 +229,13 @@ internal class FeeSelectorLogic @AssistedInject constructor( return extended(selectedTokenOrNull).fold( ifLeft = { error -> - if (error is GetFeeError.GaslessError) { - params.onLoadFee().map { LoadedFeeResult.Basic(it) } - } else { - Either.Left(error) + when (error) { + is GetFeeError.GaslessError.NotEnoughFunds -> error.left() + is GetFeeError.GaslessError -> { + // Something wrong with gasless fee, fallback to basic fee + params.onLoadFee().map { LoadedFeeResult.Basic(it) } + } + else -> error.left() } }, ifRight = { fee -> @@ -243,32 +247,38 @@ internal class FeeSelectorLogic @AssistedInject constructor( private suspend fun populateExtendedFee( fee: TransactionFeeExtended, ): Either = either { - val selectedToken = if (params.feeCryptoCurrencyStatus.currency.id != fee.feeTokenId) { - getSingleCryptoCurrencyStatusUseCase - .invokeMultiWalletSync( - userWalletId = params.userWalletId, - cryptoCurrencyId = fee.feeTokenId, - ).getOrElse { - raise(GetFeeError.DataError(IllegalStateException("No token found for id: ${fee.feeTokenId}"))) - } - } else { - params.feeCryptoCurrencyStatus + LoadedFeeResult.Extended( + fee = fee, + selectedToken = getSelectedTokenStatus(fee.feeTokenId).bind(), + availableTokens = getAvailableFeeTokens().bind(), + ) + } + + private suspend fun getSelectedTokenStatus(tokenId: CryptoCurrency.ID): Either = + either { + val feeTokenId = tokenId + if (params.feeCryptoCurrencyStatus.currency.id != feeTokenId) { + getSingleCryptoCurrencyStatusUseCase + .invokeMultiWalletSync( + userWalletId = params.userWalletId, + cryptoCurrencyId = feeTokenId, + ).getOrElse { + raise(GetFeeError.DataError(IllegalStateException("No token found for id: $tokenId"))) + } + } else { + params.feeCryptoCurrencyStatus + } } + private suspend fun getAvailableFeeTokens(): Either> = either { val userWallet = getUserWalletUseCase(params.userWalletId).mapLeft { GetFeeError.DataError(IllegalStateException("No wallet found for id: ${params.userWalletId}")) }.bind() - val availableTokens = getAvailableFeeTokensUseCase.invoke( + getAvailableFeeTokensUseCase.invoke( userWallet = userWallet, network = params.cryptoCurrencyStatus.currency.network, ).bind() - - LoadedFeeResult.Extended( - fee = fee, - selectedToken = selectedToken, - availableTokens = availableTokens, - ) } sealed class LoadedFeeResult { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorErrorTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorErrorTransformer.kt index 80386d5c77..7cac2f632f 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorErrorTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorErrorTransformer.kt @@ -7,6 +7,13 @@ import com.tangem.utils.transformer.Transformer internal class FeeSelectorErrorTransformer(private val error: GetFeeError) : Transformer { override fun transform(prevState: FeeSelectorUM): FeeSelectorUM { + if (prevState is FeeSelectorUM.Content && error is GetFeeError.GaslessError.NotEnoughFunds) { + return prevState.copy( + isPrimaryButtonEnabled = false, + feeExtraInfo = prevState.feeExtraInfo.copy(isNotEnoughFunds = true), + ) + } + return FeeSelectorUM.Error(error = error) } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt index f11d9c8dd9..c054119f3d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt @@ -18,6 +18,7 @@ class FeeSelectorTokenSelectedTransformer( feeItems = persistentListOf(FeeItem.Loading), feeExtraInfo = prevState.feeExtraInfo.copy( feeCryptoCurrencyStatus = selectedToken, + isNotEnoughFunds = false, ), ) } else { From 2cfd260e318b6e0568ee5a2933118a7a81e6cd04 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 18 Jan 2026 14:31:04 +0300 Subject: [PATCH 133/172] Updated on 2026-08-14 --- .../gasless/CreateAndSendGaslessTransactionUseCase.kt | 10 +++++----- .../confirm/model/SendWithSwapConfirmModel.kt | 3 ++- gradle/tangem_dependencies.toml | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt index 27cbc7cd31..853e9b55e2 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -11,7 +11,7 @@ import com.tangem.blockchain.common.* import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.extensions.formatHex -import com.tangem.blockchain.extensions.removeLeadingZero +import com.tangem.blockchain.extensions.normalizeByteArray import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData import com.tangem.common.CompletionResult import com.tangem.common.extensions.toDecompressedPublicKey @@ -172,8 +172,8 @@ class CreateAndSendGaslessTransactionUseCase( address = eip7702Data.executorAddress, nonce = eip7702Data.nonce, yParity = extendedEip7702Data.recId, - r = extendedEip7702Data.r.toFormattedHex(), - s = extendedEip7702Data.s.toFormattedHex(), + r = extendedEip7702Data.r.toFormattedHex(bytes = 32), + s = extendedEip7702Data.s.toFormattedHex(bytes = 32), ) return SignedGaslessData( @@ -361,8 +361,8 @@ class CreateAndSendGaslessTransactionUseCase( } private companion object { - fun BigInteger.toFormattedHex(): String { - return toByteArray().removeLeadingZero().toHexString().formatHex() + fun BigInteger.toFormattedHex(bytes: Int): String { + return toByteArray().normalizeByteArray(bytes).toHexString().formatHex() } } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 6ad374ecef..47225aeb2e 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -426,9 +426,10 @@ internal class SendWithSwapConfirmModel @Inject constructor( val hasError = hasSendError || hasSwapError uiState.update { state -> val feeUM = state.feeSelectorUM as? FeeSelectorUM.Content + val isTransactionInProcess = (state.confirmUM as? ConfirmUM.Content)?.isTransactionInProcess == true state.copy( confirmUM = (state.confirmUM as? ConfirmUM.Content)?.copy( - isPrimaryButtonEnabled = !hasError && feeUM != null, + isPrimaryButtonEnabled = !hasError && feeUM != null && !isTransactionInProcess, ) ?: state.confirmUM, ) } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index eef0c9e1fc..be628ea2c1 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-1370" +tangemBlockchainSdk = "develop-1371" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 19bdc77d9fab6550ce6f5de6fa0f37d050a36be3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 18 Jan 2026 17:39:48 +0300 Subject: [PATCH 134/172] Updated on 2026-08-14 --- .../send/v2/api/entity/FeeSelectorUM.kt | 5 ++++- .../v2/feeselector/model/FeeSelectorLogic.kt | 5 +++++ .../feeselector/ui/FeeSelectorBlockContent.kt | 4 ++++ .../tangem/feature/swap/model/SwapModel.kt | 22 +++++++++++++++++-- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt index 9d92d11e1c..f903e9ca35 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt @@ -27,7 +27,10 @@ sealed class FeeSelectorUM { override val isPrimaryButtonEnabled = false } - data class Error(val error: GetFeeError) : FeeSelectorUM() { + data class Error( + val error: GetFeeError, + val isHidden: Boolean = false, + ) : FeeSelectorUM() { override val isPrimaryButtonEnabled = false } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt index 100d288151..87fcceafc2 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt @@ -75,9 +75,14 @@ internal class FeeSelectorLogic @AssistedInject constructor( } private fun loadFee() { + if (uiState.value is FeeSelectorUM.Error) { + return + } + if (uiState.value !is FeeSelectorUM.Content) { uiState.update(FeeSelectorLoadingTransformer) } + modelScope.launch { callLoadFee() .fold( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt index 2024fbf748..c635854c69 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt @@ -61,6 +61,10 @@ internal fun FeeSelectorBlockContent( onReadMoreClick: () -> Unit, modifier: Modifier = Modifier, ) { + if (state is FeeSelectorUM.Error && state.isHidden) { + return + } + Row( modifier = modifier .background(TangemTheme.colors.background.action) 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 adc9b652e3..338aa29be8 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 @@ -651,13 +651,13 @@ internal class SwapModel @Inject constructor( modelScope.launch { feeSelectorReloadTrigger.triggerUpdate() } } } else { - feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError) + feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) Timber.e("Accidentally empty quotes list") } }, onError = { error -> Timber.e("Error when loading quotes: $error") - feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError) + feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) uiState = stateBuilder.addNotification(uiState, null) { startLoadingQuotesFromLastState() } }, ) @@ -2016,6 +2016,10 @@ internal class SwapModel @Inject constructor( return Either.Left(GetFeeError.UnknownError) } + if (isPermissionNotificationShown()) { + return Either.Left(GetFeeError.UnknownError) + } + return swapInteractor.loadFeeForSwapTransaction( fromToken = fromToken, fromAccount = dataState.fromAccount, @@ -2029,6 +2033,11 @@ internal class SwapModel @Inject constructor( } override fun onResult(newState: FeeSelectorUM) { + if (isPermissionNotificationShown()) { + state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) + return + } + state.value = newState // If fee currency is same as from currency, we need to reload quotes to update fee info @@ -2050,6 +2059,11 @@ internal class SwapModel @Inject constructor( } } + private fun isPermissionNotificationShown(): Boolean { + val permissionState = dataState.getCurrentLoadedSwapState()?.permissionState + return permissionState != null && permissionState !is PermissionDataState.Empty + } + override suspend fun loadFee(): Either { val sendCardData = uiState.sendCardData as? SwapCardState.SwapCardData ?: return Either.Left(GetFeeError.UnknownError) @@ -2063,6 +2077,10 @@ internal class SwapModel @Inject constructor( return Either.Left(GetFeeError.UnknownError) } + if (isPermissionNotificationShown()) { + return Either.Left(GetFeeError.UnknownError) + } + return swapInteractor.loadFeeForSwapTransaction( fromToken = fromToken, fromAccount = dataState.fromAccount, From 5aaf87d37076716fda40271420d73a82eeac5e8c Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 11:42:43 +0300 Subject: [PATCH 135/172] Updated on 2026-08-14 --- .../com/tangem/domain/wallets/extension/UserWalletExtensions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/extension/UserWalletExtensions.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/extension/UserWalletExtensions.kt index 1047bb4f03..693877d47c 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/extension/UserWalletExtensions.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/extension/UserWalletExtensions.kt @@ -36,7 +36,7 @@ val UserWallet.isAccountsSupported is UserWallet.Hot -> true is UserWallet.Cold -> { with(this.cardTypesResolver) { - isMultiwalletAllowed() && !isWhiteWallet() + isMultiwalletAllowed() && scanResponse.card.settings.isHDWalletAllowed } } } \ No newline at end of file From 8f2c28715ae670ffe02fa9a4aa450faaf287d66b Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 19 Jan 2026 10:20:24 +0100 Subject: [PATCH 136/172] Updated on 2026-08-14 --- .../components/feed/DefaultFeedComponent.kt | 5 ++++- .../tangem/features/feed/ui/feed/FeedList.kt | 18 +++++++++++++++--- .../presentation/wallet/ui/WalletScreen.kt | 19 ++++++++++--------- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt index 90ab30cbda..f7073e2ca2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt @@ -25,7 +25,10 @@ internal class DefaultFeedComponent( @Composable override fun Title(bottomSheetState: State) { val state by feedComponentModel.state.collectAsStateWithLifecycle() - FeedListHeader(state.feedListSearchBar) + FeedListHeader( + isSearchBarClickable = bottomSheetState.value == BottomSheetState.EXPANDED, + feedListSearchBar = state.feedListSearchBar, + ) } @Composable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index 5cf729b8ba..92313b9261 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -46,6 +46,7 @@ import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.conditional import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor @@ -56,9 +57,14 @@ import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.crea import com.tangem.features.feed.ui.feed.state.* @Composable -internal fun FeedListHeader(feedListSearchBar: FeedListSearchBar, modifier: Modifier = Modifier) { +internal fun FeedListHeader( + isSearchBarClickable: Boolean, + feedListSearchBar: FeedListSearchBar, + modifier: Modifier = Modifier, +) { val background = LocalMainBottomSheetColor.current.value FeedSearchBar( + isSearchBarClickable = isSearchBarClickable, feedListSearchBar = feedListSearchBar, modifier = modifier .drawBehind { drawRect(background) } @@ -102,13 +108,19 @@ internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) { } @Composable -private fun FeedSearchBar(feedListSearchBar: FeedListSearchBar, modifier: Modifier = Modifier) { +private fun FeedSearchBar( + isSearchBarClickable: Boolean, + feedListSearchBar: FeedListSearchBar, + modifier: Modifier = Modifier, +) { Row( modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(36.dp)) .background(color = TangemTheme.colors.field.focused) - .clickable(onClick = feedListSearchBar.onBarClick) + .conditional(condition = isSearchBarClickable) { + clickable(onClick = feedListSearchBar.onBarClick) + } .padding(14.dp), ) { Icon( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 22f5a87af7..803f2d873b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -449,20 +449,21 @@ private inline fun BaseScaffoldWithMarkets( } Column( - modifier = Modifier.sizeIn(maxHeight = maxHeight - statusBarHeight), + modifier = Modifier + // expand bottom sheet when clicked on the header + .clickable( + enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded, + indication = null, + interactionSource = null, + ) { + coroutineScope.launch { bottomSheetState.expand() } + } + .sizeIn(maxHeight = maxHeight - statusBarHeight), ) { Hand(Modifier.drawBehind { drawRect(backgroundColor.value) }) Box( modifier = Modifier - // expand bottom sheet when clicked on the header - .clickable( - enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded, - indication = null, - interactionSource = null, - ) { - coroutineScope.launch { bottomSheetState.expand() } - } .onFocusChanged { isSearchFieldFocused = it.isFocused }, From 0741b83490443d59f3f623d481a1f8a87a419da5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 19 Jan 2026 14:21:03 +0500 Subject: [PATCH 137/172] Updated on 2026-08-14 --- .../datasource/api/common/config/ApiConfig.kt | 4 +- .../datasource/api/common/config/TangemPay.kt | 71 ++++++++++++++++--- .../api/common/config/TangemPayAuth.kt | 53 -------------- .../tangem/datasource/api/pay/TangemPayApi.kt | 1 - .../tangem/datasource/di/ApiConfigsModule.kt | 12 ++-- .../config/environment/EnvironmentConfig.kt | 1 + .../converter/EnvironmentConfigConverter.kt | 1 + .../models/EnvironmentConfigModel.kt | 1 + .../api/common/config/ApiConfigTest.kt | 10 ++- .../managers/MockEnvironmentConfigStorage.kt | 4 ++ .../managers/ProdApiConfigsManagerTest.kt | 15 +++- .../repository/DefaultOnboardingRepository.kt | 9 +-- .../repository/TangemPayRequestPerformer.kt | 23 ++---- 13 files changed, 107 insertions(+), 98 deletions(-) delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPayAuth.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index 13b9af6b94..515a93b2f8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -40,8 +40,8 @@ sealed class ApiConfig { is TangemTech -> ID.TangemTech is StakeKit -> ID.StakeKit is P2PEthPool -> ID.P2PEthPool - is TangemPay -> ID.TangemPay - is TangemPayAuth -> ID.TangemPayAuth + is TangemPay.Bff -> ID.TangemPay + is TangemPay.Auth -> ID.TangemPayAuth is BlockAid -> ID.BlockAid is YieldSupply -> ID.YieldSupply is MoonPay -> ID.MoonPay diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt index c0a5c07775..b494cbc70c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt @@ -1,11 +1,13 @@ package com.tangem.datasource.api.common.config import com.tangem.datasource.BuildConfig +import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage import com.tangem.utils.ProviderSuspend import com.tangem.utils.version.AppVersionProvider -internal class TangemPay( +internal sealed class TangemPay( private val appVersionProvider: AppVersionProvider, + private val environmentConfigStorage: EnvironmentConfigStorage, ) : ApiConfig() { override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() @@ -16,6 +18,8 @@ internal class TangemPay( createProdEnvironment(), ) + protected abstract fun getBaseUrl(apiEnvironment: ApiEnvironment): String + private fun getInitialEnvironment(): ApiEnvironment { return when (BuildConfig.BUILD_TYPE) { MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK @@ -31,24 +35,75 @@ internal class TangemPay( private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( environment = ApiEnvironment.DEV, - baseUrl = "https://api.dev.us.paera.com/bff-v2/", - headers = createHeaders(), + baseUrl = getBaseUrl(ApiEnvironment.DEV), + headers = createHeaders(ApiEnvironment.DEV), ) private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( environment = ApiEnvironment.MOCK, - baseUrl = "[REDACTED_ENV_URL]", - headers = createHeaders(), + baseUrl = getBaseUrl(ApiEnvironment.MOCK), + headers = createHeaders(ApiEnvironment.MOCK), ) private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( environment = ApiEnvironment.PROD, - baseUrl = "https://api.us.paera.com/bff-v2/", - headers = createHeaders(), + baseUrl = getBaseUrl(ApiEnvironment.PROD), + headers = createHeaders(ApiEnvironment.PROD), ) - private fun createHeaders() = mapOf( + private fun createHeaders(apiEnvironment: ApiEnvironment) = mapOf( "version" to ProviderSuspend { appVersionProvider.versionName }, "platform" to ProviderSuspend { "Android" }, + "X-API-KEY" to ProviderSuspend { getBffStaticToken(apiEnvironment) }, ) + + private fun getBffStaticToken(apiEnvironment: ApiEnvironment): String { + return when (apiEnvironment) { + ApiEnvironment.MOCK, + ApiEnvironment.DEV, + -> environmentConfigStorage.getConfigSync().bffStaticTokenDev + ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().bffStaticToken + ApiEnvironment.STAGE, + ApiEnvironment.STAGE_2, + ApiEnvironment.DEV_2, + ApiEnvironment.DEV_3, + -> null + } ?: error("BffStaticToken is not provided for $apiEnvironment") + } + + class Bff( + appVersionProvider: AppVersionProvider, + environmentConfigStorage: EnvironmentConfigStorage, + ) : TangemPay(appVersionProvider, environmentConfigStorage) { + override fun getBaseUrl(apiEnvironment: ApiEnvironment): String { + return when (apiEnvironment) { + ApiEnvironment.DEV -> "https://api.dev.us.paera.com/bff-v2/" + ApiEnvironment.MOCK -> "[REDACTED_ENV_URL]" + ApiEnvironment.PROD -> "https://api.us.paera.com/bff-v2/" + ApiEnvironment.DEV_2, + ApiEnvironment.DEV_3, + ApiEnvironment.STAGE, + ApiEnvironment.STAGE_2, + -> error("Unknown environment: $apiEnvironment") + } + } + } + + class Auth( + appVersionProvider: AppVersionProvider, + environmentConfigStorage: EnvironmentConfigStorage, + ) : TangemPay(appVersionProvider, environmentConfigStorage) { + override fun getBaseUrl(apiEnvironment: ApiEnvironment): String { + return when (apiEnvironment) { + ApiEnvironment.DEV -> "https://api.dev.us.paera.com/" + ApiEnvironment.MOCK -> "[REDACTED_ENV_URL]" + ApiEnvironment.PROD -> "https://api.us.paera.com/" + ApiEnvironment.DEV_2, + ApiEnvironment.DEV_3, + ApiEnvironment.STAGE, + ApiEnvironment.STAGE_2, + -> error("Unknown environment: $apiEnvironment") + } + } + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPayAuth.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPayAuth.kt deleted file mode 100644 index cbbc29459d..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPayAuth.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.tangem.datasource.api.common.config - -import com.tangem.datasource.BuildConfig -import com.tangem.utils.ProviderSuspend -import com.tangem.utils.version.AppVersionProvider - -internal class TangemPayAuth( - private val appVersionProvider: AppVersionProvider, -) : ApiConfig() { - override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() - - override val environmentConfigs = listOf( - createDevEnvironment(), - createMockedEnvironment(), - createProdEnvironment(), - ) - - private fun getInitialEnvironment(): ApiEnvironment { - return when (BuildConfig.BUILD_TYPE) { - MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK - DEBUG_BUILD_TYPE, - INTERNAL_BUILD_TYPE, - -> ApiEnvironment.DEV - EXTERNAL_BUILD_TYPE, - RELEASE_BUILD_TYPE, - -> ApiEnvironment.PROD - else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") - } - } - - private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( - environment = ApiEnvironment.DEV, - baseUrl = "https://api.dev.us.paera.com/", - headers = createHeaders(), - ) - - private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( - environment = ApiEnvironment.MOCK, - baseUrl = "[REDACTED_ENV_URL]", - headers = createHeaders(), - ) - - private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( - environment = ApiEnvironment.PROD, - baseUrl = "https://api.us.paera.com/", - headers = createHeaders(), - ) - - private fun createHeaders() = mapOf( - "version" to ProviderSuspend { appVersionProvider.versionName }, - "platform" to ProviderSuspend { "Android" }, - ) -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 773dbb7b72..60484ac83b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -25,7 +25,6 @@ interface TangemPayApi { @GET("v1/customer/wallets/{customer_wallet_id}") suspend fun checkCustomerWalletId( - @Header("X-API-KEY") authHeader: String, @Path("customer_wallet_id") customerWalletId: String, ): ApiResponse diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index 2ae61bd722..20d4c632d5 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -86,13 +86,17 @@ internal object ApiConfigsModule { @Provides @IntoSet - fun provideTangemVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemPay(appVersionProvider) + fun provideTangemPayBffConfig( + appVersionProvider: AppVersionProvider, + environmentConfigStorage: EnvironmentConfigStorage, + ): ApiConfig = TangemPay.Bff(appVersionProvider, environmentConfigStorage) @Provides @IntoSet - fun provideTangemPayAuthConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemPayAuth( - appVersionProvider, - ) + fun provideTangemPayAuthConfig( + appVersionProvider: AppVersionProvider, + environmentConfigStorage: EnvironmentConfigStorage, + ): ApiConfig = TangemPay.Auth(appVersionProvider, environmentConfigStorage) @Provides @IntoSet diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt index c77d5ede0c..22545b5329 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt @@ -25,6 +25,7 @@ data class EnvironmentConfig( val yieldModuleApiKey: String? = null, val yieldModuleApiKeyDev: String? = null, val bffStaticToken: String? = null, + val bffStaticTokenDev: String? = null, val gaslessTxApiKeyDev: String? = null, val gaslessTxApiKey: String? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt index 8e19d3682e..6d7733109a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt @@ -33,6 +33,7 @@ internal object EnvironmentConfigConverter : Converter StakeKit(stakeKitAuthProvider = mockk()) - ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = mockk()) + ApiConfig.ID.TangemPay -> TangemPay.Bff( + appVersionProvider = mockk(), + environmentConfigStorage = mockk() + ) + ApiConfig.ID.TangemPayAuth -> TangemPay.Auth( + appVersionProvider = mockk(), + environmentConfigStorage = mockk() + ) ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk()) ApiConfig.ID.MoonPay -> MoonPay() ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk()) @@ -78,7 +85,6 @@ class ApiConfigTest { authProvider = appAuthProvider, appInfoProvider = mockk(), ) - ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = mockk()) ApiConfig.ID.GaslessTxService -> GaslessTxService( authProvider = appAuthProvider, appVersionProvider = mockk(), diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt index 7a7a84ca41..530d4de06e 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt @@ -18,6 +18,8 @@ internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage { blockAidApiKey = BLOCK_AID_API_KEY, tangemApiKey = TANGEM_API_KEY, tangemApiKeyDev = TANGEM_API_KEY_DEV, + bffStaticToken = TANGEM_PAY_BFF_KEY, + bffStaticTokenDev = TANGEM_PAY_BFF_KEY_DEV, tangemApiKeyStage = TANGEM_API_KEY_STAGE, yieldModuleApiKey = YIELD_MODULE_KEY, yieldModuleApiKeyDev = YIELD_MODULE_KEY_DEV, @@ -33,6 +35,8 @@ internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage { const val BLOCK_AID_API_KEY = "block_aid_api_key" const val TANGEM_API_KEY = "tangem_api_key" const val TANGEM_API_KEY_DEV = "tangem_api_key_dev" + const val TANGEM_PAY_BFF_KEY = "tangem_pay_bff_key" + const val TANGEM_PAY_BFF_KEY_DEV = "tangem_pay_bff_key_dev" const val TANGEM_GASLESS_API_KEY = "tangem_gasless_api_key" const val TANGEM_API_KEY_STAGE = "tangem_api_key_stage" const val YIELD_MODULE_KEY = "yield_module_api_key" diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index bb76d42aeb..76572d804f 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -13,6 +13,7 @@ import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.BLOCK_AID_API_KEY import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_API_KEY import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_GASLESS_API_KEY +import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_PAY_BFF_KEY_DEV import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.lib.auth.P2PEthPoolAuthProvider @@ -115,7 +116,14 @@ internal class ProdApiConfigsManagerTest { ) } ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = stakeKitAuthProvider) - ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = appVersionProvider) + ApiConfig.ID.TangemPay -> TangemPay.Bff( + appVersionProvider = appVersionProvider, + environmentConfigStorage = environmentConfigStorage, + ) + ApiConfig.ID.TangemPayAuth -> TangemPay.Auth( + appVersionProvider = appVersionProvider, + environmentConfigStorage = environmentConfigStorage, + ) ApiConfig.ID.BlockAid -> BlockAid(configStorage = environmentConfigStorage) ApiConfig.ID.MoonPay -> MoonPay() ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider) @@ -124,7 +132,6 @@ internal class ProdApiConfigsManagerTest { authProvider = appAuthProvider, appInfoProvider = appInfoProvider, ) - ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = appVersionProvider) ApiConfig.ID.GaslessTxService -> GaslessTxService( authProvider = appAuthProvider, appVersionProvider = appVersionProvider, @@ -141,11 +148,11 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.TangemTech -> createTangemTechModel() ApiConfig.ID.StakeKit -> createStakeKitModel() ApiConfig.ID.TangemPay -> createTangemPayModel() + ApiConfig.ID.TangemPayAuth -> createTangemPayAuthModel() ApiConfig.ID.BlockAid -> createBlockAidSdkModel() ApiConfig.ID.MoonPay -> createMoonPayModel() ApiConfig.ID.P2PEthPool -> createP2PModel() ApiConfig.ID.News -> createNewsModel() - ApiConfig.ID.TangemPayAuth -> createTangemPayAuthModel() ApiConfig.ID.GaslessTxService -> createGaslessTxServiceModel() } } @@ -269,6 +276,7 @@ internal class ProdApiConfigsManagerTest { headers = mapOf( "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "Android" }, + "X-API-KEY" to ProviderSuspend { TANGEM_PAY_BFF_KEY_DEV }, ), ), ) @@ -283,6 +291,7 @@ internal class ProdApiConfigsManagerTest { headers = mapOf( "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "Android" }, + "X-API-KEY" to ProviderSuspend { TANGEM_PAY_BFF_KEY_DEV }, ), ), ) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index aac0e1c218..0872260f70 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -185,11 +185,8 @@ internal class DefaultOnboardingRepository @Inject constructor( return Either.Right(hasTangemPay) } - return requestHelper.performWithStaticToken { staticToken -> - tangemPayApi.checkCustomerWalletId( - authHeader = staticToken, - customerWalletId = userWalletId.stringValue, - ) + return requestHelper.performWithStaticToken { + tangemPayApi.checkCustomerWalletId(customerWalletId = userWalletId.stringValue) }.map { response -> val id = response.result?.id val isTangemPayEnabled = response.result?.isTangemPayEnabled == true @@ -205,7 +202,7 @@ internal class DefaultOnboardingRepository @Inject constructor( } override suspend fun checkCustomerEligibility(): Boolean { - val response = requestHelper.performWithoutToken { + val response = requestHelper.performWithStaticToken { tangemPayApi.checkCustomerEligibility() }.getOrNull() diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt index a788639680..e85c5c6f64 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt @@ -11,7 +11,6 @@ import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.pay.TangemPayAuthApi import com.tangem.datasource.api.pay.models.request.RefreshCustomerWalletAccessTokenRequest import com.tangem.datasource.api.pay.models.response.TangemPayGetTokensResponse -import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.visa.error.VisaApiError @@ -31,7 +30,6 @@ private const val TAG = "TangemPayRequestPerformer" @Singleton internal class TangemPayRequestPerformer @Inject constructor( private val errorConverter: TangemPayErrorConverter, - private val environmentConfigStorage: EnvironmentConfigStorage, private val dispatchers: CoroutineDispatcherProvider, private val tangemPayAuthApi: TangemPayAuthApi, private val tangemPayStorage: TangemPayStorage, @@ -40,23 +38,10 @@ internal class TangemPayRequestPerformer @Inject constructor( private val customerWalletAddresses = ConcurrentHashMap() private val tokensMutex = Mutex() - suspend fun performWithStaticToken( - requestBlock: suspend (header: String) -> ApiResponse, - ): Either = withContext(dispatchers.io) { - catch( - block = { - val staticToken = - environmentConfigStorage.getConfigSync().bffStaticToken ?: error("BFF static token is null") - when (val apiResponse = requestBlock(staticToken)) { - is ApiResponse.Error -> errorConverter.convert(apiResponse.cause).left() - is ApiResponse.Success -> apiResponse.data.right() - } - }, - catch = { errorConverter.convert(it).left() }, - ) - } - - suspend fun performWithoutToken(requestBlock: suspend () -> ApiResponse): Either = + /** + * Static token added in headers [com.tangem.datasource.api.common.config.TangemPay] + */ + suspend fun performWithStaticToken(requestBlock: suspend () -> ApiResponse): Either = withContext(dispatchers.io) { catch( block = { From 04ce95d61a23c25b6d169c282d07336ec31caa4c Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 19 Jan 2026 10:23:07 +0100 Subject: [PATCH 138/172] Updated on 2026-08-14 --- .../com/tangem/features/feed/model/feed/FeedComponentModel.kt | 2 +- .../com/tangem/features/feed/ui/feed/state/FeedListUM.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index a54a7d58df..29cf1833dd 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -203,7 +203,7 @@ internal class FeedComponentModel @Inject constructor( put(it, MarketChartUM.Loading) } }.toPersistentHashMap(), - currentSortByType = SortByTypeUM.Trending, + currentSortByType = SortByTypeUM.TopGainers, ), globalState = GlobalFeedState.Loading, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt index 05db893d32..d9c50773a0 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt @@ -52,10 +52,10 @@ internal data class MarketChartConfig( val currentSortByType: SortByTypeUM = SortByTypeUM.TopGainers, ) { fun getFilterPreset() = persistentListOf( - SortByTypeUM.Trending, - SortByTypeUM.ExperiencedBuyers, SortByTypeUM.TopGainers, SortByTypeUM.TopLosers, + SortByTypeUM.ExperiencedBuyers, + SortByTypeUM.Trending, ) } From 1d374e5d075c43ae33b59b2ecf18fc9e2717815e Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 19 Jan 2026 13:46:47 +0700 Subject: [PATCH 139/172] Updated on 2026-08-14 --- .../tangem/data/nft/DefaultNFTRepository.kt | 24 +++++++++++-------- .../domain/nft/GetNFTCollectionsUseCase.kt | 7 +++--- .../transformer/UpdateDataStateTransformer.kt | 8 ++++++- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt index af884a8963..851849db2a 100644 --- a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt +++ b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt @@ -38,6 +38,8 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import timber.log.Timber import java.util.concurrent.ConcurrentHashMap @@ -63,6 +65,7 @@ internal class DefaultNFTRepository @Inject constructor( private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains) private val nftRuntimeStores = ConcurrentHashMap() + private val nftRuntimeStoresMutex = Mutex() private val nftPersistenceStores = ConcurrentHashMap() private val collectionIdConverter = NFTSdkCollectionIdentifierConverter @@ -426,16 +429,17 @@ internal class DefaultNFTRepository @Inject constructor( private suspend fun getNFTRuntimeStore(userWalletId: UserWalletId, network: Network): NFTRuntimeStore { val storeId = (userWalletId to network).formatted() - return nftRuntimeStores.getOrPut(storeId) { - nftRuntimeStoreFactory.provide(network).also { - nftRuntimeStores[storeId] = it - val storedCollections = getStoredCollections(userWalletId, network) - val storedPrices = getStoredPrices(userWalletId, network) - it.initialize( - collections = storedCollections, - prices = storedPrices, - ) - } + return nftRuntimeStoresMutex.withLock { + nftRuntimeStores[storeId]?.let { return it } + val store = nftRuntimeStoreFactory.provide(network) + val storedCollections = getStoredCollections(userWalletId, network) + val storedPrices = getStoredPrices(userWalletId, network) + store.initialize( + collections = storedCollections, + prices = storedPrices, + ) + nftRuntimeStores[storeId] = store + store } } diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt index db4c13cacc..95b2fd53cb 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt @@ -34,15 +34,15 @@ class GetNFTCollectionsUseCase( @OptIn(ExperimentalCoroutinesApi::class) fun invokeForAccounts(userWalletId: UserWalletId): Flow { - fun Account.flowOfNFTCollections(): Flow>> { + fun Account.flowOfNFTCollections(): Flow>>? { val currencies = (this as? Account.CryptoPortfolio)?.cryptoCurrencies.orEmpty() - + if (currencies.isEmpty()) return null return nftCollections(userWalletId = userWalletId, cryptoCurrencies = currencies.toList()) .map { nfts -> this to nfts } } return singleAccountListSupplier(userWalletId) - .mapLatest { statusList -> statusList.accounts.map { it.flowOfNFTCollections() } } + .mapLatest { statusList -> statusList.accounts.mapNotNull { it.flowOfNFTCollections() } } .flatMapLatest { flows -> combine(flows) { WalletNFTCollections(it.toMap()) } } } @@ -53,6 +53,7 @@ class GetNFTCollectionsUseCase( val networks = cryptoCurrencies .map { cryptoCurrency -> cryptoCurrency.network } .distinct() + if (networks.isEmpty()) return flowOf(emptyList()) return nftRepository.observeCollections(userWalletId, networks) } } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt index 431de33406..0e4605da31 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt @@ -84,7 +84,7 @@ internal class UpdateDataStateTransformer( if (isAccountMode) { val result = mutableListOf() walletNFTCollections.collections.forEach { (account, nfts) -> - if (nfts.isEmpty()) return@forEach + if (nfts.hasNoContentCollections()) return@forEach result.add(account.toAccountPortfolioUM()) result.addAll(createNFTsUM(nfts)) } @@ -105,6 +105,12 @@ internal class UpdateDataStateTransformer( ), ) + private fun List.hasNoContentCollections() = this + .all { it.contentCollections().isEmpty() } + + private fun NFTCollections.contentCollections() = (this.content as? NFTCollections.Content.Collections) + ?.collections.orEmpty() + private fun NFTCollectionsStateUM.createNFTsUM(nftCollections: List): Sequence = nftCollections .map { it.content } From 03e5416cba74086e5adb713b4d63163fb0192d47 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 19 Jan 2026 13:27:29 +0300 Subject: [PATCH 140/172] Updated on 2026-08-14 --- .../src/main/assets/configs/feature_toggles_config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 51984d020a..59a153de56 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -61,11 +61,11 @@ }, { "name": "ACCOUNTS_FEATURE_ENABLED", - "version": "undefined" + "version": "5.33.0" }, { "name": "FEED_ENABLED", - "version": "undefined" + "version": "5.33.0" }, { "name": "APP_REDESIGN_ENABLED", From 27e199afdd59ad49dcdd10b58504acb689db35db Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 19 Jan 2026 11:29:33 +0100 Subject: [PATCH 141/172] Updated on 2026-08-14 --- .../UpdateTrendingNewsStateTransformer.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateTrendingNewsStateTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateTrendingNewsStateTransformer.kt index 5b19b9c5a9..33c625fb7f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateTrendingNewsStateTransformer.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateTrendingNewsStateTransformer.kt @@ -12,6 +12,7 @@ import com.tangem.features.feed.ui.feed.state.NewsUMState import com.tangem.utils.Provider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +import org.joda.time.DateTime internal class UpdateTrendingNewsStateTransformer( private val result: TrendingNews, @@ -77,11 +78,14 @@ internal class UpdateTrendingNewsStateTransformer( private fun separateTrendingAndCommonArticles( articles: List, ): Pair> { - val trendingArticleIndex = articles.indexOfFirst { it.isTrending } - return if (trendingArticleIndex != -1) { - val trendingArticle = articles[trendingArticleIndex] - val commonArticles = articles.toMutableList().apply { removeAt(trendingArticleIndex) } - trendingArticle to commonArticles + val trendingArticles = articles.filter { it.isTrending } + return if (trendingArticles.isNotEmpty()) { + val newestTrendingArticle = trendingArticles.maxByOrNull { article -> + runCatching { DateTime.parse(article.createdAt).millis }.getOrElse { 0L } + } + val commonArticles = articles.filterNot { it.isTrending || it == newestTrendingArticle } + + trendingArticles.filter { it != newestTrendingArticle } + newestTrendingArticle to commonArticles } else { null to articles } From d839a616ef03a583e83bc65693810afa437d59b6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 19 Jan 2026 13:32:27 +0200 Subject: [PATCH 142/172] Updated on 2026-08-14 --- .../data/staking/DefaultStakingRepository.kt | 4 +- .../di/StakingBalanceSupplierModule.kt | 8 +-- .../data/staking/di/StakingDataModule.kt | 13 ++-- .../DefaultMultiStakingBalanceFetcher.kt | 18 +++--- .../DefaultMultiStakingBalanceProducer.kt | 8 +-- .../store/DefaultP2PEthPoolBalancesStore.kt | 5 +- ...ore.kt => DefaultStakeKitBalancesStore.kt} | 23 ++++++- ...ancesStore.kt => StakeKitBalancesStore.kt} | 2 +- .../staking/utils/DefaultStakingCleaner.kt | 25 +++++--- .../DefaultMultiStakingBalanceFetcherTest.kt | 62 +++++++++---------- .../DefaultMultiStakingBalanceProducerTest.kt | 34 +++++----- .../StakingBalancesStoreGetMethodTest.kt | 2 +- .../StakingBalancesStoreInitializationTest.kt | 6 +- .../StakingBalancesStoreUpdateMethodsTest.kt | 2 +- .../utils/DefaultStakingCleanerTest.kt | 62 +++++++++++++------ .../impl/presentation/model/StakingModel.kt | 13 ++-- 16 files changed, 172 insertions(+), 115 deletions(-) rename data/staking/src/main/java/com/tangem/data/staking/store/{DefaultStakingBalancesStore.kt => DefaultStakeKitBalancesStore.kt} (88%) rename data/staking/src/main/java/com/tangem/data/staking/store/{StakingBalancesStore.kt => StakeKitBalancesStore.kt} (88%) diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt index 343fae5888..626219cb64 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt @@ -3,7 +3,7 @@ package com.tangem.data.staking import arrow.core.getOrElse import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toBlockchain -import com.tangem.data.staking.store.StakingBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.domain.card.common.TapWorkarounds.isWallet2 import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.staking.StakingBalance @@ -30,7 +30,7 @@ import kotlinx.coroutines.withContext internal class DefaultStakingRepository( private val stakeKitRepository: StakeKitRepository, private val p2pEthPoolRepository: P2PEthPoolRepository, - private val stakingBalanceStoreV2: StakingBalancesStore, + private val stakingBalanceStoreV2: StakeKitBalancesStore, private val dispatchers: CoroutineDispatcherProvider, private val getUserWalletUseCase: GetUserWalletUseCase, private val stakingFeatureToggles: StakingFeatureToggles, diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt index 0b50257936..60fd0250f8 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt @@ -2,9 +2,9 @@ package com.tangem.data.staking.di import androidx.datastore.core.DataStore import com.tangem.data.staking.store.DefaultP2PEthPoolBalancesStore -import com.tangem.data.staking.store.DefaultStakingBalancesStore +import com.tangem.data.staking.store.DefaultStakeKitBalancesStore import com.tangem.data.staking.store.P2PEthPoolBalancesStore -import com.tangem.data.staking.store.StakingBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO import com.tangem.datasource.local.datastore.RuntimeSharedStore @@ -28,8 +28,8 @@ internal object StakingBalanceSupplierModule { fun provideStakingBalancesStore( persistenceStore: DataStore>>, dispatchers: CoroutineDispatcherProvider, - ): StakingBalancesStore { - return DefaultStakingBalancesStore( + ): StakeKitBalancesStore { + return DefaultStakeKitBalancesStore( runtimeStore = RuntimeSharedStore(), persistenceStore = persistenceStore, dispatchers = dispatchers, diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt index 220bdc1198..f1c17a770a 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -5,7 +5,8 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.data.staking.* import com.tangem.data.staking.converters.error.StakeKitErrorConverter -import com.tangem.data.staking.store.StakingBalancesStore +import com.tangem.data.staking.store.P2PEthPoolBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.data.staking.toggles.DefaultStakingFeatureToggles import com.tangem.data.staking.utils.DefaultStakingCleaner import com.tangem.datasource.api.ethpool.P2PEthPoolApi @@ -56,7 +57,7 @@ internal object StakingDataModule { fun provideStakingRepository( stakeKitRepository: StakeKitRepository, p2pEthPoolRepository: P2PEthPoolRepository, - stakingBalancesStore: StakingBalancesStore, + stakeKitBalancesStore: StakeKitBalancesStore, dispatchers: CoroutineDispatcherProvider, getUserWalletUseCase: GetUserWalletUseCase, stakingFeatureToggles: StakingFeatureToggles, @@ -65,7 +66,7 @@ internal object StakingDataModule { return DefaultStakingRepository( stakeKitRepository = stakeKitRepository, p2pEthPoolRepository = p2pEthPoolRepository, - stakingBalanceStoreV2 = stakingBalancesStore, + stakingBalanceStoreV2 = stakeKitBalancesStore, dispatchers = dispatchers, getUserWalletUseCase = getUserWalletUseCase, walletManagersFacade = walletManagersFacade, @@ -138,12 +139,14 @@ internal object StakingDataModule { @Singleton fun provideStakingCleaner( stakingIdFactory: StakingIdFactory, - stakingBalancesStore: StakingBalancesStore, + stakeKitBalancesStore: StakeKitBalancesStore, + p2pEthPoolBalancesStore: P2PEthPoolBalancesStore, dispatchers: CoroutineDispatcherProvider, ): StakingCleaner { return DefaultStakingCleaner( stakingIdFactory = stakingIdFactory, - stakingBalancesStore = stakingBalancesStore, + stakeKitBalancesStore = stakeKitBalancesStore, + p2pEthPoolBalancesStore = p2pEthPoolBalancesStore, dispatchers = dispatchers, ) } 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 2ff4700bb3..4a1a5c9a1b 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 @@ -6,7 +6,7 @@ import arrow.core.right import arrow.core.toOption import com.tangem.data.common.api.safeApiCall import com.tangem.data.staking.store.P2PEthPoolBalancesStore -import com.tangem.data.staking.store.StakingBalancesStore +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 @@ -43,7 +43,7 @@ import javax.inject.Inject * * @property userWalletsStore user wallets store * @property stakingYieldsStore staking yields store - * @property stakingBalancesStore staking balances store (StakeKit) + * @property stakeKitBalancesStore staking balances store (StakeKit) * @property p2PEthPoolBalancesStore P2PEthPool balances store * @property stakeKitApi stake kit API * @property p2pEthPoolApi P2PEthPool API @@ -56,7 +56,7 @@ import javax.inject.Inject internal class DefaultMultiStakingBalanceFetcher @Inject constructor( private val userWalletsStore: UserWalletsStore, private val stakingYieldsStore: StakingYieldsStore, - private val stakingBalancesStore: StakingBalancesStore, + private val stakeKitBalancesStore: StakeKitBalancesStore, private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore, private val stakeKitApi: StakeKitApi, private val p2pEthPoolApi: P2PEthPoolApi, @@ -106,7 +106,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( Timber.e(throwable, "Unable to fetch staking balances $params") if (stakeKitIds.isNotEmpty()) { - stakingBalancesStore.storeError( + stakeKitBalancesStore.storeError( userWalletId = params.userWalletId, stakingIds = stakeKitIds.toSet(), ) @@ -121,7 +121,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( } private suspend fun fetchStakeKitBalances(userWalletId: UserWalletId, stakingIds: Set) { - stakingBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds) + stakeKitBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds) val availableStakingIds = getAvailableStakingIds( userWalletId = userWalletId, @@ -270,7 +270,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( ) if (unavailableStakingIds.isNotEmpty()) { - stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = unavailableStakingIds.toSet()) + stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = unavailableStakingIds.toSet()) } return availableStakingIds.toSet().ifEmpty { @@ -322,7 +322,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( Timber.i( "Successfully fetched staking balances for $userWalletId:\n${yieldBalances.joinToString("\n")}", ) - stakingBalancesStore.storeActual(userWalletId = userWalletId, values = yieldBalances) + stakeKitBalancesStore.storeActual(userWalletId = userWalletId, values = yieldBalances) if (!allResponsesReceived(requests, yieldBalances)) { val values = stakingIds.filter { stakingId -> @@ -332,13 +332,13 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( } } - stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = values.toSet()) + stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = values.toSet()) } }, onError = { throwable -> Timber.e(throwable, "Unable to fetch staking balances $userWalletId") - stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) + stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) throw throwable }, diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducer.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducer.kt index 0ce6065dae..404059222f 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducer.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducer.kt @@ -3,7 +3,7 @@ package com.tangem.data.staking.multi import arrow.core.Option import arrow.core.some import com.tangem.data.staking.store.P2PEthPoolBalancesStore -import com.tangem.data.staking.store.StakingBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.staking.multi.MultiStakingBalanceProducer import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.onEmpty * Combines staking balances from both StakeKit and P2PEthPool providers. * * @property params params - * @property stakingBalancesStore StakeKit staking balances store + * @property stakeKitBalancesStore StakeKit staking balances store * @property p2PEthPoolBalancesStore P2PEthPool balances store * @property dispatchers dispatchers * @@ -30,7 +30,7 @@ import kotlinx.coroutines.flow.onEmpty */ internal class DefaultMultiStakingBalanceProducer @AssistedInject constructor( @Assisted val params: MultiStakingBalanceProducer.Params, - private val stakingBalancesStore: StakingBalancesStore, + private val stakeKitBalancesStore: StakeKitBalancesStore, private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore, private val dispatchers: CoroutineDispatcherProvider, ) : MultiStakingBalanceProducer { @@ -38,7 +38,7 @@ internal class DefaultMultiStakingBalanceProducer @AssistedInject constructor( override val fallback: Option> = emptySet().some() override fun produce(): Flow> { - val stakeKitFlow = stakingBalancesStore.get(userWalletId = params.userWalletId) + val stakeKitFlow = stakeKitBalancesStore.get(userWalletId = params.userWalletId) val p2pEthPoolFlow = p2PEthPoolBalancesStore.get(userWalletId = params.userWalletId) return combine(stakeKitFlow, p2pEthPoolFlow) { stakeKitBalances, p2pEthPoolBalances -> diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt index 4bb91ccd21..d43261002e 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt @@ -8,7 +8,6 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.addOrReplace import kotlinx.coroutines.CoroutineScope @@ -151,13 +150,13 @@ internal class DefaultP2PEthPoolBalancesStore( } private suspend fun clearInPersistence(userWalletId: UserWalletId, stakingIds: Set) { - val integrationIds = stakingIds.map { it.integrationId }.toSet() + val addressesToClear = stakingIds.map { it.address }.toSet() persistenceStore.updateData { current -> current.toMutableMap().apply { this[userWalletId.stringValue] = this[userWalletId.stringValue].orEmpty() .filterNot { response -> - StakingIntegrationID.P2PEthPool.value in integrationIds + response.delegatorAddress in addressesToClear } .toSet() } diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakingBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakeKitBalancesStore.kt similarity index 88% rename from data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakingBalancesStore.kt rename to data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakeKitBalancesStore.kt index b2b0ba10a9..16c5404a01 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakingBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakeKitBalancesStore.kt @@ -22,7 +22,7 @@ internal typealias WalletIdWithWrappers = Map> /** - * Default implementation of [StakingBalancesStore] + * Default implementation of [StakeKitBalancesStore] * * @property runtimeStore runtime store * @property persistenceStore persistence store @@ -30,11 +30,11 @@ internal typealias WalletIdWithStakingBalances = Map, private val persistenceStore: DataStore, dispatchers: CoroutineDispatcherProvider, -) : StakingBalancesStore { +) : StakeKitBalancesStore { private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) @@ -97,6 +97,23 @@ internal class DefaultStakingBalancesStore( } override suspend fun clear(userWalletId: UserWalletId, stakingIds: Set) { + coroutineScope { + launch { clearInRuntime(userWalletId = userWalletId, stakingIds = stakingIds) } + launch { clearInPersistence(userWalletId = userWalletId, stakingIds = stakingIds) } + } + } + + private suspend fun clearInRuntime(userWalletId: UserWalletId, stakingIds: Set) { + runtimeStore.update(default = emptyMap()) { stored -> + stored.toMutableMap().apply { + this[userWalletId] = this[userWalletId].orEmpty() + .filterNot { it.stakingId in stakingIds } + .toSet() + } + } + } + + private suspend fun clearInPersistence(userWalletId: UserWalletId, stakingIds: Set) { persistenceStore.updateData { current -> current.toMutableMap().apply { this[userWalletId.stringValue] = this[userWalletId.stringValue].orEmpty() diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/StakingBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/StakeKitBalancesStore.kt similarity index 88% rename from data/staking/src/main/java/com/tangem/data/staking/store/StakingBalancesStore.kt rename to data/staking/src/main/java/com/tangem/data/staking/store/StakeKitBalancesStore.kt index aff991ff47..e9ea59d60d 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/StakingBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/StakeKitBalancesStore.kt @@ -8,7 +8,7 @@ import com.tangem.domain.models.wallet.UserWalletId * * Extends [BaseStakingBalancesStore] with StakeKit-specific storage operations. */ -interface StakingBalancesStore : BaseStakingBalancesStore { +interface StakeKitBalancesStore : BaseStakingBalancesStore { /** Store actual StakeKit yield balances */ suspend fun storeActual(userWalletId: UserWalletId, values: Set) diff --git a/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt b/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt index 396099e6ff..0fe16ead81 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt @@ -1,7 +1,8 @@ package com.tangem.data.staking.utils +import com.tangem.data.staking.store.P2PEthPoolBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.data.staking.store.StakingBalancesStore import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.StakingIdFactory @@ -16,14 +17,16 @@ import timber.log.Timber /** * Default implementation of [StakingCleaner]. * - * @property stakingBalancesStore Store to manage staking balances. + * @property stakeKitBalancesStore Store to manage StakeKit staking balances. + * @property p2pEthPoolBalancesStore Store to manage P2PEthPool staking balances. * @property dispatchers Coroutine dispatchers provider. * [REDACTED_AUTHOR] */ internal class DefaultStakingCleaner( private val stakingIdFactory: StakingIdFactory, - private val stakingBalancesStore: StakingBalancesStore, + private val stakeKitBalancesStore: StakeKitBalancesStore, + private val p2pEthPoolBalancesStore: P2PEthPoolBalancesStore, private val dispatchers: CoroutineDispatcherProvider, ) : StakingCleaner { @@ -53,15 +56,23 @@ internal class DefaultStakingCleaner( withContext(dispatchers.default) { awaitAll( - async { clearStatusesStore(userWalletId = userWalletId, stakingIds = stakingIds) }, + async { clearStakeKitBalancesStore(userWalletId = userWalletId, stakingIds = stakingIds) }, + async { clearP2PEthPoolBalancesStore(userWalletId = userWalletId, stakingIds = stakingIds) }, ) } } - private suspend fun clearStatusesStore(userWalletId: UserWalletId, stakingIds: Set) { + private suspend fun clearStakeKitBalancesStore(userWalletId: UserWalletId, stakingIds: Set) { runSuspendCatching { - stakingBalancesStore.clear(userWalletId, stakingIds) + stakeKitBalancesStore.clear(userWalletId, stakingIds) } - .onFailure { Timber.e(it, "Failed to clear yield balance statuses for wallet: $userWalletId") } + .onFailure { Timber.e(it, "Failed to clear StakeKit balance statuses for wallet: $userWalletId") } + } + + private suspend fun clearP2PEthPoolBalancesStore(userWalletId: UserWalletId, stakingIds: Set) { + runSuspendCatching { + p2pEthPoolBalancesStore.clear(userWalletId, stakingIds) + } + .onFailure { Timber.e(it, "Failed to clear P2PEthPool balance statuses for wallet: $userWalletId") } } } \ 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 30f1ed325f..65412ad690 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 @@ -5,7 +5,7 @@ import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory import com.tangem.common.test.data.staking.MockYieldDTOFactory import com.tangem.common.test.domain.wallet.MockUserWalletFactory import com.tangem.data.staking.store.P2PEthPoolBalancesStore -import com.tangem.data.staking.store.StakingBalancesStore +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.common.response.ApiResponseError @@ -35,7 +35,7 @@ internal class DefaultMultiStakingBalanceFetcherTest { private val userWalletsStore: UserWalletsStore = mockk() private val stakingYieldsStore: StakingYieldsStore = mockk() - private val stakingBalancesStore: StakingBalancesStore = mockk(relaxUnitFun = true) + private val stakeKitBalancesStore: StakeKitBalancesStore = mockk(relaxUnitFun = true) private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore = mockk(relaxUnitFun = true) private val stakeKitApi: StakeKitApi = mockk() private val p2pEthPoolApi: P2PEthPoolApi = mockk() @@ -44,7 +44,7 @@ internal class DefaultMultiStakingBalanceFetcherTest { private val fetcher = DefaultMultiStakingBalanceFetcher( userWalletsStore = userWalletsStore, stakingYieldsStore = stakingYieldsStore, - stakingBalancesStore = stakingBalancesStore, + stakeKitBalancesStore = stakeKitBalancesStore, p2PEthPoolBalancesStore = p2PEthPoolBalancesStore, stakeKitApi = stakeKitApi, p2pEthPoolApi = p2pEthPoolApi, @@ -54,7 +54,7 @@ internal class DefaultMultiStakingBalanceFetcherTest { @BeforeEach fun resetMocks() { - clearMocks(userWalletsStore, stakingYieldsStore, stakingBalancesStore, stakeKitApi) + clearMocks(userWalletsStore, stakingYieldsStore, stakeKitBalancesStore, stakeKitApi) } @Test @@ -81,13 +81,13 @@ internal class DefaultMultiStakingBalanceFetcherTest { // Assert coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) - stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) stakingYieldsStore.getSyncWithTimeout() stakeKitApi.getMultipleYieldBalances(requests) - stakingBalancesStore.storeActual(userWalletId = userWalletId, values = result) + stakeKitBalancesStore.storeActual(userWalletId = userWalletId, values = result) } - coVerify(inverse = true) { stakingBalancesStore.storeError(any(), any()) } + coVerify(inverse = true) { stakeKitBalancesStore.storeError(any(), any()) } assertEitherRight(actual) } @@ -113,11 +113,11 @@ internal class DefaultMultiStakingBalanceFetcherTest { // Assert coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) - stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) stakingYieldsStore.getSyncWithTimeout() - stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(solanaId)) + stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(solanaId)) stakeKitApi.getMultipleYieldBalances(requests) - stakingBalancesStore.storeActual(userWalletId = userWalletId, values = result) + stakeKitBalancesStore.storeActual(userWalletId = userWalletId, values = result) } assertEitherRight(actual) @@ -138,11 +138,11 @@ internal class DefaultMultiStakingBalanceFetcherTest { coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) } coVerify(inverse = true) { - stakingBalancesStore.refresh(userWalletId = any(), stakingIds = any()) + stakeKitBalancesStore.refresh(userWalletId = any(), stakingIds = any()) stakingYieldsStore.getSyncWithTimeout() stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any()) - stakingBalancesStore.storeActual(userWalletId = any(), values = any()) - stakingBalancesStore.storeError(userWalletId = any(), stakingIds = any()) + stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) + stakeKitBalancesStore.storeError(userWalletId = any(), stakingIds = any()) } val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${userWallet.toOption()}") @@ -164,11 +164,11 @@ internal class DefaultMultiStakingBalanceFetcherTest { coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) } coVerify(inverse = true) { - stakingBalancesStore.refresh(userWalletId = any(), stakingIds = any()) + stakeKitBalancesStore.refresh(userWalletId = any(), stakingIds = any()) stakingYieldsStore.getSyncWithTimeout() stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any()) - stakingBalancesStore.storeActual(userWalletId = any(), values = any()) - stakingBalancesStore.storeError(userWalletId = any(), stakingIds = any()) + stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) + stakeKitBalancesStore.storeError(userWalletId = any(), stakingIds = any()) } val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${null.toOption()}") @@ -190,14 +190,14 @@ internal class DefaultMultiStakingBalanceFetcherTest { // Assert coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) - stakingBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) + stakeKitBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) stakingYieldsStore.getSyncWithTimeout() - stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds) + stakeKitBalancesStore.storeError(userWalletId, tonAndSolanaIds) } coVerify(inverse = true) { stakeKitApi.getMultipleYieldBalances(any()) - stakingBalancesStore.storeActual(userWalletId = any(), values = any()) + stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) } val expected = IllegalStateException("No enabled yields for ${params.userWalletId}") @@ -219,14 +219,14 @@ internal class DefaultMultiStakingBalanceFetcherTest { // Assert coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) - stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) stakingYieldsStore.getSyncWithTimeout() - stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds) + stakeKitBalancesStore.storeError(userWalletId, tonAndSolanaIds) } coVerify(inverse = true) { stakeKitApi.getMultipleYieldBalances(any()) - stakingBalancesStore.storeActual(userWalletId = any(), values = any()) + stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) } val expected = IllegalStateException("No enabled yields for ${params.userWalletId}") @@ -253,14 +253,14 @@ internal class DefaultMultiStakingBalanceFetcherTest { // Assert coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) - stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) stakingYieldsStore.getSyncWithTimeout() - stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds) + stakeKitBalancesStore.storeError(userWalletId, tonAndSolanaIds) } coVerify(inverse = true) { stakeKitApi.getMultipleYieldBalances(any()) - stakingBalancesStore.storeActual(userWalletId = any(), values = any()) + stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) } val expected = IllegalStateException("No enabled yields for ${params.userWalletId}") @@ -284,14 +284,14 @@ internal class DefaultMultiStakingBalanceFetcherTest { // Assert coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) - stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) stakingYieldsStore.getSyncWithTimeout() - stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds) + stakeKitBalancesStore.storeError(userWalletId, tonAndSolanaIds) } coVerify(inverse = true) { stakeKitApi.getMultipleYieldBalances(any()) - stakingBalancesStore.storeActual(userWalletId = any(), values = any()) + stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) } val expected = IllegalStateException( @@ -329,13 +329,13 @@ internal class DefaultMultiStakingBalanceFetcherTest { // Assert coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) - stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) stakingYieldsStore.getSyncWithTimeout() stakeKitApi.getMultipleYieldBalances(requests) - stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = tonAndSolanaIds) + stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = tonAndSolanaIds) } - coVerify(inverse = true) { stakingBalancesStore.storeActual(userWalletId = any(), values = any()) } + coVerify(inverse = true) { stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) } val expected = ApiResponseError.NetworkException() diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducerTest.kt index e33aa4cd4f..4ac3d369fa 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducerTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducerTest.kt @@ -4,7 +4,7 @@ import com.google.common.truth.Truth import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory import com.tangem.common.test.data.staking.MockP2PEthPoolAccountResponseFactory import com.tangem.data.staking.store.P2PEthPoolBalancesStore -import com.tangem.data.staking.store.StakingBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.data.staking.toDomain import com.tangem.domain.models.StatusSource import com.tangem.domain.models.staking.* @@ -27,13 +27,13 @@ internal class DefaultMultiStakingBalanceProducerTest { private val params = MultiStakingBalanceProducer.Params(userWalletId = UserWalletId("011")) - private val stakingBalancesStore = mockk() + private val stakeKitBalancesStore = mockk() private val p2PEthPoolBalancesStore = mockk() private val dispatchers = TestingCoroutineDispatcherProvider() private val producer = DefaultMultiStakingBalanceProducer( params = params, - stakingBalancesStore = stakingBalancesStore, + stakeKitBalancesStore = stakeKitBalancesStore, p2PEthPoolBalancesStore = p2PEthPoolBalancesStore, dispatchers = dispatchers, ) @@ -47,13 +47,13 @@ internal class DefaultMultiStakingBalanceProducerTest { val networksStatusesFlow = flowOf(balances) - every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) val actual = producer.produce() // check after producer.produce() - verify { stakingBalancesStore.get(params.userWalletId) } + verify { stakeKitBalancesStore.get(params.userWalletId) } verify { p2PEthPoolBalancesStore.get(params.userWalletId) } val values = getEmittedValues(flow = actual) @@ -66,13 +66,13 @@ internal class DefaultMultiStakingBalanceProducerTest { fun `test that flow is updated if balances are updated`() = runTest { val networksStatusesFlow = MutableSharedFlow>(replay = 2) - every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) val actual = producer.produce() // check after producer.produce() - verify { stakingBalancesStore.get(params.userWalletId) } + verify { stakeKitBalancesStore.get(params.userWalletId) } verify { p2PEthPoolBalancesStore.get(params.userWalletId) } // first emit @@ -107,13 +107,13 @@ internal class DefaultMultiStakingBalanceProducerTest { fun `test that flow is filtered the same balance`() = runTest { val networksStatusesFlow = MutableSharedFlow>(replay = 2) - every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) val actual = producer.produce() // check after producer.produce() - verify { stakingBalancesStore.get(params.userWalletId) } + verify { stakeKitBalancesStore.get(params.userWalletId) } verify { p2PEthPoolBalancesStore.get(params.userWalletId) } // first emit @@ -156,13 +156,13 @@ internal class DefaultMultiStakingBalanceProducerTest { } .buffer(capacity = 5) - every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) val actual = producer.produceWithFallback() // check after producer.produce() - verify { stakingBalancesStore.get(params.userWalletId) } + verify { stakeKitBalancesStore.get(params.userWalletId) } verify { p2PEthPoolBalancesStore.get(params.userWalletId) } val values1 = getEmittedValues(flow = actual) @@ -180,13 +180,13 @@ internal class DefaultMultiStakingBalanceProducerTest { @Test fun `test that flow is empty`() = runTest { - every { stakingBalancesStore.get(params.userWalletId) } returns emptyFlow() + every { stakeKitBalancesStore.get(params.userWalletId) } returns emptyFlow() every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns emptyFlow() val actual = producer.produce() // check after producer.produce() - verify { stakingBalancesStore.get(params.userWalletId) } + verify { stakeKitBalancesStore.get(params.userWalletId) } verify { p2PEthPoolBalancesStore.get(params.userWalletId) } val values = getEmittedValues(flow = actual) @@ -200,13 +200,13 @@ internal class DefaultMultiStakingBalanceProducerTest { val stakeKitBalances = createStakeKitBalances() val p2pEthPoolBalances = createP2PEthPoolBalances() - every { stakingBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances) + every { stakeKitBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances) every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(p2pEthPoolBalances) val actual = producer.produce() // check after producer.produce() - verify { stakingBalancesStore.get(params.userWalletId) } + verify { stakeKitBalancesStore.get(params.userWalletId) } verify { p2PEthPoolBalancesStore.get(params.userWalletId) } val values = getEmittedValues(flow = actual) @@ -220,13 +220,13 @@ internal class DefaultMultiStakingBalanceProducerTest { val stakeKitBalances = createStakeKitBalancesWithTonOnly() val p2pEthPoolFlow = MutableSharedFlow>(replay = 2) - every { stakingBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances) + every { stakeKitBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances) every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns p2pEthPoolFlow val actual = producer.produce() // check after producer.produce() - verify { stakingBalancesStore.get(params.userWalletId) } + verify { stakeKitBalancesStore.get(params.userWalletId) } verify { p2PEthPoolBalancesStore.get(params.userWalletId) } // first emit - empty P2PEthPool diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreGetMethodTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreGetMethodTest.kt index 8ca3111517..b3716181f1 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreGetMethodTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreGetMethodTest.kt @@ -20,7 +20,7 @@ internal class StakingBalancesStoreGetMethodTest { private val runtimeStore = RuntimeSharedStore() private val persistenceStore = MockStateDataStore(default = emptyMap()) - private val store = DefaultStakingBalancesStore( + private val store = DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreInitializationTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreInitializationTest.kt index cf08a1f8e3..5ccbe2c608 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreInitializationTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreInitializationTest.kt @@ -27,7 +27,7 @@ internal class StakingBalancesStoreInitializationTest { every { persistenceStore.data } returns emptyFlow() - DefaultStakingBalancesStore( + DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), @@ -41,7 +41,7 @@ internal class StakingBalancesStoreInitializationTest { val runtimeStore = RuntimeSharedStore() val persistenceStore = MockStateDataStore(default = emptyMap()) - DefaultStakingBalancesStore( + DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), @@ -63,7 +63,7 @@ internal class StakingBalancesStoreInitializationTest { } } - DefaultStakingBalancesStore( + DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreUpdateMethodsTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreUpdateMethodsTest.kt index 18d1b25415..602f2b4dc8 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreUpdateMethodsTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreUpdateMethodsTest.kt @@ -23,7 +23,7 @@ internal class StakingBalancesStoreUpdateMethodsTest { private val runtimeStore = RuntimeSharedStore() private val persistenceStore = MockStateDataStore(default = emptyMap()) - private val store = DefaultStakingBalancesStore( + private val store = DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt index 823197d89f..81e8af5f3b 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt @@ -2,7 +2,8 @@ package com.tangem.data.staking.utils import arrow.core.right import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.data.staking.store.StakingBalancesStore +import com.tangem.data.staking.store.P2PEthPoolBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.StakingIdFactory @@ -10,7 +11,7 @@ import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.coEvery -import io.mockk.coVerifyOrder +import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -22,10 +23,12 @@ import org.junit.jupiter.api.TestInstance class DefaultStakingCleanerTest { private val stakingIdFactory = mockk(relaxed = true) - private val stakingBalancesStore = mockk(relaxed = true) + private val stakeKitBalancesStore = mockk(relaxed = true) + private val p2pEthPoolBalancesStore = mockk(relaxed = true) private val cleaner = DefaultStakingCleaner( stakingIdFactory = stakingIdFactory, - stakingBalancesStore = stakingBalancesStore, + stakeKitBalancesStore = stakeKitBalancesStore, + p2pEthPoolBalancesStore = p2pEthPoolBalancesStore, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -33,7 +36,7 @@ class DefaultStakingCleanerTest { @BeforeEach fun setUp() { - clearMocks(stakingIdFactory, stakingBalancesStore) + clearMocks(stakingIdFactory, stakeKitBalancesStore, p2pEthPoolBalancesStore) } @Nested @@ -50,8 +53,9 @@ class DefaultStakingCleanerTest { cleaner(userWalletId = userWalletId, stakingIds = stakingIds) // Assert - coVerifyOrder { - stakingBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + coVerify { + stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) } } @@ -61,22 +65,39 @@ class DefaultStakingCleanerTest { cleaner(userWalletId = userWalletId, stakingIds = emptySet()) // Assert - coVerifyOrder(inverse = true) { - stakingBalancesStore.clear(userWalletId = any(), stakingIds = any()) + coVerify(inverse = true) { + stakeKitBalancesStore.clear(userWalletId = any(), stakingIds = any()) + p2pEthPoolBalancesStore.clear(userWalletId = any(), stakingIds = any()) } } @Test fun `should catch exception from stakingBalancesStore and not throw`() = runTest { // Arrange - coEvery { stakingBalancesStore.clear(userWalletId, stakingIds) } throws Exception() + coEvery { stakeKitBalancesStore.clear(userWalletId, stakingIds) } throws Exception() // Act cleaner(userWalletId = userWalletId, stakingIds = stakingIds) // Assert - coVerifyOrder { - stakingBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + coVerify { + stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + } + } + + @Test + fun `should catch exception from p2pEthPoolBalancesStore and not throw`() = runTest { + // Arrange + coEvery { p2pEthPoolBalancesStore.clear(userWalletId, stakingIds) } throws Exception() + + // Act + cleaner(userWalletId = userWalletId, stakingIds = stakingIds) + + // Assert + coVerify { + stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) } } } @@ -102,9 +123,10 @@ class DefaultStakingCleanerTest { cleaner(userWalletId = userWalletId, currency = coin) // Assert - coVerifyOrder { + coVerify { stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = coin) - stakingBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) } } @@ -114,9 +136,10 @@ class DefaultStakingCleanerTest { cleaner(userWalletId = userWalletId, currencies = emptyList()) // Assert - coVerifyOrder(inverse = true) { + coVerify(inverse = true) { stakingIdFactory.create(userWalletId = any(), cryptoCurrency = any()) - stakingBalancesStore.clear(userWalletId = any(), stakingIds = any()) + stakeKitBalancesStore.clear(userWalletId = any(), stakingIds = any()) + p2pEthPoolBalancesStore.clear(userWalletId = any(), stakingIds = any()) } } @@ -130,16 +153,17 @@ class DefaultStakingCleanerTest { coEvery { stakingIdFactory.create(userWalletId, coin) } returns stakingId.right() coEvery { - stakingBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) } throws Exception() // Act cleaner(userWalletId = userWalletId, currency = coin) // Assert - coVerifyOrder { + coVerify { stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = coin) - stakingBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) } } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index cc46fde53c..f4d1fdfd3e 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -267,11 +267,12 @@ internal class StakingModel @Inject constructor( private val transactionsInProgress: CopyOnWriteArrayList = CopyOnWriteArrayList() - private var actionsJobHolder: JobHolder = JobHolder() - private var approvalJobHolder: JobHolder = JobHolder() - private var feeJobHolder: JobHolder = JobHolder() - private var sendTransactionJobHolder = JobHolder() - private var stepChangesJobHolder = JobHolder() + private val actionsJobHolder: JobHolder = JobHolder() + private val approvalJobHolder: JobHolder = JobHolder() + private val feeJobHolder: JobHolder = JobHolder() + private val sendTransactionJobHolder = JobHolder() + private val stepChangesJobHolder = JobHolder() + private val balanceHidingJobHolder = JobHolder() init { subscribeOnSelectedAppCurrency() @@ -286,6 +287,7 @@ internal class StakingModel @Inject constructor( feeJobHolder.cancel() sendTransactionJobHolder.cancel() stepChangesJobHolder.cancel() + balanceHidingJobHolder.cancel() } override fun onBackClick() { @@ -1284,6 +1286,7 @@ internal class StakingModel @Inject constructor( } .flowOn(dispatchers.main) .launchIn(modelScope) + .saveIn(balanceHidingJobHolder) } private fun subscribeOnSelectedAppCurrency() { From d808a3ddb2f39787fcd3356dc12af967c766093c Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 19 Jan 2026 16:52:33 +0300 Subject: [PATCH 143/172] Updated on 2026-08-14 --- .../send/v2/feeselector/model/FeeSelectorLogic.kt | 9 ++++++++- .../send/v2/feeselector/model/FeeSelectorModel.kt | 8 +------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt index 87fcceafc2..a6efb1b2dd 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt @@ -17,6 +17,7 @@ import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCase +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.NonceInserted @@ -55,11 +56,17 @@ internal class FeeSelectorLogic @AssistedInject constructor( private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val getAvailableFeeTokensUseCase: GetAvailableFeeTokensUseCase, + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) : FeeSelectorIntents { private var appCurrency: AppCurrency = AppCurrency.Default val uiState = MutableStateFlow(params.state) + val isGaslessEnabled = sendFeatureToggles.isGaslessTransactionsEnabled && + params.onLoadFeeExtended != null && + isGaslessFeeSupportedForNetwork(params.feeCryptoCurrencyStatus.currency.network) && + params.cryptoCurrencyStatus.currency is CryptoCurrency.Token + init { initAppCurrency() subscribeOnFeeReloadTriggerUpdates() @@ -216,7 +223,7 @@ internal class FeeSelectorLogic @AssistedInject constructor( @Suppress("UnreachableCode") private suspend fun callLoadFee(): Either { val extended = params.onLoadFeeExtended - if (extended == null || !sendFeatureToggles.isGaslessTransactionsEnabled) { + if (extended == null || !isGaslessEnabled) { return params.onLoadFee().map { LoadedFeeResult.Basic(it) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt index 96ee2a20ad..39db4c6f51 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt @@ -8,8 +8,6 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.utils.stack import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork -import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.send.v2.api.entity.FeeItem import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.params.FeeSelectorParams @@ -25,8 +23,6 @@ internal class FeeSelectorModel @Inject constructor( paramsContainer: ParamsContainer, feeSelectorLogicFactory: FeeSelectorLogic.Factory, override val dispatchers: CoroutineDispatcherProvider, - private val sendFeatureToggles: SendFeatureToggles, - private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) : Model(), FeeSelectorIntents { private val params = paramsContainer.require() @@ -90,9 +86,7 @@ internal class FeeSelectorModel @Inject constructor( fun getInitialRoute(): FeeSelectorRoute { return when { - sendFeatureToggles.isGaslessTransactionsEnabled && params.onLoadFeeExtended != null && - isGaslessFeeSupportedForNetwork(params.feeCryptoCurrencyStatus.currency.network) -> - FeeSelectorRoute.NetworkFee + feeSelectorLogic.isGaslessEnabled -> FeeSelectorRoute.NetworkFee else -> FeeSelectorRoute.ChooseSpeed } } From b775329fb3846325661791a36cc93cad1566ea14 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 20 Jan 2026 14:14:10 +0400 Subject: [PATCH 144/172] Updated on 2026-08-14 --- .../state/ExpressTransactionsBlockState.kt | 22 ++ .../tokendetails}/TokenDetailsDialogConfig.kt | 10 +- .../tangempay/details/impl/build.gradle.kts | 1 + ...faultTangemPayDetailsContainerComponent.kt | 5 +- .../components/TangemPayDetailsComponent.kt | 19 ++ .../EmptyExpressTransactionsComponent.kt | 34 ++ .../ExpressTransactionsComponentProvider.kt | 25 ++ ...reviewEmptyExpressTransactionsComponent.kt | 32 ++ .../tangempay/model/TangemPayDetailsModel.kt | 27 +- .../tangempay/ui/TangemPayDetailsScreen.kt | 23 +- features/tokendetails/api/build.gradle.kts | 8 + .../ExpressTransactionsComponent.kt | 27 ++ .../ExpressTransactionsEventListener.kt | 14 + .../tokendetails/di/TokenDetailsModule.kt | 26 +- .../DefaultExpressTransactionsComponent.kt | 42 +++ .../model/ExpressTransactionsModel.kt | 306 ++++++++++++++++++ .../model/TokenDetailsClickIntents.kt | 83 ++++- .../tokendetails/model/TokenDetailsModel.kt | 8 +- .../tokendetails/state/TokenDetailsState.kt | 2 +- ...nDetailsOnrampTransactionStateConverter.kt | 4 +- .../state/factory/TokenDetailsStateFactory.kt | 58 ++-- ...enDetailsSwapTransactionsStateConverter.kt | 4 +- .../factory/express/ExchangeStatusFactory.kt | 6 +- .../factory/express/ExpressStatusFactory.kt | 6 +- .../factory/express/OnrampStatusFactory.kt | 6 +- ...DefaultExpressTransactionsEventListener.kt | 24 ++ .../tokendetails/ui/TokenDetailsScreen.kt | 7 +- .../ui/components/TokenDetailsDialogs.kt | 12 +- 28 files changed, 768 insertions(+), 73 deletions(-) create mode 100644 common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionsBlockState.kt rename {features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components => common/ui/src/main/java/com/tangem/common/ui/tokendetails}/TokenDetailsDialogConfig.kt (95%) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/EmptyExpressTransactionsComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/ExpressTransactionsComponentProvider.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/PreviewEmptyExpressTransactionsComponent.kt create mode 100644 features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsComponent.kt create mode 100644 features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsEventListener.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/DefaultExpressTransactionsComponent.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/DefaultExpressTransactionsEventListener.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionsBlockState.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionsBlockState.kt new file mode 100644 index 0000000000..870d30bc07 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionsBlockState.kt @@ -0,0 +1,22 @@ +package com.tangem.common.ui.expressStatus.state + +import androidx.compose.runtime.Composable +import com.tangem.common.ui.tokendetails.TokenDetailsDialogConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import kotlinx.collections.immutable.PersistentList + +data class ExpressTransactionsBlockState( + val transactions: PersistentList, + val bottomSheetSlot: BottomSheetSlot?, + val dialogSlot: DialogSlot?, +) + +data class BottomSheetSlot( + val config: TangemBottomSheetConfig, + val content: @Composable () -> Unit, +) + +data class DialogSlot( + val config: TokenDetailsDialogConfig, + val content: @Composable () -> Unit, +) \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsDialogConfig.kt b/common/ui/src/main/java/com/tangem/common/ui/tokendetails/TokenDetailsDialogConfig.kt similarity index 95% rename from features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsDialogConfig.kt rename to common/ui/src/main/java/com/tangem/common/ui/tokendetails/TokenDetailsDialogConfig.kt index 7df750dad1..8681f259a6 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsDialogConfig.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokendetails/TokenDetailsDialogConfig.kt @@ -1,9 +1,9 @@ -package com.tangem.feature.tokendetails.presentation.tokendetails.state.components +package com.tangem.common.ui.tokendetails +import com.tangem.core.ui.R import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList -import com.tangem.features.tokendetails.impl.R /** * Wallet bottom sheet config @@ -12,7 +12,7 @@ import com.tangem.features.tokendetails.impl.R * @property onDismissRequest lambda be invoked when bottom sheet is dismissed * @property content content config */ -internal data class TokenDetailsDialogConfig( +data class TokenDetailsDialogConfig( val isShow: Boolean, val onDismissRequest: () -> Unit, val content: DialogContentConfig, @@ -28,7 +28,7 @@ internal data class TokenDetailsDialogConfig( data class ButtonConfig( val text: TextReference, val onClick: () -> Unit, - val warning: Boolean = false, + val hasWarning: Boolean = false, ) data class ConfirmHideConfig( @@ -51,7 +51,7 @@ internal data class TokenDetailsDialogConfig( override val confirmButtonConfig: ButtonConfig = ButtonConfig( text = TextReference.Res(R.string.token_details_hide_alert_hide), onClick = onConfirmClick, - warning = true, + hasWarning = true, ) } diff --git a/features/tangempay/details/impl/build.gradle.kts b/features/tangempay/details/impl/build.gradle.kts index 52a85dad6b..cc18ae7c58 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(projects.features.tangempay.details.api) implementation(projects.features.tokenRecieve.api) implementation(projects.features.txhistory.api) + implementation(projects.features.tokendetails.api) /** Domain */ implementation(projects.domain.balanceHiding) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index 126f7124d8..baa27a447b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -15,16 +15,18 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.components.express.ExpressTransactionsComponentProvider import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -class DefaultTangemPayDetailsContainerComponent @AssistedInject constructor( +internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, @Assisted private val params: TangemPayDetailsContainerComponent.Params, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, + private val expressTransactionsComponentProvider: ExpressTransactionsComponentProvider, ) : AppComponentContext by appComponentContext, TangemPayDetailsContainerComponent { private val stackNavigation = StackNavigation() @@ -60,6 +62,7 @@ class DefaultTangemPayDetailsContainerComponent @AssistedInject constructor( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), params = params, tokenReceiveComponentFactory = tokenReceiveComponentFactory, + expressTransactionsComponentProvider = expressTransactionsComponentProvider, ) TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index fb099938ee..a4e44f025e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -8,6 +8,7 @@ import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss +import com.arkivanov.essenty.lifecycle.subscribe import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext @@ -17,6 +18,7 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.express.ExpressTransactionsComponentProvider import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation @@ -28,6 +30,7 @@ internal class TangemPayDetailsComponent( private val appComponentContext: AppComponentContext, private val params: TangemPayDetailsContainerComponent.Params, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, + private val expressTransactionsComponentProvider: ExpressTransactionsComponentProvider, ) : AppComponentContext by appComponentContext, ComposableContentComponent { private val model: TangemPayDetailsModel = getOrCreateModel(params = params) @@ -52,6 +55,21 @@ internal class TangemPayDetailsComponent( params = TangemPayCardDetailsBlockComponent.Params(params = params), ) + private val expressTransactionsComponent by lazy { + expressTransactionsComponentProvider.create( + appComponentContext = child("expressTransactionsComponent"), + userWalletId = params.userWalletId, + cryptoCurrency = model.cryptoCurrency, + ) + } + + init { + lifecycle.subscribe( + onPause = model::onPause, + onResume = model::onResume, + ) + } + @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() @@ -62,6 +80,7 @@ internal class TangemPayDetailsComponent( state = state, txHistoryComponent = txHistoryComponent, cardDetailsBlockComponent = cardDetailsBlockComponent, + expressTransactionsComponent = expressTransactionsComponent, modifier = modifier, ) bottomSheet.child?.instance?.BottomSheet() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/EmptyExpressTransactionsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/EmptyExpressTransactionsComponent.kt new file mode 100644 index 0000000000..a06c8a9f4f --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/EmptyExpressTransactionsComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.features.tangempay.components.express + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM +import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.features.tokendetails.ExpressTransactionsComponent +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +@Stable +internal class EmptyExpressTransactionsComponent( + context: AppComponentContext, +) : AppComponentContext by context, ExpressTransactionsComponent { + + override val state: StateFlow = MutableStateFlow(getInitialState()) + + override fun LazyListScope.expressTransactionsContent( + state: PersistentList, + modifier: Modifier, + ) {} + + private fun getInitialState(): ExpressTransactionsBlockState { + return ExpressTransactionsBlockState( + transactions = persistentListOf(), + bottomSheetSlot = null, + dialogSlot = null, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/ExpressTransactionsComponentProvider.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/ExpressTransactionsComponentProvider.kt new file mode 100644 index 0000000000..bcf6a91874 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/ExpressTransactionsComponentProvider.kt @@ -0,0 +1,25 @@ +package com.tangem.features.tangempay.components.express + +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.tokendetails.ExpressTransactionsComponent +import javax.inject.Inject + +internal class ExpressTransactionsComponentProvider @Inject constructor( + private val expressTransactionsComponentFactory: ExpressTransactionsComponent.Factory, +) { + + fun create( + appComponentContext: AppComponentContext, + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency?, + ): ExpressTransactionsComponent = if (cryptoCurrency != null) { + expressTransactionsComponentFactory.create( + context = appComponentContext, + params = ExpressTransactionsComponent.Params(userWalletId = userWalletId, currency = cryptoCurrency), + ) + } else { + EmptyExpressTransactionsComponent(context = appComponentContext) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/PreviewEmptyExpressTransactionsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/PreviewEmptyExpressTransactionsComponent.kt new file mode 100644 index 0000000000..23ead18dcf --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/PreviewEmptyExpressTransactionsComponent.kt @@ -0,0 +1,32 @@ +package com.tangem.features.tangempay.components.express + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.ui.Modifier +import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM +import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState +import com.tangem.features.tokendetails.ExpressTransactionsComponent +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** Cannot really preview anything here since the UM implementation [ExchangeUM] is in token:details module + * For the actual preview @see [TokenDetailsScreen] + **/ +internal class PreviewEmptyExpressTransactionsComponent : ExpressTransactionsComponent { + + override val state: StateFlow = MutableStateFlow(getInitialState()) + + override fun LazyListScope.expressTransactionsContent( + state: PersistentList, + modifier: Modifier, + ) {} + + private fun getInitialState(): ExpressTransactionsBlockState { + return ExpressTransactionsBlockState( + transactions = persistentListOf(), + bottomSheetSlot = null, + dialogSlot = null, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 2c2b69be13..bbdffbda81 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -21,6 +21,8 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayTopUpData @@ -47,6 +49,8 @@ import com.tangem.features.tangempay.utils.TangemPayDetailIntents import com.tangem.features.tangempay.utils.TangemPayMessagesFactory import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions import com.tangem.features.tangempay.utils.TangemPayTxHistoryUpdateListener +import com.tangem.features.tokendetails.ExpressTransactionsEvent +import com.tangem.features.tokendetails.ExpressTransactionsEventListener import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -78,6 +82,7 @@ internal class TangemPayDetailsModel @Inject constructor( private val orderRepository: CustomerOrderRepository, private val getUserWalletUseCase: GetUserWalletUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val expressTransactionsEventListener: ExpressTransactionsEventListener, ) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener, ViewPinListener { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() @@ -99,6 +104,11 @@ internal class TangemPayDetailsModel @Inject constructor( private var balance: TangemPayCardBalance? = null + private val userWallet: UserWallet? = getUserWalletUseCase(params.userWalletId).getOrNull() + val cryptoCurrency: CryptoCurrency? = userWallet?.let { wallet -> + tangemPayCryptoCurrencyFactory.create(userWallet = wallet, chainId = params.config.chainId).getOrNull() + } + val bottomSheetNavigation: SlotNavigation = SlotNavigation() init { @@ -109,6 +119,18 @@ internal class TangemPayDetailsModel @Inject constructor( subscribeToCardFrozenState() } + fun onResume() { + modelScope.launch { + expressTransactionsEventListener.send(ExpressTransactionsEvent.Update) + } + } + + fun onPause() { + modelScope.launch { + expressTransactionsEventListener.send(ExpressTransactionsEvent.Clear) + } + } + private fun subscribeToCardFrozenState() { cardDetailsRepository .cardFrozenState(params.config.cardId) @@ -252,8 +274,8 @@ internal class TangemPayDetailsModel @Inject constructor( if (hasActiveWithdrawal) { showBottomSheetError(TangemPayDetailsErrorType.WithdrawInProgress) } else { - val userWallet = getUserWalletUseCase(params.userWalletId).getOrNull() - val currency = userWallet?.let { + val userWallet = userWallet ?: getUserWalletUseCase(params.userWalletId).getOrNull() + val currency = cryptoCurrency ?: userWallet?.let { tangemPayCryptoCurrencyFactory.create(userWallet = userWallet, chainId = params.config.chainId) .getOrNull() } @@ -336,6 +358,7 @@ internal class TangemPayDetailsModel @Inject constructor( modelScope.launch { uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = refreshState.value)) cardDetailsEventListener.send(CardDetailsEvent.Hide) + expressTransactionsEventListener.send(ExpressTransactionsEvent.Update) txHistoryUpdateListener.triggerUpdate() fetchBalance().join() uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = false)) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 255f5c37a5..91bd0656aa 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -34,17 +34,22 @@ import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.components.text.applyBladeBrush -import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TokenDetailsTopBarTestTags import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.express.PreviewEmptyExpressTransactionsComponent import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.* +import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.persistentListOf @@ -54,6 +59,7 @@ internal fun TangemPayDetailsScreen( state: TangemPayDetailsUM, txHistoryComponent: TangemPayTxHistoryComponent, cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, + expressTransactionsComponent: ExpressTransactionsComponent, modifier: Modifier = Modifier, ) { Scaffold( @@ -66,6 +72,9 @@ internal fun TangemPayDetailsScreen( val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } val txHistoryState by txHistoryComponent.state.collectAsStateWithLifecycle() val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() + val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle() + val expressTransactionsBottomSheetState = expressState.bottomSheetSlot + val expressTransactionsDialogState = expressState.dialogSlot TangemPullToRefreshContainer( config = state.pullToRefreshConfig, @@ -138,9 +147,19 @@ internal fun TangemPayDetailsScreen( ) }, ) + with(expressTransactionsComponent) { + expressTransactionsContent( + state = expressState.transactions, + modifier = modifier + .padding(start = 16.dp, end = 16.dp, top = 12.dp) + .fillMaxWidth(), + ) + } with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) } } } + expressTransactionsDialogState?.content() + expressTransactionsBottomSheetState?.content() } } @@ -292,6 +311,7 @@ private fun TangemPayDetailsScreenPreview( cardFrozenState = TangemPayCardFrozenState.Unfrozen, ), ), + expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), ) } } @@ -370,6 +390,7 @@ private fun TangemPayDetailsTxHistoryScreenPreview( cardFrozenState = TangemPayCardFrozenState.Unfrozen, ), ), + expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), ) } } diff --git a/features/tokendetails/api/build.gradle.kts b/features/tokendetails/api/build.gradle.kts index 0551d08f54..72eb80b695 100644 --- a/features/tokendetails/api/build.gradle.kts +++ b/features/tokendetails/api/build.gradle.kts @@ -14,6 +14,9 @@ dependencies { implementation(projects.core.decompose) implementation(projects.core.ui) + /** Common */ + implementation(projects.common.ui) + /** Domain models */ api(projects.domain.models) implementation(projects.domain.tokens.models) @@ -21,4 +24,9 @@ dependencies { /** Compose */ implementation(deps.compose.runtime) + implementation(deps.compose.ui) + implementation(deps.compose.foundation) + + /** Other */ + implementation(deps.kotlin.immutable.collections) } \ No newline at end of file diff --git a/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsComponent.kt b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsComponent.kt new file mode 100644 index 0000000000..0ed67d34a6 --- /dev/null +++ b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsComponent.kt @@ -0,0 +1,27 @@ +package com.tangem.features.tokendetails + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM +import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.flow.StateFlow + +@Stable +interface ExpressTransactionsComponent { + + val state: StateFlow + + fun LazyListScope.expressTransactionsContent(state: PersistentList, modifier: Modifier) + + data class Params( + val userWalletId: UserWalletId, + val currency: CryptoCurrency, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsEventListener.kt b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsEventListener.kt new file mode 100644 index 0000000000..bbe0d91105 --- /dev/null +++ b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsEventListener.kt @@ -0,0 +1,14 @@ +package com.tangem.features.tokendetails + +import kotlinx.coroutines.flow.Flow + +interface ExpressTransactionsEventListener { + + val event: Flow + + suspend fun send(event: ExpressTransactionsEvent) +} + +enum class ExpressTransactionsEvent { + Update, Clear +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsModule.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsModule.kt index 356a4c6fe4..18e5a4f513 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsModule.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsModule.kt @@ -4,9 +4,14 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.feature.tokendetails.DefaultTokenDetailsComponent +import com.tangem.feature.tokendetails.presentation.DefaultExpressTransactionsComponent import com.tangem.feature.tokendetails.presentation.router.DefaultTokenDetailsRouter import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsModel import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsModel +import com.tangem.feature.tokendetails.presentation.tokendetails.state.utils.DefaultExpressTransactionsEventListener +import com.tangem.features.tokendetails.ExpressTransactionsComponent +import com.tangem.features.tokendetails.ExpressTransactionsEventListener import com.tangem.features.tokendetails.TokenDetailsComponent import dagger.Binds import dagger.Module @@ -14,18 +19,35 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.ClassKey import dagger.multibindings.IntoMap +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) internal interface TokenDetailsModule { @Binds - fun bindComponentFactory(factory: DefaultTokenDetailsComponent.Factory): TokenDetailsComponent.Factory + fun bindTokenDetailsComponentFactory(factory: DefaultTokenDetailsComponent.Factory): TokenDetailsComponent.Factory + + @Binds + fun bindExpressTransactionsComponent( + factory: DefaultExpressTransactionsComponent.Factory, + ): ExpressTransactionsComponent.Factory @Binds @IntoMap @ClassKey(TokenDetailsModel::class) - fun bindModel(model: TokenDetailsModel): Model + fun bindTokenDetailsModel(model: TokenDetailsModel): Model + + @Binds + @IntoMap + @ClassKey(ExpressTransactionsModel::class) + fun bindExpressTransactionsModel(model: ExpressTransactionsModel): Model + + @Binds + @Singleton + fun bindExpressTransactionsEventListener( + impl: DefaultExpressTransactionsEventListener, + ): ExpressTransactionsEventListener } @Module diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/DefaultExpressTransactionsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/DefaultExpressTransactionsComponent.kt new file mode 100644 index 0000000000..02b83cfe13 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/DefaultExpressTransactionsComponent.kt @@ -0,0 +1,42 @@ +package com.tangem.feature.tokendetails.presentation + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import com.tangem.common.ui.expressStatus.expressTransactionsItems +import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM +import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsModel +import com.tangem.features.tokendetails.ExpressTransactionsComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.flow.StateFlow + +@Stable +internal class DefaultExpressTransactionsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: ExpressTransactionsComponent.Params, +) : AppComponentContext by context, ExpressTransactionsComponent { + + private val model: ExpressTransactionsModel = getOrCreateModel(params = params) + override val state: StateFlow = model.uiState + + override fun LazyListScope.expressTransactionsContent( + state: PersistentList, + modifier: Modifier, + ) { + expressTransactionsItems(expressTxs = state, modifier = modifier) + } + + @AssistedFactory + interface Factory : ExpressTransactionsComponent.Factory { + override fun create( + context: AppComponentContext, + params: ExpressTransactionsComponent.Params, + ): DefaultExpressTransactionsComponent + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt new file mode 100644 index 0000000000..6d5210c3cf --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt @@ -0,0 +1,306 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import arrow.core.right +import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig +import com.tangem.common.ui.expressStatus.state.BottomSheetSlot +import com.tangem.common.ui.expressStatus.state.DialogSlot +import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM +import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState +import com.tangem.common.ui.tokendetails.TokenDetailsDialogConfig +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.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase +import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory +import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express.ExpressStatusFactory +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsDialogs +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express.ExpressStatusBottomSheet +import com.tangem.features.tokendetails.ExpressTransactionsComponent +import com.tangem.features.tokendetails.ExpressTransactionsEvent +import com.tangem.features.tokendetails.ExpressTransactionsEventListener +import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.* +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +@Suppress("LongParameterList", "PropertyUsedBeforeDeclaration") +@Stable +@ModelScoped +internal class ExpressTransactionsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, + expressStatusFactory: ExpressStatusFactory.Factory, + getUserWalletUseCase: GetUserWalletUseCase, + private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val networkHasDerivationUseCase: NetworkHasDerivationUseCase, + private val router: InnerTokenDetailsRouter, + private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val getAccountCryptoCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + private val expressTransactionsEventListener: ExpressTransactionsEventListener, +) : Model(), ExpressTransactionsClickIntents { + + private val params = paramsContainer.require() + private val userWalletId: UserWalletId = params.userWalletId + private val cryptoCurrency: CryptoCurrency = params.currency + + private val userWallet: UserWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: error("UserWallet not found") + + private val marketPriceJobHolder = JobHolder() + private val expressTxJobHolder = JobHolder() + private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() + + private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null + private var account: Account.CryptoPortfolio? = null + private val expressTxStatusTaskScheduler = SingleTaskScheduler>() + + private val waitForFirstExpressStatusEmmit = MutableStateFlow(false) + + private val currentStateProvider: Provider = Provider { internalUiState.value } + + private val stateFactory by lazy(mode = LazyThreadSafetyMode.NONE) { + TokenDetailsStateFactory( + appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), + currentStateProvider = currentStateProvider, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + tokenDetailsClickIntents = EmptyTokenDetailsClickIntents(), + expressTransactionsClickIntents = this, + networkHasDerivationUseCase = networkHasDerivationUseCase, + getUserWalletUseCase = getUserWalletUseCase, + userWalletId = userWalletId, + yieldSupplyFeatureToggles = yieldSupplyFeatureToggles, + ) + } + + private val expressStatusFactory by lazy(mode = LazyThreadSafetyMode.NONE) { + expressStatusFactory.create( + clickIntents = this, + appCurrencyProvider = Provider { selectedAppCurrencyFlow.value }, + currentStateProvider = currentStateProvider, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + userWallet = userWallet, + cryptoCurrency = cryptoCurrency, + ) + } + + private val internalUiState = MutableStateFlow(stateFactory.getInitialState(cryptoCurrency)) + + val uiState: StateFlow = internalUiState + .map(::mapInnerState) + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = mapInnerState(internalUiState.value), + ) + + init { + subscribeOnExternalEvents() + subscribeOnCurrencyStatusUpdates() + subscribeOnExpressTransactionsUpdates() + } + + override fun onExpressTransactionClick(txId: String) { + val expressTxState = internalUiState.value.expressTxsToDisplay.firstOrNull { it.info.txId == txId } + ?: return + internalUiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState) + } + + override fun onGoToProviderClick(url: String) { + router.openUrl(url) + } + + override fun onGoToRefundedTokenClick(cryptoCurrency: CryptoCurrency) { + router.openTokenDetails(userWalletId, cryptoCurrency) + } + + override fun onOpenUrlClick(url: String) { + router.openUrl(url) + } + + override fun onConfirmDisposeExpressStatus() { + internalUiState.value = stateFactory.getStateWithConfirmHideExpressStatus() + } + + override fun onDisposeExpressStatus() { + val bottomSheetState = internalUiState.value.bottomSheetConfig?.content + if (bottomSheetState is ExpressStatusBottomSheetConfig) { + modelScope.launch { + expressStatusFactory.removeTransactionOnBottomSheetClosed( + expressState = bottomSheetState.value, + isForceDispose = true, + ) + } + } + internalUiState.value = stateFactory.getStateWithClosedBottomSheet() + } + + override fun onDismissBottomSheet() { + when (val bsContent = internalUiState.value.bottomSheetConfig?.content) { + is ExpressStatusBottomSheetConfig -> { + modelScope.launch(dispatchers.main) { + expressStatusFactory.removeTransactionOnBottomSheetClosed(bsContent.value) + } + } + } + internalUiState.value = stateFactory.getStateWithClosedBottomSheet() + } + + override fun onDismissDialog() { + internalUiState.value = stateFactory.getStateWithClosedDialog() + } + + override fun onDestroy() { + clear() + super.onDestroy() + } + + private fun mapInnerState(innerState: TokenDetailsState): ExpressTransactionsBlockState { + val bsContent = innerState.bottomSheetConfig?.content + return ExpressTransactionsBlockState( + transactions = innerState.expressTxsToDisplay, + bottomSheetSlot = if (bsContent != null && bsContent is ExpressStatusBottomSheetConfig) { + innerState.bottomSheetConfig.toBottomSheetSlot() + } else { + null + }, + dialogSlot = innerState.dialogConfig?.toDialogSlot(), + ) + } + + private fun TangemBottomSheetConfig.toBottomSheetSlot(): BottomSheetSlot { + val contentLambda: @Composable () -> Unit = { + when (this.content) { + is ExpressStatusBottomSheetConfig -> ExpressStatusBottomSheet(config = this) + } + } + return BottomSheetSlot(config = this, content = contentLambda) + } + + private fun TokenDetailsDialogConfig.toDialogSlot(): DialogSlot { + val contentLambda: @Composable () -> Unit = { + TokenDetailsDialogs(this) + } + return DialogSlot(config = this, content = contentLambda) + } + + private fun subscribeOnExternalEvents() { + modelScope.launch { + expressTransactionsEventListener.event.collect { event -> + when (event) { + ExpressTransactionsEvent.Update -> subscribeOnExpressTransactionsUpdates() + ExpressTransactionsEvent.Clear -> clear() + } + } + } + } + + private fun subscribeOnCurrencyStatusUpdates() { + if (accountsFeatureToggles.isFeatureEnabled) { + getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency) + .onEach { account = it.account } + .map { it.status.right() } + } else { + getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( + userWalletId = userWalletId, + currencyId = cryptoCurrency.id, + isSingleWalletWithTokens = userWallet is UserWallet.Cold && + userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken(), + ) + } + .distinctUntilChanged() + .onEach { maybeCurrencyStatus -> + internalUiState.value = stateFactory.getCurrencyLoadedBalanceState(maybeCurrencyStatus) + maybeCurrencyStatus.onRight { status -> + cryptoCurrencyStatus = status + } + } + .flowOn(dispatchers.main) + .launchIn(modelScope) + .saveIn(marketPriceJobHolder) + } + + private fun subscribeOnExpressTransactionsUpdates() { + expressTxStatusTaskScheduler.cancelTask() + expressStatusFactory.getExpressStatuses() + .distinctUntilChanged() + .onEach { waitForFirstExpressStatusEmmit.value = true } + .onEach { expressTxs -> + internalUiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs( + expressTxs = expressTxs, + updateBalance = { /* no-op */ }, + ) + expressTxStatusTaskScheduler.scheduleTask( + scope = modelScope, + task = PeriodicTask( + isDelayFirst = false, + delay = EXPRESS_STATUS_UPDATE_DELAY, + task = { + try { + Result.success( + expressStatusFactory.getUpdatedExpressStatuses(internalUiState.value.expressTxs), + ) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Result.failure(exception) + } + }, + onSuccess = { updatedTxs -> + internalUiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs( + expressTxs = updatedTxs, + updateBalance = { /* no-op */ }, + ) + }, + onError = { /* no-op */ }, + ), + ) + } + .flowOn(dispatchers.main) + .launchIn(modelScope) + .saveIn(expressTxJobHolder) + } + + private fun createSelectedAppCurrencyFlow(): StateFlow { + return getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + } + + private fun clear() { + expressTxStatusTaskScheduler.cancelTask() + expressTxJobHolder.cancel() + } + + private companion object { + const val EXPRESS_STATUS_UPDATE_DELAY = 10_000L + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index 66d495e481..bf41cabfe1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt @@ -24,8 +24,6 @@ interface TokenDetailsClickIntents { fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason) - fun onDismissDialog() - fun onHideClick() fun onHideConfirmed() @@ -42,14 +40,8 @@ interface TokenDetailsClickIntents { fun onAddressTypeSelected(addressModel: AddressModel) - fun onDismissBottomSheet() - fun onCloseRentInfoNotification() - fun onExpressTransactionClick(txId: String) - - fun onGoToProviderClick(url: String) - fun onSwapPromoDismiss(promoId: PromoId) fun onSwapPromoClick(promoId: PromoId) @@ -72,6 +64,15 @@ interface TokenDetailsClickIntents { fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig) + fun onYieldInfoClick() +} + +interface ExpressTransactionsClickIntents { + + fun onExpressTransactionClick(txId: String) + + fun onGoToProviderClick(url: String) + fun onGoToRefundedTokenClick(cryptoCurrency: CryptoCurrency) fun onOpenUrlClick(url: String) @@ -80,5 +81,69 @@ interface TokenDetailsClickIntents { fun onDisposeExpressStatus() - fun onYieldInfoClick() + fun onDismissBottomSheet() + + fun onDismissDialog() +} + +@Suppress("TooManyFunctions") +internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents { + override fun onRefreshSwipe(isRefreshing: Boolean) { /* no op */ } + + override fun onBackClick() { /* no op */ } + + override fun onBuyClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } + + override fun onBuyCoinClick(cryptoCurrency: CryptoCurrency) { /* no op */ } + + override fun onStakeBannerClick() { /* no op */ } + + override fun onReloadClick() { /* no op */ } + + override fun onSendClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } + + override fun onReceiveClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } + + override fun onStakeClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } + + override fun onGenerateExtendedKey() { /* no op */ } + + override fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } + + override fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } + + override fun onHideClick() { /* no op */ } + + override fun onHideConfirmed() { /* no op */ } + + override fun onExploreClick() { /* no op */ } + + override fun onAddressTypeSelected(addressModel: AddressModel) { /* no op */ } + + override fun onTransactionClick(txHash: String) { /* no op */ } + + override fun onCloseRentInfoNotification() { /* no op */ } + + override fun onSwapPromoDismiss(promoId: PromoId) { /* no op */ } + + override fun onSwapPromoClick(promoId: PromoId) { /* no op */ } + + override fun onRetryIncompleteTransactionClick() { /* no op */ } + + override fun onOpenTrustlineClick() { /* no op */ } + + override fun onDismissIncompleteTransactionClick() { /* no op */ } + + override fun onConfirmDismissIncompleteTransactionClick() { /* no op */ } + + override fun onAssociateClick() { /* no op */ } + + override fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig) { /* no op */ } + + override fun onYieldInfoClick() { /* no op */ } + + override fun onCopyAddress(): TextReference? { + /* no op */ + return null + } } \ 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 b660c09fdd..ba5ee42ad8 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 @@ -153,7 +153,10 @@ internal class TokenDetailsModel @Inject constructor( private val getAccountCryptoCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val yieldSupplyGetRewardsBalanceUseCase: YieldSupplyGetRewardsBalanceUseCase, -) : Model(), TokenDetailsClickIntents, YieldSupplyDepositedWarningComponent.ModelCallback { +) : Model(), + TokenDetailsClickIntents, + ExpressTransactionsClickIntents, + YieldSupplyDepositedWarningComponent.ModelCallback { private val params = paramsContainer.require() private val userWalletId: UserWalletId = params.userWalletId @@ -184,7 +187,8 @@ internal class TokenDetailsModel @Inject constructor( currentStateProvider = Provider { uiState.value }, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, - clickIntents = this, + tokenDetailsClickIntents = this, + expressTransactionsClickIntents = this, networkHasDerivationUseCase = networkHasDerivationUseCase, getUserWalletUseCase = getUserWalletUseCase, userWalletId = userWalletId, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt index 40805e48b9..ed8cf22b9f 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt @@ -1,10 +1,10 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM +import com.tangem.common.ui.tokendetails.TokenDetailsDialogConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.marketprice.MarketPriceBlockState -import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.PersistentList diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt index 742e94dab2..52acac7843 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt @@ -20,14 +20,14 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.onramp.model.OnrampStatus import com.tangem.domain.onramp.model.cache.OnrampTransaction import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsClickIntents import com.tangem.features.tokendetails.impl.R import com.tangem.utils.Provider import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.persistentListOf internal class TokenDetailsOnrampTransactionStateConverter( - private val clickIntents: TokenDetailsClickIntents, + private val clickIntents: ExpressTransactionsClickIntents, private val cryptoCurrency: CryptoCurrency, private val cryptoCurrencyStatusProvider: Provider, private val appCurrencyProvider: Provider, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt index da13ce39ba..87552d058e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt @@ -3,6 +3,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory import arrow.core.Either import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetConfig import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig +import com.tangem.common.ui.tokendetails.TokenDetailsDialogConfig import com.tangem.common.ui.tokens.getUnavailabilityReasonText import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem @@ -27,12 +28,12 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase import com.tangem.domain.yield.supply.models.YieldSupplyRewardBalance +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsAppBarMenuConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState -import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig import com.tangem.features.tokendetails.impl.R import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.utils.Provider @@ -43,7 +44,8 @@ internal class TokenDetailsStateFactory( private val currentStateProvider: Provider, private val appCurrencyProvider: Provider, private val cryptoCurrencyStatusProvider: Provider, - private val clickIntents: TokenDetailsClickIntents, + private val tokenDetailsClickIntents: TokenDetailsClickIntents, + private val expressTransactionsClickIntents: ExpressTransactionsClickIntents, private val networkHasDerivationUseCase: NetworkHasDerivationUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val userWalletId: UserWalletId, @@ -52,7 +54,7 @@ internal class TokenDetailsStateFactory( private val skeletonStateConverter by lazy { TokenDetailsSkeletonStateConverter( - clickIntents = clickIntents, + clickIntents = tokenDetailsClickIntents, networkHasDerivationUseCase = networkHasDerivationUseCase, getUserWalletUseCase = getUserWalletUseCase, userWalletId = userWalletId, @@ -64,7 +66,7 @@ internal class TokenDetailsStateFactory( TokenDetailsNotificationConverter( userWalletId = userWalletId, getUserWalletUseCase = getUserWalletUseCase, - clickIntents = clickIntents, + clickIntents = tokenDetailsClickIntents, ) } @@ -72,7 +74,7 @@ internal class TokenDetailsStateFactory( TokenDetailsLoadedBalanceConverter( currentStateProvider = currentStateProvider, appCurrencyProvider = appCurrencyProvider, - clickIntents = clickIntents, + clickIntents = tokenDetailsClickIntents, yieldSupplyFeatureToggles = yieldSupplyFeatureToggles, ) } @@ -80,7 +82,7 @@ internal class TokenDetailsStateFactory( private val tokenDetailsButtonsConverter by lazy { TokenDetailsActionButtonsConverter( currentStateProvider = currentStateProvider, - clickIntents = clickIntents, + clickIntents = tokenDetailsClickIntents, ) } @@ -117,7 +119,7 @@ internal class TokenDetailsStateFactory( return TokenDetailsStakingInfoConverter( currentState = state, cryptoCurrencyStatus = cryptoCurrencyStatus, - clickIntents = clickIntents, + clickIntents = tokenDetailsClickIntents, appCurrencyProvider = appCurrencyProvider, stakingEntryInfo = stakingEntryInfo, ).convert(stakingAvailability) @@ -136,11 +138,11 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( dialogConfig = TokenDetailsDialogConfig( isShow = true, - onDismissRequest = clickIntents::onDismissDialog, + onDismissRequest = expressTransactionsClickIntents::onDismissDialog, content = TokenDetailsDialogConfig.DialogContentConfig.ConfirmHideConfig( currencyTitle = currency.name, - onConfirmClick = clickIntents::onHideConfirmed, - onCancelClick = clickIntents::onDismissDialog, + onConfirmClick = tokenDetailsClickIntents::onHideConfirmed, + onCancelClick = expressTransactionsClickIntents::onDismissDialog, ), ), ) @@ -150,12 +152,12 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( dialogConfig = TokenDetailsDialogConfig( isShow = true, - onDismissRequest = clickIntents::onDismissDialog, + onDismissRequest = expressTransactionsClickIntents::onDismissDialog, content = TokenDetailsDialogConfig.DialogContentConfig.HasLinkedTokensConfig( currencyName = currency.name, currencySymbol = currency.symbol, networkName = currency.network.name, - onConfirmClick = clickIntents::onDismissDialog, + onConfirmClick = expressTransactionsClickIntents::onDismissDialog, ), ), ) @@ -165,10 +167,10 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( dialogConfig = TokenDetailsDialogConfig( isShow = true, - onDismissRequest = clickIntents::onDismissDialog, + onDismissRequest = expressTransactionsClickIntents::onDismissDialog, content = TokenDetailsDialogConfig.DialogContentConfig.RemoveIncompleteTransactionConfirmDialogConfig( - onConfirmClick = clickIntents::onConfirmDismissIncompleteTransactionClick, - onCancelClick = clickIntents::onDismissDialog, + onConfirmClick = tokenDetailsClickIntents::onConfirmDismissIncompleteTransactionClick, + onCancelClick = expressTransactionsClickIntents::onDismissDialog, ), ), ) @@ -178,10 +180,10 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( dialogConfig = TokenDetailsDialogConfig( isShow = true, - onDismissRequest = clickIntents::onDismissDialog, + onDismissRequest = expressTransactionsClickIntents::onDismissDialog, content = TokenDetailsDialogConfig.DialogContentConfig.DisabledButtonReasonDialogConfig( text = unavailabilityReason.getUnavailabilityReasonText(), - onConfirmClick = clickIntents::onDismissDialog, + onConfirmClick = expressTransactionsClickIntents::onDismissDialog, ), ), ) @@ -191,10 +193,10 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( dialogConfig = TokenDetailsDialogConfig( isShow = true, - onDismissRequest = clickIntents::onDismissDialog, + onDismissRequest = expressTransactionsClickIntents::onDismissDialog, content = TokenDetailsDialogConfig.DialogContentConfig.ErrorDialogConfig( text = text, - onConfirmClick = clickIntents::onDismissDialog, + onConfirmClick = expressTransactionsClickIntents::onDismissDialog, ), ), ) @@ -217,7 +219,7 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( bottomSheetConfig = TangemBottomSheetConfig( isShown = true, - onDismissRequest = clickIntents::onDismissBottomSheet, + onDismissRequest = expressTransactionsClickIntents::onDismissBottomSheet, content = TokenReceiveBottomSheetConfig( asset = TokenReceiveBottomSheetConfig.Asset.Currency( name = currency.name, @@ -240,7 +242,7 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( bottomSheetConfig = TangemBottomSheetConfig( isShown = true, - onDismissRequest = clickIntents::onDismissBottomSheet, + onDismissRequest = expressTransactionsClickIntents::onDismissBottomSheet, content = ChooseAddressBottomSheetConfig( asset = TokenReceiveBottomSheetConfig.Asset.Currency( name = currency.name, @@ -248,7 +250,7 @@ internal class TokenDetailsStateFactory( ), network = currency.network, networkAddress = networkAddress, - onClick = clickIntents::onAddressTypeSelected, + onClick = tokenDetailsClickIntents::onAddressTypeSelected, ), ), ) @@ -338,13 +340,13 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( dialogConfig = TokenDetailsDialogConfig( isShow = true, - onDismissRequest = clickIntents::onDismissDialog, + onDismissRequest = expressTransactionsClickIntents::onDismissDialog, content = TokenDetailsDialogConfig.DialogContentConfig.ConfirmExpressStatusHideDialogConfig( onConfirmClick = { - clickIntents.onDisposeExpressStatus() - clickIntents.onDismissDialog() + expressTransactionsClickIntents.onDisposeExpressStatus() + expressTransactionsClickIntents.onDismissDialog() }, - onCancelClick = clickIntents::onDismissDialog, + onCancelClick = expressTransactionsClickIntents::onDismissDialog, ), ), ) @@ -367,13 +369,13 @@ internal class TokenDetailsStateFactory( TangemDropdownMenuItem( title = resourceReference(R.string.token_details_generate_xpub), textColor = themedColor { TangemTheme.colors.text.primary1 }, - onClick = clickIntents::onGenerateExtendedKey, + onClick = tokenDetailsClickIntents::onGenerateExtendedKey, ).let(::add) } TangemDropdownMenuItem( title = TextReference.Res(id = R.string.token_details_hide_token), textColor = themedColor { TangemTheme.colors.text.warning }, - onClick = clickIntents::onHideClick, + onClick = tokenDetailsClickIntents::onHideClick, ).let(::add) }.toImmutableList(), ) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt index 42e28102ef..c4204e04e1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt @@ -25,7 +25,7 @@ import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isF import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeStatusState import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM @@ -43,7 +43,7 @@ import java.util.Locale // Fixme [REDACTED_JIRA] @Suppress("LargeClass") internal class TokenDetailsSwapTransactionsStateConverter( - private val clickIntents: TokenDetailsClickIntents, + private val clickIntents: ExpressTransactionsClickIntents, private val cryptoCurrency: CryptoCurrency, private val analyticsEventsHandler: AnalyticsEventHandler, appCurrencyProvider: Provider, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt index 3aed5a00de..d2b9ffaddb 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt @@ -18,7 +18,7 @@ import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.domain.* -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter @@ -44,7 +44,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val swapTransactionStatusStore: SwapTransactionStatusStore, private val analyticsEventsHandler: AnalyticsEventHandler, - @Assisted private val clickIntents: TokenDetailsClickIntents, + @Assisted private val clickIntents: ExpressTransactionsClickIntents, @Assisted private val appCurrencyProvider: Provider, @Assisted private val currentStateProvider: Provider, @Assisted private val userWallet: UserWallet, @@ -257,7 +257,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( @AssistedFactory interface Factory { fun create( - clickIntents: TokenDetailsClickIntents, + clickIntents: ExpressTransactionsClickIntents, appCurrencyProvider: Provider, currentStateProvider: Provider, userWallet: UserWallet, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt index 46a530ddbe..bb0dbbd4c4 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt @@ -12,7 +12,7 @@ import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent import com.tangem.feature.swap.domain.models.domain.ExchangeStatus -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM @@ -33,7 +33,7 @@ import kotlinx.coroutines.withContext @Suppress("LongParameterList") internal class ExpressStatusFactory @AssistedInject constructor( @Assisted private val currentStateProvider: Provider, - @Assisted private val clickIntents: TokenDetailsClickIntents, + @Assisted private val clickIntents: ExpressTransactionsClickIntents, @Assisted private val cryptoCurrency: CryptoCurrency, @Assisted appCurrencyProvider: Provider, @Assisted userWallet: UserWallet, @@ -205,7 +205,7 @@ internal class ExpressStatusFactory @AssistedInject constructor( interface Factory { @Suppress("LongParameterList") fun create( - clickIntents: TokenDetailsClickIntents, + clickIntents: ExpressTransactionsClickIntents, appCurrencyProvider: Provider, currentStateProvider: Provider, userWallet: UserWallet, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/OnrampStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/OnrampStatusFactory.kt index 61a05030ba..4543119759 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/OnrampStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/OnrampStatusFactory.kt @@ -15,7 +15,7 @@ import com.tangem.domain.onramp.OnrampUpdateTransactionStatusUseCase import com.tangem.domain.onramp.model.OnrampStatus import com.tangem.domain.onramp.model.OnrampStatus.Status.* import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsOnrampTransactionStateConverter import com.tangem.utils.Provider @@ -37,7 +37,7 @@ internal class OnrampStatusFactory @AssistedInject constructor( @Assisted private val currentStateProvider: Provider, @Assisted private val cryptoCurrencyStatusProvider: Provider, @Assisted private val appCurrencyProvider: Provider, - @Assisted private val clickIntents: TokenDetailsClickIntents, + @Assisted private val clickIntents: ExpressTransactionsClickIntents, @Assisted private val cryptoCurrency: CryptoCurrency, @Assisted private val userWallet: UserWallet, ) { @@ -157,7 +157,7 @@ internal class OnrampStatusFactory @AssistedInject constructor( currentStateProvider: Provider, cryptoCurrencyStatusProvider: Provider, appCurrencyProvider: Provider, - clickIntents: TokenDetailsClickIntents, + clickIntents: ExpressTransactionsClickIntents, cryptoCurrency: CryptoCurrency, userWallet: UserWallet, ): OnrampStatusFactory diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/DefaultExpressTransactionsEventListener.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/DefaultExpressTransactionsEventListener.kt new file mode 100644 index 0000000000..b6a5d1da32 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/DefaultExpressTransactionsEventListener.kt @@ -0,0 +1,24 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.utils + +import com.tangem.features.tokendetails.ExpressTransactionsEvent +import com.tangem.features.tokendetails.ExpressTransactionsEventListener +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class DefaultExpressTransactionsEventListener @Inject constructor() : ExpressTransactionsEventListener { + + private val _event = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 0, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + override val event: Flow = _event + + override suspend fun send(event: ExpressTransactionsEvent) { + _event.emit(event) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index f3466d9025..2e2ea39238 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -45,7 +45,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow // TODO: Split to blocks [REDACTED_JIRA] -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable internal fun TokenDetailsScreen( state: TokenDetailsState, @@ -62,6 +62,7 @@ internal fun TokenDetailsScreen( ) { scaffoldPaddings -> val listState = rememberLazyListState() val txHistoryComponentState by txHistoryComponent.txHistoryState.collectAsStateWithLifecycle() + val dialogConfig = state.dialogConfig val betweenItemsPadding = TangemTheme.dimens.spacing12 val horizontalPadding = TangemTheme.dimens.spacing16 val itemModifier = Modifier @@ -162,7 +163,9 @@ internal fun TokenDetailsScreen( } } - TokenDetailsDialogs(state = state) + if (dialogConfig != null) { + TokenDetailsDialogs(dialogConfig = dialogConfig) + } state.bottomSheetConfig?.let { config -> when (config.content) { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsDialogs.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsDialogs.kt index 3e57199b5a..5428e5d949 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsDialogs.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsDialogs.kt @@ -1,16 +1,14 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components import androidx.compose.runtime.Composable +import com.tangem.common.ui.tokendetails.TokenDetailsDialogConfig import com.tangem.core.ui.components.BasicDialog import com.tangem.core.ui.components.DialogButtonUM import com.tangem.core.ui.extensions.resolveReference -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState -import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig @Composable -internal fun TokenDetailsDialogs(state: TokenDetailsState) { - val dialogConfig = state.dialogConfig - if (dialogConfig != null && dialogConfig.isShow) { +internal fun TokenDetailsDialogs(dialogConfig: TokenDetailsDialogConfig) { + if (dialogConfig.isShow) { TokenDetailsDialog(config = dialogConfig) } } @@ -21,7 +19,7 @@ private fun TokenDetailsDialog(config: TokenDetailsDialogConfig) { message = config.content.message.resolveReference(), confirmButton = DialogButtonUM( title = config.content.confirmButtonConfig.text.resolveReference(), - isWarning = config.content.confirmButtonConfig.warning, + isWarning = config.content.confirmButtonConfig.hasWarning, onClick = config.content.confirmButtonConfig.onClick, ), onDismissDialog = config.onDismissRequest, @@ -29,7 +27,7 @@ private fun TokenDetailsDialog(config: TokenDetailsDialogConfig) { dismissButton = config.content.cancelButtonConfig?.let { cancelButtonConfig -> DialogButtonUM( title = cancelButtonConfig.text.resolveReference(), - isWarning = cancelButtonConfig.warning, + isWarning = cancelButtonConfig.hasWarning, onClick = cancelButtonConfig.onClick, ) }, From 20efecd9ee7b3d44c7c6a637780be78801aeecb8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 20 Jan 2026 14:59:32 +0300 Subject: [PATCH 145/172] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 6 +++--- core/res/src/main/res/values-es/strings.xml | 4 ++-- core/res/src/main/res/values-fr/strings.xml | 4 ++-- core/res/src/main/res/values-it/strings.xml | 3 ++- core/res/src/main/res/values-ja/strings.xml | 14 ++++++++++++-- core/res/src/main/res/values-ru/strings.xml | 6 +++--- core/res/src/main/res/values-uk-rUA/strings.xml | 4 ++-- core/res/src/main/res/values-zh-rTW/strings.xml | 3 ++- core/res/src/main/res/values/strings.xml | 14 ++++++++++++-- .../com/tangem/feature/swap/model/SwapModel.kt | 1 + .../tangem/feature/swap/models/SwapStateHolder.kt | 1 + .../com/tangem/feature/swap/ui/StateBuilder.kt | 2 ++ .../tangem/feature/swap/ui/SwapScreenContent.kt | 6 +++++- 13 files changed, 49 insertions(+), 19 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 65cca8211d..0a5f0530a8 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -1509,7 +1509,7 @@ Es dauert in der Regel bis zu 15 Minuten. Einrichtung Ihrer Tangem-Karte Ausstellung Deiner Karte - Wir bereiten Ihre Karte vor. Dies kann etwas dauern. + Die Karte wird in der Regel innerhalb von 5 Minuten automatisch ausgestellt. In seltenen Fällen, wenn eine manuelle Überprüfung erforderlich ist, kann die Ausstellung bis zu 48 Stunden dauern. Tangem Pay Konvertierung bestätigen Möchtest Du den KYC wirklich abbrechen? Du kannst später jederzeit weiter machen. @@ -1518,7 +1518,7 @@ KYC in Bearbeitung Status anzeigen KYC für Tangem Pay in Arbeit - Über die Schaltflächen unten kannst Du Deinen aktuellen KYC-Status einsehen oder ihn abbrechen. + Dokumente werden in der Regel innerhalb von 5 Minuten automatisch überprüft. In seltenen Fällen, wenn eine manuelle Überprüfung erforderlich ist, kann es bis zu 48 Stunden dauern. Holen Sie sich Ihre kostenlose virtuelle Tangem Visa-Karte Nutzen Sie USDC für alltägliche Zahlungen Karte erhalten @@ -1767,7 +1767,7 @@ Importiere eine bestehende Wallet mit Deiner Wiederherstellungsphrase. Wallet importieren Wiederherstellungsphrase eingeben - Du hast Deine Wallet erfolgreich gesichert. + Du hast Deine Wallet erfolgreich importiert. Wallet importieren Import abgeschlossen Wallet importieren diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index e80c01ac3c..70a5296c78 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -1509,7 +1509,7 @@ Suele tardar hasta 15 minutos Configurando tu tarjeta Tangem Emisión de su tarjeta - Estamos preparando tu tarjeta. Esto puede llevar un poco de tiempo. + La tarjeta suele emitirse automáticamente en menos de 5 minutos. En casos excepcionales que requieran revisión manual, la emisión puede tardar hasta 48 horas. Tangem Pay Confirmar cancelación ¿Seguro que desea detener el proceso KYC? Puede retomarlo en cualquier momento. @@ -1518,7 +1518,7 @@ KYC en curso Ver estado KYC en progreso para Tangem Pay - Utilice los botones a continuación para ver su estado KYC actual o cancelarlo. + Los documentos suelen verificarse automáticamente en menos de 5 minutos. En casos excepcionales que requieran revisión manual, el proceso puede tardar hasta 48 horas. Obtén tu tarjeta virtual Tangem Visa gratuita Usa USDC para pagos cotidianos Obtener tarjeta diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index ed570724fa..a1a06768bb 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1502,7 +1502,7 @@ Cela prend généralement jusqu\'à 15 minutes Configuration de votre carte Tangem Émission de votre carte - Nous préparons votre carte. Cela peut prendre un peu de temps. + La carte est généralement émise automatiquement en moins de 5 minutes. Dans de rares cas nécessitant une vérification manuelle, l’émission peut prendre jusqu’à 48 heures. Tangem Pay Confirmer l\'annulation Êtes-vous sûr de vouloir interrompre le processus KYC ? Vous pouvez y revenir à tout moment. @@ -1511,7 +1511,7 @@ KYC en cours Voir le statut KYC en cours pour Tangem Pay - Utilisez les boutons ci-dessous pour consulter votre statut KYC actuel ou l\'annuler. + Les documents sont généralement vérifiés automatiquement en moins de 5 minutes. Dans de rares cas nécessitant une vérification manuelle, le processus peut prendre jusqu’à 48 heures. Obtenez votre carte virtuelle Tangem Visa gratuite Utilisez USDC pour les paiements quotidiens Obtenir la carte diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index 9128efeb7f..cfa982d89a 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -145,13 +145,14 @@ Di solito richiede fino a 15 minuti Configurazione della tua carta Tangem Emissione della tua carta - Stiamo preparando la tua carta. Ci vorrà un po\' di tempo. + La carta viene solitamente emessa automaticamente entro 5 minuti. In rari casi, se è necessaria una revisione manuale, l’emissione può richiedere fino a 48 ore. Tangem Pay Non siamo riusciti a verificare il tuo profilo. Per domande, contatta il supporto. Purtroppo non siamo riusciti a verificare la tua identità KYC in corso Visualizza stato KYC in corso per Tangem Pay + I documenti vengono solitamente verificati automaticamente entro 5 minuti. In rari casi, se è necessaria una revisione manuale, il processo può richiedere fino a 48 ore. Ottieni la tua carta virtuale Tangem Visa gratuita Usa USDC per i pagamenti quotidiani Ottieni carta diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 5197f96905..a59573d0ab 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -430,6 +430,7 @@ このメカニズムは、カードまたはリングに対する近接攻撃から保護します。コマンドの受信と実行の間に遅延を強制します。 パスコード カードの状態の変更を伴うコマンドを実行する前に、パスコードを入力する必要があります。 + ハードウェアウォレットにアップグレード NFT 紹介プログラム デバイスの画面を下に向けると、残高をすばやく非表示にしたり表示したりできます。 @@ -443,6 +444,7 @@ 署名済み フィードバックを送信 詳細 + 同時に利用できるのは、モバイルウォレットまたはハードウェアウォレットのいずれか一方のみです。Tangemのコールドウォレットを利用するには、現在のウォレットをアップグレードするか、アプリから削除してください。 インターネット接続を確認するか、別のネットワークに切り替えてください。 利用規約 デフォルトアドレス @@ -552,6 +554,7 @@ Tangemへのフィードバック 取引を送信できません コインの説明エラー + 残高不足 ガス代不要の取引手数料 エラーが発生しました エラーが発生しました。コード: %s 。 @@ -1225,6 +1228,11 @@ 設定したいカードまたはリングをスキャンするために準備してください。 ウォレット削除 これにより、ウォレットがアプリから削除されます。ウォレットは再度追加できます。 + シンプルな操作 + 暗号資産をオフラインで厳重保管。カードサイズで、金庫以上の安心を。 + シードフレーズ不要 + 業界最高水準のハードウェアウォレット + Tangemコールドウォレット 名前 トークンを活用しよう ネットワーク手数料とは、ブロックチェーン上で取引を処理し、承認するために支払う少額の料金です。 @@ -1391,6 +1399,7 @@ 手間がかからず直感的に操作でき、数回タップするだけでトークンを交換できます。 とにかく便利 プロバイダー経由のスワップ + 資産 この金額には以下が含まれます:\n- サービスプロバイダーの手数料\n- 取引所からユーザーのアドレスに%s を送り返すためのネットワーク手数料。 金額には以下が含まれます: \n • サービス プロバイダーの手数料\n • 取引所からユーザーのアドレスに%1$sを送金するためのネットワーク手数料。 \n\nプロバイダーのスリッページは最大%2$sです この金額には、サービスプロバイダーの手数料が含まれています。 @@ -1493,7 +1502,7 @@ 通常は最大で15分ほどかかります Tangemカードのセットアップ カードを発行しています - カードを準備しています。少し時間がかかる場合があります。 + カードは通常、5分以内に自動で発行されます。手動での審査が必要な稀な場合は、最大48時間かかることがあります。 Tangem Pay 中止を確定する KYC手続きを中止しますか?いつでも再開できます。 @@ -1503,7 +1512,7 @@ KYC進行中 ステータスを表示 Tangem PayのKYC手続き進行中 - 以下のボタンから、現在のKYCステータスを確認するか、KYCをキャンセルできます。 + 書類は通常、5分以内に自動で確認されます。手動での審査が必要な稀な場合は、最大48時間かかることがあります。 KYCが承認されませんでした KYCブロックを非表示 申し訳ありません、確認できませんでした @@ -1602,6 +1611,7 @@ エラーが発生しました。エラー コード:%s。サポートにお問い合わせください。 %sを使用するか、カード / リングをスキャンしてウォレットにアクセスしてください 接続に失敗しました:このdAppは、サポートされていないWallet Connectバージョン1.0を使用しています。正常に接続するには、dAppがWallet Connectバージョン2.0をサポートしていることを確認してください。 + ハードウェアウォレットにアップグレード 最新の機能とニュースをお届けします 取引・スワップ・重要な更新に関するリアルタイム通知。 取引アラート diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 21edba4fa6..f54c4a89be 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -325,7 +325,7 @@ %d сеть %d сети %d сетей - %d сети + %d сетях Новый адрес Новости @@ -1543,7 +1543,7 @@ Обычно это занимает до 15 минут Готовим вашу Tangem Card Выпускаем карту - Выпускаем карту, это займет немного времени. + Обычно это занимает не больше 5 минут, в редких случах при необходимости ручной проверки – до 48 часов. Tangem Pay Подтвердите отмену Если вы отмените KYC сейчас, вы сможете потом начать его заново @@ -1552,7 +1552,7 @@ KYC в процессе Посмотреть статус KYC в процессе для Tangem Pay - Вы можете посмотреть текущий статус KYC или отменить его + Обычно документы проверяются автоматически в течение 5 минут, в редких случах при необходимости ручной проверки – до 48 часов. KYC отклонено Откройте бесплатную виртуальную карту Tangem Visa Оплачивайте ежедневные покупки в USDC diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index e9f4049c3b..5954c2933e 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1536,7 +1536,7 @@ Зазвичай це займає до 15 хвилин Готуємо вашу картку Tangem Випускаємо картку - Випускаємо картку, це займе деякий час. + Карту зазвичай випускають автоматично протягом 5 хвилин. У рідкісних випадках, коли потрібна ручна перевірка, процес може тривати до 48 годин. Tangem Pay Підтвердити скасування Ви впевнені, що хочете зупинити процес KYC? Ви можете повернутися до нього в будь-який момент. @@ -1545,7 +1545,7 @@ KYC в процесі Переглянути статус Перевірка KYC для Tangem Pay в процесі - Ви можете переглянути поточний статус KYC або відмінити його + Документи зазвичай перевіряються автоматично протягом 5 хвилин. У рідкісних випадках, коли потрібна ручна перевірка, це може зайняти до 48 годин. Отримайте безкоштовну віртуальну картку Tangem Visa Використовуйте USDC для щоденних платежів Отримати картку diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index a79d66c1d7..393b0cadb9 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -388,13 +388,14 @@ 通常需要最多15分钟 設置您的 Tangem 卡 正在发行您的卡片 - 正在为您准备卡片,这可能需要一些时间 + 卡片通常會在 5 分鐘內自動發行。如需人工審核的特殊情況,可能需要最長 48 小時。 Tangem Pay 我们无法验证您的资料。如有任何疑问,请联系客服。 很抱歉,我们无法验证您的身份 KYC进行中 查看状态 Tangem Pay 的 KYC 正在進行中 + 文件通常會在 5 分鐘內自動驗證完成。如需人工審核的特殊情況,可能需要最長 48 小時。 獲取您的免費 Tangem Visa 虛擬卡 使用 USDC 進行日常支付 获取卡片 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index a5e90a215a..162f432ac1 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -438,6 +438,7 @@ This mechanism protects against proximity attacks on a card or ring. It will enforce a delay between reception and execution of a command. Passcode Before executing any command entailing a change of the card state, you will have to enter the passcode. + Upgrade to hardware wallet NFT Referral program Flip your device screen down to quickly hide and show balances @@ -451,6 +452,7 @@ Signed Send feedback Details + You can have either one mobile wallet or hardware wallet at the same time. Upgrade you wallet or delete it from the app to start using a Tangem cold wallet. Check your internet connection or switch to a different network Terms of service Default Address @@ -1247,6 +1249,11 @@ Prepare to scan card or ring you want to set up. Forget wallet This will remove the wallet from the application. The wallet itself can be added again. + Simple to use + Keeps your crypto safe and offline. Slim as a credit card, safer than a bank vault. + No seed phrase + Best in class hardware wallet + Tangem Cold Wallet Name Put your token to work A network fee is a small payment required to process and confirm your transaction on the blockchain. @@ -1413,6 +1420,7 @@ Hassle-free and intuitive, allowing you to swap tokens in just a few taps Simply Convenient Swap via provider + Your assets The amount includes:\n• service provider\'s fee\n• network fee for sending %s from the exchange back to the user\'s address. The amount includes:\n• service provider\'s fee\n• network fee for sending %1$s from the exchange back to the user\'s address. \n\nProvider slippage is up to %2$s The amount includes the service provider\'s fee. @@ -1427,6 +1435,7 @@ Insufficient funds Give Permission Swap + Swapping... You receive Choose token not available @@ -1515,7 +1524,7 @@ It usually takes up to 15 minutes Setting up your Tangem Card Issuing your card - We’re getting your card ready. This may take a little time. + The card is usually issued automatically within 5 minutes. In rare cases, if manual review is required, it may take up to 48 hours. Tangem Pay Confirm Cancellation Are you sure you want to stop the KYC process? You can return to it anytime. @@ -1525,7 +1534,7 @@ KYC in progress View Status KYC in progress for Tangem Pay - Use the buttons below to view your current KYC status or cancel it. + Documents are usually verified automatically within 5 minutes. In rare cases, if manual review is required, it may take up to 48 hours. KYC rejected Hide KYC block Sorry, we couldn\'t verify @@ -1624,6 +1633,7 @@ We\'ve encountered an error. Error code: %s. Please contact our support. Use %s or scan a card/ring to have access to your wallet Connection failed: This dApp uses Wallet Connect version 1.0, which is not supported. Please ensure the dApp supports Wallet Connect version 2.0 to connect successfully. + Upgrade to a hardware wallet Stay up to date with the latest features and news Real-time alerts for transactions, exchanges, and critical updates. Transaction Alerts 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 338aa29be8..66242b645e 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 @@ -2048,6 +2048,7 @@ internal class SwapModel @Inject constructor( uiState = uiState.copy( swapButton = uiState.swapButton.copy( isEnabled = false, + isInProgress = false, ), ) modelScope.launch { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 48a1a717c6..f37d7dd2fe 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -78,6 +78,7 @@ sealed class SwapCardState { data class SwapButton( @DrawableRes val walletInteractionIcon: Int?, val isEnabled: Boolean, + val isInProgress: Boolean = false, val onClick: () -> Unit, ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 89570857d7..33bf20ea2b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -608,6 +608,7 @@ internal class StateBuilder( return uiState.copy( swapButton = uiState.swapButton.copy( isEnabled = false, + isInProgress = true, ), ) } @@ -811,6 +812,7 @@ internal class StateBuilder( return uiState.copy( swapButton = uiState.swapButton.copy( isEnabled = false, + isInProgress = false, ), permissionState = GiveTxPermissionState.InProgress, notifications = notificationsFactory.getApprovalInProgressStateNotification(uiState.notifications), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 1c6eca0df3..a1cb85f872 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -380,7 +380,11 @@ private fun MainButton(state: SwapStateHolder) { } else { PrimaryButtonIconEnd( modifier = Modifier.fillMaxWidth(), - text = stringResourceSafe(id = R.string.swapping_swap_action), + text = if (state.swapButton.isInProgress) { + stringResourceSafe(id = R.string.swapping_swap_action_in_progress) + } else { + stringResourceSafe(id = R.string.swapping_swap_action) + }, iconResId = state.swapButton.walletInteractionIcon, enabled = state.swapButton.isEnabled, onClick = state.swapButton.onClick, From f08687d9850e7825169c59d7d2cbc8f79154f4f9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 13:35:48 +0300 Subject: [PATCH 146/172] Updated on 2026-08-14 --- features/feed/api/build.gradle.kts | 3 ++ .../portfolio/add}/AddToPortfolioComponent.kt | 7 ++-- .../portfolio/add}/AddToPortfolioManager.kt | 15 +++++--- .../portfolio/add}/AvailableToAddData.kt | 12 +++---- .../portfolio/add/impl/AddTokenComponent.kt | 4 +-- .../add/impl/ChooseNetworkComponent.kt | 2 +- .../impl/DefaultAddToPortfolioComponent.kt | 2 +- .../converter/AvailableToAddDataConverter.kt | 6 ++-- .../impl/di/AddToPortfolioComponentModule.kt | 4 +-- .../add/impl/model/AddToPortfolioModel.kt | 34 +++++++++++++------ .../add/impl/model/AddToPortfolioRoutes.kt | 2 +- .../portfolio/add/impl/model/AddTokenModel.kt | 4 +-- .../add/impl/model/AddTokenUiBuilder.kt | 4 +-- .../impl/ui/DefaultAddToPortfolioManager.kt | 7 ++-- .../impl/DefaultMarketsPortfolioComponent.kt | 2 +- .../impl/model/MarketsPortfolioModel.kt | 7 ++-- 16 files changed, 70 insertions(+), 45 deletions(-) rename features/feed/{impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api => api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add}/AddToPortfolioComponent.kt (64%) rename features/feed/{impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api => api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add}/AddToPortfolioManager.kt (76%) rename features/feed/{impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api => api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add}/AvailableToAddData.kt (89%) diff --git a/features/feed/api/build.gradle.kts b/features/feed/api/build.gradle.kts index 73aa93625c..890d5aefad 100644 --- a/features/feed/api/build.gradle.kts +++ b/features/feed/api/build.gradle.kts @@ -13,6 +13,9 @@ android { dependencies { implementation(deps.compose.foundation) + /* Project - API */ + api(projects.features.account.api) + /* Project - Core */ implementation(projects.core.decompose) implementation(projects.core.ui) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioComponent.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioComponent.kt similarity index 64% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioComponent.kt rename to features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioComponent.kt index 38afdb8400..902f38339d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioComponent.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioComponent.kt @@ -1,17 +1,20 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.api +package com.tangem.features.feed.components.market.details.portfolio.add import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.currency.CryptoCurrency -internal interface AddToPortfolioComponent : ComposableBottomSheetComponent { +interface AddToPortfolioComponent : ComposableBottomSheetComponent { data class Params( val addToPortfolioManager: AddToPortfolioManager, val callback: Callback, + val shouldSkipTokenActionsScreen: Boolean = false, ) interface Callback { fun onDismiss() + fun onSuccess(addedToken: CryptoCurrency) } interface Factory : ComponentFactory diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioManager.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioManager.kt similarity index 76% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioManager.kt rename to features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioManager.kt index bcad1f6cb9..8d4e35e9ce 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioManager.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioManager.kt @@ -1,17 +1,17 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.api +package com.tangem.features.feed.components.market.details.portfolio.add import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.Serializable -internal interface AddToPortfolioManager { +interface AddToPortfolioManager { val token: TokenMarketParams - val analyticsParams: MarketsPortfolioComponent.AnalyticsParams? + val analyticsParams: AnalyticsParams? val portfolioFetcher: PortfolioFetcher val state: StateFlow @@ -28,11 +28,16 @@ internal interface AddToPortfolioManager { data object NothingToAdd : State } + @Serializable + data class AnalyticsParams( + val source: String, + ) + interface Factory { fun create( scope: CoroutineScope, token: TokenMarketParams, - analyticsParams: MarketsPortfolioComponent.AnalyticsParams?, + analyticsParams: AnalyticsParams?, ): AddToPortfolioManager } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AvailableToAddData.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt similarity index 89% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AvailableToAddData.kt rename to features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt index 94006d470e..9d221287dd 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AvailableToAddData.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.api +package com.tangem.features.feed.components.market.details.portfolio.add import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.models.account.AccountId @@ -9,7 +9,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import kotlinx.serialization.Serializable -internal data class AvailableToAddData( +data class AvailableToAddData( val availableToAddWallets: Map, ) { val isAvailableToAdd: Boolean @@ -18,7 +18,7 @@ internal data class AvailableToAddData( get() = availableToAddWallets.size == 1 && availableToAddWallets.values.first().accounts.size == 1 } -internal data class AvailableToAddWallet( +data class AvailableToAddWallet( val userWallet: UserWallet, val accounts: List, val availableNetworks: Set, @@ -26,7 +26,7 @@ internal data class AvailableToAddWallet( ) @Serializable -internal data class AvailableToAddAccount( +data class AvailableToAddAccount( val account: AccountStatus, val availableNetworks: Set, val addedNetworks: Set, @@ -44,14 +44,14 @@ internal data class AvailableToAddAccount( } @Serializable -internal data class SelectedPortfolio( +data class SelectedPortfolio( val userWallet: UserWallet, val account: AvailableToAddAccount, val isAccountMode: Boolean, val isAvailableMorePortfolio: Boolean, ) -internal data class SelectedNetwork( +data class SelectedNetwork( val selectedNetwork: TokenMarketInfo.Network, val cryptoCurrency: CryptoCurrency, val isAvailableMoreNetwork: Boolean, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt index 808dcceab5..b762aa9727 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt @@ -9,8 +9,8 @@ import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedNetwork -import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenModel import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent import dagger.assisted.Assisted diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt index 727f08f7cf..71c5a6dc55 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt @@ -9,7 +9,7 @@ import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.ChooseNetworkModel import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.ChooseNetworkContent import dagger.assisted.Assisted diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt index 174bdcf21b..edd7f01ef5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt @@ -32,7 +32,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioModel import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes import com.tangem.features.feed.impl.R diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt index d42d22f23c..dd013d17ba 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt @@ -12,9 +12,9 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.feed.components.market.details.portfolio.add.api.AvailableToAddAccount -import com.tangem.features.feed.components.market.details.portfolio.add.api.AvailableToAddData -import com.tangem.features.feed.components.market.details.portfolio.add.api.AvailableToAddWallet +import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddAccount +import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddData +import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddWallet import javax.inject.Inject internal class AvailableToAddDataConverter @Inject constructor( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt index f074c0d167..b522e1b6e3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt @@ -1,7 +1,7 @@ package com.tangem.features.feed.components.market.details.portfolio.add.impl.di -import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioManager +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.DefaultAddToPortfolioManager import dagger.Binds diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt index 2a9b23cdd5..4e71735618 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt @@ -19,7 +19,12 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.features.account.PortfolioSelectorController -import com.tangem.features.feed.components.market.details.portfolio.add.api.* +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddAccount +import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddData +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent @@ -91,6 +96,11 @@ internal class AddToPortfolioModel @Inject constructor( channel.close() } + fun finishSuccessFlow(addedToken: CryptoCurrencyStatus) { + params.callback.onSuccess(addedToken.currency) + channel.close() + } + val featureDataFlow: StateFlow = featureData .filterIsInstance() .map { it.availableToAddData } @@ -183,13 +193,17 @@ internal class AddToPortfolioModel @Inject constructor( messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) - setupTokenActionsFlow(selectedPortfolio, addedToken) - .onEach { cryptoCurrencyData -> - tokenActionsData.emit(cryptoCurrencyData) - navigation.replaceAll(AddToPortfolioRoutes.TokenActions) - } - .onEmpty { finishFlow() } - .launchIn(this) + if (params.shouldSkipTokenActionsScreen) { + finishSuccessFlow(addedToken) + } else { + setupTokenActionsFlow(selectedPortfolio, addedToken) + .onEach { cryptoCurrencyData -> + tokenActionsData.emit(cryptoCurrencyData) + navigation.replaceAll(AddToPortfolioRoutes.TokenActions) + } + .onEmpty { finishFlow() } + .launchIn(this) + } callbackDelegate.onLaterClick.receiveAsFlow().first() finishFlow() @@ -316,8 +330,8 @@ internal class AddToPortfolioModel @Inject constructor( network: TokenMarketInfo.Network, account: AvailableToAddAccount, ): CryptoCurrency? { - val accountIndex = when (account.account) { - is AccountStatus.CryptoPortfolio -> account.account.account.derivationIndex + val accountIndex = when (val accountStatus = account.account) { + is AccountStatus.CryptoPortfolio -> accountStatus.account.derivationIndex } return getTokenMarketCryptoCurrency( userWalletId = userWallet.walletId, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt index 3d954d2239..a8c2acb3aa 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt @@ -2,7 +2,7 @@ package com.tangem.features.feed.components.market.details.portfolio.add.impl.mo import androidx.compose.runtime.Immutable import com.tangem.core.decompose.navigation.Route -import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio import kotlinx.serialization.Serializable @Serializable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt index 830e71e6de..cedc618422 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt @@ -13,8 +13,8 @@ import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.models.account.Account import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase -import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedNetwork -import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenUiBuilder.Companion.toggleProgress import com.tangem.features.feed.impl.R diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt index 24b4079240..b6ad346e0c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt @@ -13,8 +13,8 @@ import com.tangem.core.ui.extensions.iconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.account.AccountStatus -import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedNetwork -import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent import com.tangem.features.feed.impl.R import javax.inject.Inject diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt index f286f46e40..8c27a3929d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt @@ -3,9 +3,8 @@ package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioManager +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager import com.tangem.features.feed.components.market.details.portfolio.add.impl.converter.AvailableToAddDataConverter -import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -17,7 +16,7 @@ import kotlinx.coroutines.flow.* internal class DefaultAddToPortfolioManager @AssistedInject constructor( private val availableToAddDataConverter: AvailableToAddDataConverter, @Assisted override val token: TokenMarketParams, - @Assisted override val analyticsParams: MarketsPortfolioComponent.AnalyticsParams?, + @Assisted override val analyticsParams: AddToPortfolioManager.AnalyticsParams?, @Assisted val scope: CoroutineScope, dispatchers: CoroutineDispatcherProvider, portfolioFetcherFactory: PortfolioFetcher.Factory, @@ -66,7 +65,7 @@ internal class DefaultAddToPortfolioManager @AssistedInject constructor( override fun create( scope: CoroutineScope, token: TokenMarketParams, - analyticsParams: MarketsPortfolioComponent.AnalyticsParams?, + analyticsParams: AddToPortfolioManager.AnalyticsParams?, ): DefaultAddToPortfolioManager } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt index cd1eb2d9ee..6df78dda91 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt @@ -14,7 +14,7 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioModel import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioRoute diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt index 550f17efbc..1715e60e47 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt @@ -31,7 +31,7 @@ import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase -import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData @@ -50,7 +50,7 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject -import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioManager as NewAddToPortfolioManager +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager as NewAddToPortfolioManager @Suppress("LongParameterList", "LargeClass") @Stable @@ -96,6 +96,7 @@ internal class MarketsPortfolioModel @Inject constructor( val bottomSheetNavigation: SlotNavigation = SlotNavigation() val addToPortfolioCallback = object : AddToPortfolioComponent.Callback { override fun onDismiss() = bottomSheetNavigation.dismiss() + override fun onSuccess(addedToken: CryptoCurrency) = bottomSheetNavigation.dismiss() } private val currentAppCurrency = getSelectedAppCurrencyUseCase() @@ -184,7 +185,7 @@ internal class MarketsPortfolioModel @Inject constructor( .create( modelScope, params.token, - params.analyticsParams, + params.analyticsParams?.source?.let { NewAddToPortfolioManager.AnalyticsParams(it) }, ) newMarketsPortfolioDelegate = newMarketsPortfolioDelegateFactory.create( scope = modelScope, From 155b6516d99779e876133a0b7dbd1e5128caa4a6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 13:36:43 +0300 Subject: [PATCH 147/172] Updated on 2026-08-14 --- .../com/tangem/features/swap/SwapComponent.kt | 4 + features/swap/impl/build.gradle.kts | 4 + .../feature/swap/DefaultSwapComponent.kt | 34 +++++ .../TokenMarketInfoToParamsConverter.kt | 26 ++++ .../tangem/feature/swap/model/SwapModel.kt | 135 +++++++++++++++--- .../swap/models/AddToPortfolioRoute.kt | 7 + 6 files changed, 193 insertions(+), 17 deletions(-) create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokenMarketInfoToParamsConverter.kt create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt index 2d0f54f7b2..d2ef17de45 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt @@ -2,7 +2,9 @@ package com.tangem.features.swap import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import java.math.BigDecimal @@ -15,6 +17,8 @@ interface SwapComponent : ComposableContentComponent { val isInitialReverseOrder: Boolean = false, val screenSource: String, val tangemPayInput: TangemPayInput? = null, + val preselectedToToken: CryptoCurrencyStatus? = null, + val preselectedAccount: Account.CryptoPortfolio? = null, ) { data class TangemPayInput( val cryptoAmount: BigDecimal, diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index c31fc810fd..488c083c0e 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -26,6 +26,9 @@ dependencies { implementation(projects.common.uiMarkets) implementation(projects.common.uiCharts) + /** Data modules */ + implementation(projects.data.common) + /** Domain modules **/ implementation(projects.domain.models) implementation(projects.domain.appCurrency) @@ -60,6 +63,7 @@ dependencies { implementation(projects.features.swap.api) implementation(projects.features.sendV2.api) implementation(projects.features.sendV2.impl) + implementation(projects.features.feed.api) /** AndroidX */ implementation(deps.androidx.activity.compose) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index b391c87b53..3cde40dc0c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate @@ -17,15 +18,18 @@ import com.tangem.common.ui.swapStoriesScreen.SwapStoriesScreen import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.model.SwapModel +import com.tangem.feature.swap.models.AddToPortfolioRoute import com.tangem.feature.swap.models.SwapCardState.SwapCardData import com.tangem.feature.swap.router.SwapNavScreen import com.tangem.feature.swap.ui.SwapScreen import com.tangem.feature.swap.ui.SwapSelectTokenScreen import com.tangem.feature.swap.ui.SwapSuccessScreen +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents import com.tangem.features.swap.SwapComponent @@ -39,10 +43,19 @@ internal class DefaultSwapComponent @AssistedInject constructor( @Assisted private val params: SwapComponent.Params, private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory, private val sendFeatureToggles: SendFeatureToggles, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, ) : SwapComponent, AppComponentContext by appComponentContext { private val model: SwapModel = getOrCreateModel(params) + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = AddToPortfolioRoute.serializer(), + key = BOTTOM_SHEET_SLOT_KEY, + handleBackButton = false, + childFactory = { configuration, context -> bottomSheetChild(context) }, + ) + init { lifecycle.subscribe( onStart = model::onStart, @@ -54,6 +67,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( val childSlot = childSlot( source = slotNavigation, serializer = null, + key = FEE_SELECTOR_SLOT_KEY, childFactory = { config, context -> createSwapFeeSelectorBlockComponent( context = childByContext(context), @@ -114,6 +128,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( val feeSelectorChildStackState by childSlot.subscribeAsState() val feeSelectorBlockComponent = feeSelectorChildStackState.child?.instance + val bottomSheet by bottomSheetSlot.subscribeAsState() Crossfade( modifier = Modifier.background(TangemTheme.colors.background.secondary), @@ -165,10 +180,29 @@ internal class DefaultSwapComponent @AssistedInject constructor( } } } + + bottomSheet.child?.instance?.BottomSheet() + } + + @Suppress("UnsafeCallOnNullableType") + private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent { + return addToPortfolioComponentFactory.create( + context = childByContext(componentContext), + params = AddToPortfolioComponent.Params( + addToPortfolioManager = model.addToPortfolioManager!!, + callback = model.addToPortfolioCallback, + shouldSkipTokenActionsScreen = true, + ), + ) } @AssistedFactory interface Factory : SwapComponent.Factory { override fun create(context: AppComponentContext, params: SwapComponent.Params): DefaultSwapComponent } + + private companion object { + const val BOTTOM_SHEET_SLOT_KEY = "bottomSheetSlot" + const val FEE_SELECTOR_SLOT_KEY = "feeSelectorSlot" + } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokenMarketInfoToParamsConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokenMarketInfoToParamsConverter.kt new file mode 100644 index 0000000000..992ad0521b --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokenMarketInfoToParamsConverter.kt @@ -0,0 +1,26 @@ +package com.tangem.feature.swap.converters + +import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.utils.converter.Converter + +internal class TokenMarketInfoToParamsConverter : Converter { + + override fun convert(value: TokenMarketInfo): TokenMarketParams { + val tokenId = CryptoCurrency.RawID(value.id) + return TokenMarketParams( + id = tokenId, + name = value.name, + symbol = value.symbol, + tokenQuotes = TokenMarketParams.Quotes( + currentPrice = value.quotes.currentPrice, + h24Percent = value.quotes.h24ChangePercent, + weekPercent = value.quotes.weekChangePercent, + monthPercent = value.quotes.monthChangePercent, + ), + imageUrl = getTokenIconUrlFromDefaultHost(tokenId), + ) + } +} \ No newline at end of file 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 66242b645e..ce909de7e6 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 @@ -6,10 +6,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import arrow.core.Either import arrow.core.getOrElse +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.transaction.TransactionFee +import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.routing.AppRouter import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState.InProgress.getApproveTypeOrNull +import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam @@ -28,6 +33,7 @@ import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase @@ -35,10 +41,12 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase +import com.tangem.domain.markets.GetTokenMarketInfoUseCase import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.promo.GetStoryContentUseCase @@ -58,9 +66,11 @@ import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.swap.analytics.StoriesEvents import com.tangem.feature.swap.analytics.SwapEvents import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent +import com.tangem.feature.swap.converters.TokenMarketInfoToParamsConverter import com.tangem.feature.swap.domain.SwapInteractor import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.TxFeeSealedState @@ -69,6 +79,7 @@ import com.tangem.feature.swap.domain.models.ExpressException import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.* +import com.tangem.feature.swap.models.AddToPortfolioRoute import com.tangem.feature.swap.models.SwapCardState import com.tangem.feature.swap.models.SwapStateHolder import com.tangem.feature.swap.models.UiActions @@ -80,11 +91,14 @@ import com.tangem.feature.swap.router.SwapNavScreen import com.tangem.feature.swap.router.SwapRouter import com.tangem.feature.swap.ui.StateBuilder import com.tangem.feature.swap.utils.formatToUIRepresentation +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.swap.SwapComponent import com.tangem.features.swap.SwapFeatureToggles +import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP import com.tangem.utils.coroutines.* @@ -100,6 +114,7 @@ import java.text.DecimalFormat import java.text.NumberFormat import java.util.Locale import javax.inject.Inject +import kotlin.collections.filter typealias SuccessLoadedSwapData = Map @@ -137,7 +152,11 @@ internal class SwapModel @Inject constructor( private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val sendFeatureToggles: SendFeatureToggles, private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, - private val swapFeatureToggles: SwapFeatureToggles, + swapFeatureToggles: SwapFeatureToggles, + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, + private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, + private val excludedBlockchains: ExcludedBlockchains, + private val getUserWalletsUseCase: GetWalletsUseCase, ) : Model() { private val params = paramsContainer.require() @@ -214,6 +233,7 @@ internal class SwapModel @Inject constructor( private val fromTokenBalanceJobHolder = JobHolder() private val toTokenBalanceJobHolder = JobHolder() + private val addToPortfolioJobHolder = JobHolder() private var isAmountChangedByUser: Boolean = false private var lastPermissionNotificationTokens: Pair? = null @@ -234,6 +254,24 @@ internal class SwapModel @Inject constructor( val currentScreen: SwapNavScreen get() = swapRouter.currentScreen + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val addToPortfolioCallback = object : AddToPortfolioComponent.Callback { + override fun onDismiss() = bottomSheetNavigation.dismiss() + + override fun onSuccess(addedToken: CryptoCurrency) { + modelScope.launch { + bottomSheetNavigation.dismiss() + getAccountCurrencyStatusUseCase.invoke(userWalletId, addedToken) + .firstOrNull { + it.status.value is CryptoCurrencyStatus.Loaded + }?.let { (account, status) -> + applyAddedToken(status, account) + } + } + } + } + var addToPortfolioManager: AddToPortfolioManager? = null + init { userCountry = getUserCountryUseCase.invokeSync().getOrNull() ?: UserCountry.Other(Locale.getDefault().country) @@ -318,7 +356,7 @@ internal class SwapModel @Inject constructor( items = uiItems, loadMore = { searchMarketsListManager.loadMore() }, onItemClick = { item -> - // TODO [REDACTED_TASK_KEY] Add currency to swap form market list + addToPortfolioItem(item) }, visibleIdsChanged = { visibleMarketItemIds.value = it }, ) @@ -424,21 +462,7 @@ internal class SwapModel @Inject constructor( ) } - (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = coin, - isFromCurrency = true, - ) - } - - (dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = coin, - isFromCurrency = false, - ) - } + subscribeToCoinBalanceUpdatesIfNeeded() }.onFailure { error -> Timber.e(error) @@ -464,6 +488,47 @@ internal class SwapModel @Inject constructor( } } + private fun applyAddedToken(addedToken: CryptoCurrencyStatus, addedAccount: Account.CryptoPortfolio?) { + modelScope.launch { + runCatching(dispatchers.io) { + swapInteractor.getTokensDataState(initialCurrencyFrom) + }.onSuccess { state -> + updateTokensState(state) + + applyInitialTokenChoice( + state = state, + selectedCurrency = addedToken, + selectedAccount = addedAccount, + isReverseFromTo = isOrderReversed, + ) + + subscribeToCoinBalanceUpdatesIfNeeded() + + swapRouter.back() + }.onFailure { error -> + Timber.e(error) + } + } + } + + private fun subscribeToCoinBalanceUpdatesIfNeeded() { + (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> + subscribeToCoinBalanceUpdates( + userWalletId = userWalletId, + coin = coin, + isFromCurrency = true, + ) + } + + (dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> + subscribeToCoinBalanceUpdates( + userWalletId = userWalletId, + coin = coin, + isFromCurrency = false, + ) + } + } + private fun initStories() { modelScope.launch { getStoryContentUseCase.invokeSync(StoryContentIds.STORY_FIRST_TIME_SWAP.id).fold( @@ -1933,6 +1998,42 @@ internal class SwapModel @Inject constructor( } } + private fun addToPortfolioItem(item: MarketsListItemUM) { + modelScope.launch { + val tokenInfo = getTokenMarketInfoUseCase( + selectedAppCurrencyFlow.value, + item.id, + item.currencySymbol, + ).getOrNull() ?: return@launch + + val converter = TokenMarketInfoToParamsConverter() + val param = converter.convert(tokenInfo) + val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } + + val networks = tokenInfo.networks?.filter { network -> + BlockchainUtils.isSupportedNetworkId( + blockchainId = network.networkId, + excludedBlockchains = excludedBlockchains, + hotExcludedBlockchains = hotWalletExcludedBlockchains, + hasOnlyHotWallets = hasOnlyHotWallets, + ) + }.orEmpty() + + addToPortfolioManager = addToPortfolioManagerFactory + .create( + scope = modelScope, + token = param, + analyticsParams = null, + ).apply { + setTokenNetworks(networks) + } + + addToPortfolioManager?.state + ?.firstOrNull { it is AddToPortfolioManager.State.AvailableToAdd } + ?.run { bottomSheetNavigation.activate(AddToPortfolioRoute) } + }.saveIn(addToPortfolioJobHolder) + } + private fun CryptoCurrency.getNetworkInfo(): NetworkInfo { return NetworkInfo( name = this.network.name, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt new file mode 100644 index 0000000000..afebcd52cf --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt @@ -0,0 +1,7 @@ +package com.tangem.feature.swap.models + +import com.tangem.core.decompose.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +internal data object AddToPortfolioRoute : Route \ No newline at end of file From fc5fbfca7f59124ead9492f9827d99f3c5059f62 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 20 Jan 2026 15:47:35 +0200 Subject: [PATCH 148/172] Updated on 2026-08-14 --- .../impl/presentation/model/StakingModel.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index f4d1fdfd3e..a70dfc89f5 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -203,17 +203,19 @@ internal class StakingModel @Inject constructor( private val balancesToShow: List get() { val stakingBalance = cryptoCurrencyStatus.value.stakingBalance - return when (stakingBalance) { - is StakingBalance.Data.StakeKit -> { - val invalidatedItems = invalidatePendingTransactionsUseCase( - balanceItems = stakingBalance.balance.items, + return when (integration) { + is StakeKitIntegration -> { + val balanceItems = (stakingBalance as? StakingBalance.Data.StakeKit) + ?.balance?.items.orEmpty() + invalidatePendingTransactionsUseCase( + balanceItems = balanceItems, stakingActions = stakingActions, token = integration.token, - ).getOrElse { emptyList() } - invalidatedItems.toStakingBalanceEntries() + ).getOrElse { emptyList() }.toStakingBalanceEntries() + } + is P2PEthPoolIntegration -> { + (stakingBalance as? StakingBalance.Data.P2PEthPool)?.entries.orEmpty() } - is StakingBalance.Data.P2PEthPool -> stakingBalance.entries - else -> emptyList() } } From e8c4e3a4a7fc6e9a7f0ffaae8a1e34a969f280f2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 20 Jan 2026 15:03:30 +0100 Subject: [PATCH 149/172] Updated on 2026-08-14 --- .../tangem/features/feed/ui/EntryContent.kt | 11 +-- .../feed/ui/market/list/MarketsList.kt | 2 +- .../utils/EntryContentAnimationTransitions.kt | 71 +++++++++++++++++++ 3 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt index 0aaf292083..a8efdab5d9 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt @@ -6,19 +6,20 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.State +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp 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.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.utils.WindowInsetsZero import com.tangem.features.feed.components.FeedEntryChildFactory +import com.tangem.features.feed.ui.utils.contentFeedEntryStackAnimation +import com.tangem.features.feed.ui.utils.topBarFeedEntryStackAnimation @Composable internal fun EntryContent( @@ -29,6 +30,8 @@ internal fun EntryContent( ) { val density = LocalDensity.current val background = LocalMainBottomSheetColor.current.value + val animationContent = remember { contentFeedEntryStackAnimation() } + val animationAppBar = remember { topBarFeedEntryStackAnimation() } Surface(contentColor = background) { Scaffold( @@ -52,7 +55,7 @@ internal fun EntryContent( } }, stack = stackState.value, - animation = stackAnimation(fade()), + animation = animationAppBar, ) { child -> child.instance.Title(bottomSheetState) } @@ -60,7 +63,7 @@ internal fun EntryContent( content = { contentPadding -> Children( stack = stackState.value, - animation = stackAnimation(fade()), + animation = animationContent, ) { child -> child.instance.Content( modifier = Modifier.padding(contentPadding), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt index 368afe2910..32e675caff 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt @@ -48,7 +48,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay private const val SHOW_MORE_KEY = "privacyPolicy" -private const val DELAY_FOR_FOCUS_REQUEST = 200L +private const val DELAY_FOR_FOCUS_REQUEST = 500L @Composable internal fun TopBarWithSearch( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt new file mode 100644 index 0000000000..638e28ca11 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt @@ -0,0 +1,71 @@ +package com.tangem.features.feed.ui.utils + +import androidx.compose.animation.core.tween +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.FaultyDecomposeApi +import com.arkivanov.decompose.extensions.compose.stack.animation.* +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.core.ui.utils.toPx +import com.tangem.features.feed.components.FeedEntryChildFactory + +private const val DELAY_FOR_TRANSITION = 400 + +@OptIn(FaultyDecomposeApi::class) +internal fun topBarFeedEntryStackAnimation(): StackAnimation< + FeedEntryChildFactory.Child, + ComposableModularBottomSheetContentComponent, + > = + stackAnimation { to, from, _ -> + val isSearchToTokenList = + (to.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true + val isFromSearchTokenList = + (from.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true + if (isSearchToTokenList || isFromSearchTokenList) { + fade() + } else { + slide() + } + } + +@OptIn(FaultyDecomposeApi::class) +internal fun contentFeedEntryStackAnimation(): StackAnimation< + FeedEntryChildFactory.Child, + ComposableModularBottomSheetContentComponent, + > = + stackAnimation { to, from, _ -> + val isSearchToTokenList = + (to.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true + val isFromSearchTokenList = + (from.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true + if (isSearchToTokenList || isFromSearchTokenList) { + fadeAndSlideInt() + } else { + slide() + } + } + +private fun fadeAndSlideInt(): StackAnimator = + stackAnimator(animationSpec = tween(DELAY_FOR_TRANSITION)) { factor, _, content -> + val alpha = 1f - kotlin.math.abs(factor) + val slidePx = 32.dp.toPx() + val yOffset = when { + factor > 0 -> (slidePx * factor).toInt() + factor < 0 -> (slidePx * factor * -1).toInt() + else -> 0 + } + content( + Modifier + .alpha(alpha) + .offsetY(yOffset), + ) + } + +private fun Modifier.offsetY(pixels: Int): Modifier = layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative(x = 0, y = pixels) + } +} \ No newline at end of file From c66b64f96cd2a80b9b496205e39a11f287599d74 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 20 Jan 2026 16:52:56 +0200 Subject: [PATCH 150/172] Updated on 2026-08-14 --- .../datasource/api/ethpool/P2PEthPoolApi.kt | 19 ------------ .../response/P2PEthPoolRewardsResponse.kt | 31 ------------------- .../staking/DefaultP2PEthPoolRepository.kt | 23 -------------- .../ethpool/P2PEthPoolRewardConverter.kt | 20 ------------ .../staking/model/ethpool/P2PEthPoolReward.kt | 15 --------- .../repositories/P2PEthPoolRepository.kt | 17 ---------- .../operations/CryptoCurrencyStatusFactory.kt | 1 - 7 files changed, 126 deletions(-) delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolRewardsResponse.kt delete mode 100644 data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt delete mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt index 99de156115..f76e0a7bfa 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 @@ -94,23 +94,4 @@ interface P2PEthPoolApi { @Path("delegatorAddress") delegatorAddress: String, @Path("vaultAddress") vaultAddress: String, ): ApiResponse> - - /** - * Get rewards history - * - * Retrieve historical rewards data for a specific account and vault. - * - * @param network Ethereum pool network: "mainnet" or "hoodi" - * @param delegatorAddress Account address that initiated staking - * @param vaultAddress Ethereum address of the vault - * @param period Optional period filter (30, 60, or 90 days) - */ - // TODO p2p not used, consider removing this method - @GET("api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}/rewards") - suspend fun getRewards( - @Path("network") network: String, - @Path("delegatorAddress") delegatorAddress: String, - @Path("vaultAddress") vaultAddress: String, - @Query("period") period: Int? = null, - ): ApiResponse> } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolRewardsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolRewardsResponse.kt deleted file mode 100644 index ef4dd03ff0..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolRewardsResponse.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.tangem.datasource.api.ethpool.models.response - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import org.joda.time.DateTime -import java.math.BigDecimal - -/** - * Response for GET /api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}/rewards - */ -@JsonClass(generateAdapter = true) -data class P2PEthPoolRewardsResponse( - @Json(name = "delegatorAddress") - val delegatorAddress: String, - @Json(name = "vaultAddress") - val vaultAddress: String, - @Json(name = "rewards") - val rewards: List, -) - -@JsonClass(generateAdapter = true) -data class P2PEthPoolRewardDTO( - @Json(name = "date") - val date: DateTime, - @Json(name = "apy") - val apy: Double, - @Json(name = "balance") - val balance: BigDecimal, - @Json(name = "rewards") - val rewards: BigDecimal, -) \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt index 4ffe515037..8752d83b0e 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -7,7 +7,6 @@ import arrow.core.raise.either import arrow.core.raise.ensure import com.tangem.data.staking.converters.ethpool.P2PEthPoolBroadcastResultConverter import com.tangem.data.staking.converters.ethpool.P2PEthPoolErrorConverter -import com.tangem.data.staking.converters.ethpool.P2PEthPoolRewardConverter import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingAccountConverter import com.tangem.data.staking.converters.ethpool.P2PEthPoolUnsignedTxConverter import com.tangem.data.staking.converters.ethpool.P2PEthPoolVaultConverter @@ -23,7 +22,6 @@ import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingOption import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork -import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import com.tangem.domain.staking.model.stakekit.StakingError @@ -48,7 +46,6 @@ internal class DefaultP2PEthPoolRepository( private val vaultConverter = P2PEthPoolVaultConverter private val accountConverter = P2PEthPoolStakingAccountConverter - private val rewardConverter = P2PEthPoolRewardConverter private val broadcastResultConverter = P2PEthPoolBroadcastResultConverter private val errorConverter = P2PEthPoolErrorConverter @@ -181,26 +178,6 @@ internal class DefaultP2PEthPoolRepository( } } - override suspend fun getRewards( - network: P2PEthPoolNetwork, - delegatorAddress: String, - vaultAddress: String, - period: Int?, - ): Either> = either { - withContext(dispatchers.io) { - handleApiResponse( - p2pEthPoolApi.getRewards( - network = network.value, - delegatorAddress = delegatorAddress, - vaultAddress = vaultAddress, - period = period, - ), - ) { result -> - result.rewards.map { rewardConverter.convert(it) } - } - } - } - override fun getVaultsFlow(): Flow> { return p2pEthPoolVaultsStore.get() } diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt deleted file mode 100644 index 0efd436ce7..0000000000 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.data.staking.converters.ethpool - -import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolRewardDTO -import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward -import com.tangem.utils.converter.Converter - -/** - * Converter from P2PEthPool Reward Entry DTO to Domain model - */ -internal object P2PEthPoolRewardConverter : Converter { - - override fun convert(value: P2PEthPoolRewardDTO): P2PEthPoolReward { - return P2PEthPoolReward( - date = value.date, - apy = value.apy.toBigDecimal(), - balance = value.balance, - rewards = value.rewards, - ) - } -} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt deleted file mode 100644 index b4672d3f90..0000000000 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.domain.staking.model.ethpool - -import com.tangem.domain.models.serialization.SerializedBigDecimal -import org.joda.time.DateTime - -/** - * Rewards history entry - * Historical reward information for account - */ -data class P2PEthPoolReward( - val date: DateTime, - val apy: SerializedBigDecimal, - val balance: SerializedBigDecimal, - val rewards: SerializedBigDecimal, -) \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt index 7b4e4568e2..f0f34da571 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt @@ -5,7 +5,6 @@ import com.tangem.domain.models.staking.P2PEthPoolStakingAccount import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork -import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig @@ -112,22 +111,6 @@ interface P2PEthPoolRepository { vaultAddress: String, ): Either - /** - * Get rewards history for account and vault - * - * @param network P2PEthPool network (MAINNET or TESTNET) - * @param delegatorAddress User's wallet address - * @param vaultAddress Vault contract address - * @param period Optional period filter in days (30, 60, or 90) - * @return Either error or list of reward entries - */ - suspend fun getRewards( - network: P2PEthPoolNetwork, - delegatorAddress: String, - vaultAddress: String, - period: Int? = null, - ): Either> - /** * Get flow of cached vaults. * diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactory.kt index d58781bda3..a229a4fd7c 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactory.kt @@ -268,7 +268,6 @@ object CryptoCurrencyStatusFactory { } } is StakingBalance.Data.P2PEthPool -> { - // TODO p2p val isCurrentAddressStaking = stakingBalance.stakingId.address == address.defaultAddress.value if (isCurrentAddressStaking) stakingBalance else null } From c4b3de5e8583dcca4f5e1580896152836215ca9a Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 18 Jan 2026 18:58:54 +0400 Subject: [PATCH 151/172] Updated on 2026-08-14 --- .../tangempay/TangemPayAnalyticsEvents.kt | 124 ++++++++++++++++++ .../TangemPayCardDetailsBlockStateFactory.kt | 3 +- .../entity/TangemPayDetailsStateFactory.kt | 2 + .../entity/TangemPayDetailsTopBarConfig.kt | 1 + .../tangempay/entity/TangemPayDetailsUM.kt | 3 +- .../model/TangemPayCardDetailsBlockModel.kt | 17 ++- .../model/TangemPayChangePinModel.kt | 13 +- .../tangempay/model/TangemPayDetailsModel.kt | 24 ++++ .../model/TangemPayTxHistoryDetailsModel.kt | 5 + .../tangempay/model/TangemPayViewPinModel.kt | 4 + .../ui/TangemPayAddToWalletScreen.kt | 2 +- .../tangempay/ui/TangemPayCardDetailsBlock.kt | 15 ++- .../tangempay/ui/TangemPayDetailsScreen.kt | 15 ++- .../model/TangemPayOnboardingModel.kt | 1 + 14 files changed, 212 insertions(+), 17 deletions(-) diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt index 2f944349f5..e4b6af4d70 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt @@ -52,4 +52,128 @@ sealed class TangemPayAnalyticsEvents( categoryName = "Visa Screen", event = "Button - Visa Swap", ) + + class ChooseWalletPopup : TangemPayAnalyticsEvents( + categoryName = "Visa Onboarding", + event = "Choose Wallet Popup", + ) + + class CardSettingsClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Card Settings", + ) + + class TermsAndLimitsClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Terms And Limits", + ) + + class FreezeCardClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Freeze Card", + ) + + class FreezeCardConfirmShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Popup - Freeze Confirmation", + ) + + class FreezeCardConfirmClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Freeze Confirmation On Popup", + ) + + class UnfreezeCardClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Unfreeze Card", + ) + + class UnfreezeCardConfirmShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Popup - Unfreeze Confirmation", + ) + + class UnfreezeCardConfirmClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Unfreeze Confirmation On Popup", + ) + + class PinCodeClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - PIN Code", + ) + + class ChangePinScreenShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Screen - Change PIN", + ) + + class ChangePinSubmitClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Set PIN On Change PIN Screen", + ) + + class ChangePinSuccessShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Message - PIN Setup Success", + ) + + class CurrentPinShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Popup - Current PIN", + ) + + class ChangePinOnCurrentPinClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Change PIN On Current PIN", + ) + + class ViewCardDetailsClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - View Card Details", + ) + + class CopyCardNumberClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Copy Card Number", + ) + + class CopyCardExpiryClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Copy Card Expiry", + ) + + class CopyCardCVVClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Copy CVV", + ) + + class AddToWalletClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Add Card To Wallet", + ) + + class WithdrawClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Visa Withdraw", + ) + + class GoToSupportOnBetaBannerClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Go To Support On Beta Banner", + ) + + class TransactionInListClicked(type: String, status: String) : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Clicked On Transaction In List", + params = mapOf( + "type" to type, + "status" to status, + ), + ) + + class SupportOnTransactionPopupClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Support On Transaction Popup", + ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt index 7904ea72d9..e79a05f253 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt @@ -3,12 +3,13 @@ package com.tangem.features.tangempay.entity import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.model.CardDataType import com.tangem.utils.StringsSigns internal class TangemPayCardDetailsBlockStateFactory( private val cardNumberEnd: String, private val onReveal: () -> Unit, - private val onCopy: (String) -> Unit, + private val onCopy: (String, CardDataType) -> Unit, ) { fun getInitialState() = TangemPayCardDetailsUM( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index 55b429059a..8fb38c33bc 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -18,6 +18,7 @@ import kotlinx.collections.immutable.toPersistentList @Suppress("LongParameterList") internal class TangemPayDetailsStateFactory( private val onBack: () -> Unit, + private val onOpenMenu: () -> Unit, private val intents: TangemPayDetailIntents, private val cardFrozenState: TangemPayCardFrozenState, private val converter: TangemPayCardFrozenStateConverter, @@ -47,6 +48,7 @@ internal class TangemPayDetailsStateFactory( return TangemPayDetailsUM( topBarConfig = TangemPayDetailsTopBarConfig( onBackClick = onBack, + onOpenMenu = onOpenMenu, items = listOfNotNull( TangemPayDetailsTopBarMenuItem( type = TangemPayDetailsTopBarMenuItemType.ChangePin, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt index 1010496a66..bd4daa10c9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt @@ -5,6 +5,7 @@ import kotlinx.collections.immutable.ImmutableList internal data class TangemPayDetailsTopBarConfig( val onBackClick: () -> Unit, + val onOpenMenu: () -> Unit, val items: ImmutableList?, ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 37663aa1f0..f67ed6b7b0 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -5,6 +5,7 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.model.CardDataType import kotlinx.collections.immutable.ImmutableList internal data class TangemPayDetailsUM( @@ -25,7 +26,7 @@ internal data class TangemPayCardDetailsUM( val cvv: String, val buttonText: TextReference = TextReference.EMPTY, val onClick: () -> Unit = {}, - val onCopy: (String) -> Unit = {}, + val onCopy: (String, CardDataType) -> Unit, val isHidden: Boolean = true, val isLoading: Boolean = false, val cardFrozenState: TangemPayCardFrozenState, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt index 5c83a87360..fcee864f89 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -9,6 +10,7 @@ import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayCardDetailsBlockStateFactory @@ -29,6 +31,7 @@ import com.tangem.utils.transformer.update as transformerUpdate private const val SHOW_DETAILS_TIME = 30_000L +@Suppress("LongParameterList") @Stable @ModelScoped internal class TangemPayCardDetailsBlockModel @Inject constructor( @@ -38,6 +41,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( private val clipboardManager: ClipboardManager, private val uiMessageSender: UiMessageSender, private val cardDetailsEventListener: CardDetailsEventListener, + private val analytics: AnalyticsEventHandler, ) : Model() { private val params: TangemPayCardDetailsBlockComponent.Params = paramsContainer.require() @@ -74,6 +78,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( } private fun revealCardDetails() { + analytics.send(TangemPayAnalyticsEvents.ViewCardDetailsClicked()) modelScope.launch { uiState.transformerUpdate( transformer = DetailsRevealProgressStateTransformer(onClickHide = ::hideCardDetails), @@ -115,7 +120,17 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( ) } - private fun copyData(text: String) { + private fun copyData(text: String, type: CardDataType) { + val event = when (type) { + CardDataType.Number -> TangemPayAnalyticsEvents.CopyCardNumberClicked() + CardDataType.Expiry -> TangemPayAnalyticsEvents.CopyCardExpiryClicked() + CardDataType.CVV -> TangemPayAnalyticsEvents.CopyCardCVVClicked() + } + analytics.send(event) clipboardManager.setText(text = text.filterNot { it.isWhitespace() }, isSensitive = true) } +} + +internal enum class CardDataType { + Number, Expiry, CVV } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt index 6d9f9d47f2..634b5a0e69 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -10,6 +11,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayChangePinUM @@ -30,17 +32,23 @@ internal class TangemPayChangePinModel @Inject constructor( private val uiMessageSender: UiMessageSender, private val router: Router, private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val analytics: AnalyticsEventHandler, ) : Model() { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() val uiState: StateFlow field = MutableStateFlow(getInitialState()) + init { + analytics.send(TangemPayAnalyticsEvents.ChangePinScreenShown()) + } + private fun onPinCodeChange(pin: String) { uiState.update(transformer = PinCodeChangeTransformer(newPin = pin)) } private fun onClickSubmit() { + analytics.send(TangemPayAnalyticsEvents.ChangePinSubmitClicked()) modelScope.launch { uiState.update { it.copy(submitButtonLoading = true) } val result = try { @@ -59,7 +67,10 @@ internal class TangemPayChangePinModel @Inject constructor( message = ToastMessage(resourceReference(R.string.tangempay_pin_validation_error_message)), ) } - SetPinResult.SUCCESS -> router.push(TangemPayDetailsInnerRoute.ChangePINSuccess) + SetPinResult.SUCCESS -> { + analytics.send(TangemPayAnalyticsEvents.ChangePinSuccessShown()) + router.push(TangemPayDetailsInnerRoute.ChangePINSuccess) + } SetPinResult.DECRYPTION_ERROR, SetPinResult.UNKNOWN_ERROR, null, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index bbdffbda81..d31e72f0a4 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -90,6 +90,7 @@ internal class TangemPayDetailsModel @Inject constructor( private val cardFrozenStateConverter = TangemPayCardFrozenStateConverter(onUnfreezeClick = ::onClickUnfreezeCard) private val stateFactory = TangemPayDetailsStateFactory( onBack = router::pop, + onOpenMenu = ::onOpenMenu, intents = this, cardFrozenState = params.config.cardFrozenState, converter = cardFrozenStateConverter, @@ -148,6 +149,7 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onClickPinCode() { + analytics.send(TangemPayAnalyticsEvents.PinCodeClicked()) if (!params.config.isPinSet) { router.push(TangemPayDetailsInnerRoute.ChangePIN) } else { @@ -161,14 +163,19 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onClickFreezeCard() { + analytics.send(TangemPayAnalyticsEvents.FreezeCardClicked()) uiMessageSender.send(TangemPayMessagesFactory.createFreezeCardMessage(onFreezeClicked = ::freezeCard)) + analytics.send(TangemPayAnalyticsEvents.FreezeCardConfirmShown()) } override fun onClickUnfreezeCard() { + analytics.send(TangemPayAnalyticsEvents.UnfreezeCardClicked()) uiMessageSender.send(TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard)) + analytics.send(TangemPayAnalyticsEvents.UnfreezeCardConfirmShown()) } private fun freezeCard() { + analytics.send(TangemPayAnalyticsEvents.FreezeCardConfirmClicked()) modelScope.launch { val result = try { cardDetailsRepository.freezeCard(userWalletId = params.userWalletId, cardId = params.config.cardId) @@ -207,6 +214,7 @@ internal class TangemPayDetailsModel @Inject constructor( } private fun unfreezeCard() { + analytics.send(TangemPayAnalyticsEvents.UnfreezeCardConfirmClicked()) modelScope.launch { val result = try { cardDetailsRepository.unfreezeCard(userWalletId = params.userWalletId, cardId = params.config.cardId) @@ -264,6 +272,7 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onClickWithdraw() { + analytics.send(TangemPayAnalyticsEvents.WithdrawClicked()) val currentBalance = balance val depositAddress = currentBalance?.depositAddress if (currentBalance == null || depositAddress == null) { @@ -345,6 +354,7 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onContactSupportClicked() { + analytics.send(TangemPayAnalyticsEvents.GoToSupportOnBetaBannerClicked()) modelScope.launch { sendFeedbackEmailUseCase.invoke( type = FeedbackEmailType.Visa.FeatureIsBeta( @@ -366,6 +376,7 @@ internal class TangemPayDetailsModel @Inject constructor( } private fun onClickAddToWalletBlock() { + analytics.send(TangemPayAnalyticsEvents.AddToWalletClicked()) router.push(TangemPayDetailsInnerRoute.AddToWallet) } @@ -386,6 +397,10 @@ internal class TangemPayDetailsModel @Inject constructor( }.saveIn(addToWalletBannerJobHolder) } + private fun onOpenMenu() { + analytics.send(TangemPayAnalyticsEvents.CardSettingsClicked()) + } + override fun onClickSwap(data: TangemPayTopUpData) { analytics.send(TangemPayAnalyticsEvents.SwapClicked()) bottomSheetNavigation.dismiss() @@ -424,6 +439,7 @@ internal class TangemPayDetailsModel @Inject constructor( override fun onClickChangePin() { bottomSheetNavigation.dismiss() + analytics.send(TangemPayAnalyticsEvents.ChangePinOnCurrentPinClicked()) router.push(TangemPayDetailsInnerRoute.ChangePIN) } @@ -432,6 +448,13 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onTransactionClick(item: TangemPayTxHistoryItem) { + val (type, status) = when (item) { + is TangemPayTxHistoryItem.Collateral -> "collateral" to "unknown" + is TangemPayTxHistoryItem.Fee -> "fee" to "unknown" + is TangemPayTxHistoryItem.Payment -> "payment" to "unknown" + is TangemPayTxHistoryItem.Spend -> "spend" to item.status.name.lowercase() + } + analytics.send(TangemPayAnalyticsEvents.TransactionInListClicked(type = type, status = status)) bottomSheetNavigation.activate( configuration = TangemPayDetailsNavigation.TransactionDetails( transaction = item, @@ -441,6 +464,7 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onClickTermsAndLimits() { + analytics.send(TangemPayAnalyticsEvents.TermsAndLimitsClicked()) urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt index 8dae2f4f77..1d6f1f82fa 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -9,6 +10,7 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent import com.tangem.features.tangempay.entity.TangemPayTxHistoryDetailsUM import com.tangem.features.tangempay.model.transformers.TangemPayTxHistoryDetailsConverter @@ -17,6 +19,7 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @Stable @ModelScoped internal class TangemPayTxHistoryDetailsModel @Inject constructor( @@ -25,6 +28,7 @@ internal class TangemPayTxHistoryDetailsModel @Inject constructor( private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val urlOpener: UrlOpener, private val balanceHidingSettings: GetBalanceHidingSettingsUseCase, + private val analytics: AnalyticsEventHandler, paramsContainer: ParamsContainer, ) : Model() { @@ -61,6 +65,7 @@ internal class TangemPayTxHistoryDetailsModel @Inject constructor( } private fun dispute() { + analytics.send(TangemPayAnalyticsEvents.SupportOnTransactionPopupClicked()) modelScope.launch { val walletMetaInfo = getWalletMetaInfoUseCase.invoke(params.userWalletId).getOrNull() ?: return@launch diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayViewPinModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayViewPinModel.kt index 14631dc022..a610d66541 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayViewPinModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayViewPinModel.kt @@ -1,10 +1,12 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.TangemPayViewPinComponent import com.tangem.features.tangempay.entity.TangemPayViewPinUM import com.tangem.features.tangempay.model.transformers.TangemPayViewPinErrorStateTransformer @@ -22,6 +24,7 @@ internal class TangemPayViewPinModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val analytics: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -30,6 +33,7 @@ internal class TangemPayViewPinModel @Inject constructor( field = MutableStateFlow(getInitialState()) init { + analytics.send(TangemPayAnalyticsEvents.CurrentPinShown()) getCardPin() } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt index eb2a5831d4..7fd6c66c61 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt @@ -187,7 +187,7 @@ private fun PreviewTangemPayAddToWalletScreen() { numberShort = "*1245", expiry = "••/••", cvv = "•••", - onCopy = {}, + onCopy = { _, _ -> }, onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index 403e3fe778..33e91f7745 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -33,6 +33,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.model.CardDataType private val CustomCardBlockColor = Color(0x1F828282) @@ -79,9 +80,9 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M cardNumber = state.number, expiry = state.expiry, cvv = state.cvv, - onCopyCardNumber = { state.onCopy(state.number) }, - onCopyCvv = { state.onCopy(state.cvv) }, - onCopyExpiry = { state.onCopy(state.expiry) }, + onCopyCardNumber = { state.onCopy(state.number, CardDataType.Number) }, + onCopyCvv = { state.onCopy(state.cvv, CardDataType.CVV) }, + onCopyExpiry = { state.onCopy(state.expiry, CardDataType.Expiry) }, onHideDetails = state.onClick, modifier = Modifier.graphicsLayer { rotationY = 180f }, ) @@ -309,7 +310,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide expiry = "", cvv = "", buttonText = resourceReference(R.string.tangempay_card_details_show_details), - onCopy = {}, + onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Frozen, ), @@ -321,7 +322,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide expiry = "", cvv = "", buttonText = resourceReference(R.string.tangempay_card_details_show_details), - onCopy = {}, + onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Unfrozen, ), @@ -333,7 +334,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide expiry = "", cvv = "", buttonText = resourceReference(R.string.tangempay_card_details_show_details), - onCopy = {}, + onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Pending, ), @@ -341,7 +342,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide isLoading = false, onClick = {}, buttonText = resourceReference(R.string.tangempay_card_details_hide_details), - onCopy = {}, + onCopy = { _, _ -> }, isHidden = false, number = "1234 5678 9012 3456", numberShort = "*3456", diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 91bd0656aa..da34d0e786 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -254,7 +254,12 @@ private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modi title = {}, actions = { AnimatedVisibility(visible = config.items != null && config.items.isNotEmpty()) { - IconButton(onClick = { showDropdownMenu = true }) { + IconButton( + onClick = { + config.onOpenMenu() + showDropdownMenu = true + }, + ) { Icon( painter = painterResource(id = R.drawable.ic_more_vertical_24), tint = TangemTheme.colors.icon.primary1, @@ -305,7 +310,7 @@ private fun TangemPayDetailsScreenPreview( numberShort = "*1245", expiry = "••/••", cvv = "•••", - onCopy = {}, + onCopy = { _, _ -> }, onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, @@ -319,7 +324,7 @@ private fun TangemPayDetailsScreenPreview( private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( collection = listOf( TangemPayDetailsUM( - topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, items = null), + topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, onOpenMenu = {}, items = null), pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), balanceBlockState = TangemPayDetailsBalanceBlockState.Content( actionButtons = persistentListOf( @@ -348,7 +353,7 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider }, onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt index 1c484af706..a3dc4ea532 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt @@ -151,6 +151,7 @@ internal class TangemPayOnboardingModel @Inject constructor( if (eligibleWalletsIds.size == 1) { checkCustomerInfo(userWalletId = eligibleWalletsIds[0]) } else { + analytics.send(TangemPayAnalyticsEvents.ChooseWalletPopup()) bottomSheetNavigation.activate(TangemPayOnboardingNavigation.WalletSelector(eligibleWalletsIds)) } } From 73ca4410e12eb3e0d50a02385947dab496aa0975 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 20 Jan 2026 15:59:31 +0400 Subject: [PATCH 152/172] Updated on 2026-08-14 --- .../tangem/data/pay/DefaultTangemPayEligibilityManager.kt | 3 ++- .../com/tangem/domain/pay/TangemPayEligibilityManager.kt | 1 + .../main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt | 7 ++++++- .../child/wallet/model/intents/TangemPayClickIntents.kt | 1 + 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt index 9c27024b35..1f2acd1d9b 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt @@ -44,6 +44,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( override suspend fun getTangemPayAvailability(): Boolean { return onboardingRepository.checkCustomerEligibility() + .also { isEligible -> if (!isEligible) reset() } } private suspend fun getUserWalletsData(): List { @@ -120,7 +121,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( } } - private fun reset() { + override fun reset() { cachedEligibleWallets = null eligibleWalletsDeferred?.cancel() eligibleWalletsDeferred = null diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayEligibilityManager.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayEligibilityManager.kt index cc26e4832a..fcb41b6984 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayEligibilityManager.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayEligibilityManager.kt @@ -6,4 +6,5 @@ interface TangemPayEligibilityManager { suspend fun getEligibleWallets(shouldExcludePaeraCustomers: Boolean): List suspend fun getTangemPayAvailability(): Boolean + fun reset() } \ No newline at end of file diff --git a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt index ba0446ea8a..f729fe489c 100644 --- a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt +++ b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt @@ -6,6 +6,7 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.pay.KycStartInfo +import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.repository.KycRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -22,6 +23,7 @@ class DefaultKycModel @Inject constructor( analytics: AnalyticsEventHandler, override val dispatchers: CoroutineDispatcherProvider, private val kycRepository: KycRepository, + private val tangemPayEligibilityManager: TangemPayEligibilityManager, ) : Model() { private val params: KycComponent.Params = paramsContainer.require() @@ -33,7 +35,10 @@ class DefaultKycModel @Inject constructor( analytics.send(TangemPayAnalyticsEvents.KycFlowOpened()) modelScope.launch { try { - kycRepository.getKycStartInfo(params.userWalletId).getOrNull()?.let { _uiState.emit(it) } + kycRepository.getKycStartInfo(params.userWalletId).getOrNull()?.let { state -> + _uiState.emit(state) + tangemPayEligibilityManager.reset() + } } catch (e: Exception) { Timber.e(e) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt index f157783abb..18c1ae190b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt @@ -245,6 +245,7 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( if (isEligible) { router.openTangemPayOnboarding(mode = AppRoute.TangemPayOnboarding.Mode.FromBannerOnMain(userWalletId)) } else { + stateHolder.update(transformer = TangemPayHideOnboardingStateTransformer(userWalletId)) tangemPayMainScreenCustomerInfoUseCase.fetch(userWalletId) } } From ab22a6c1311f7f885568ccb061989ff7ecdbaff8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 20 Jan 2026 15:38:36 +0300 Subject: [PATCH 153/172] Updated on 2026-08-14 --- .../com/tangem/pagination/BatchFetchResult.kt | 2 + .../com/tangem/pagination/BatchListSource.kt | 4 +- .../com/tangem/pagination/BatchListState.kt | 2 + .../markets/DefaultMarketsTokenRepository.kt | 1 + .../UpdateAccountTokenListTransformer.kt | 38 +++--- .../swap/converters/TokensDataConverterV2.kt | 44 ++++--- .../tangem/feature/swap/model/SwapModel.kt | 11 +- .../swap/models/SwapSelectTokenStateHolder.kt | 8 ++ .../market/SwapMarketsListBatchFlowManager.kt | 9 ++ .../models/market/state/SwapMarketState.kt | 1 + .../feature/swap/ui/SwapSelectTokenScreen.kt | 123 +++++++++++++----- .../ui/market/SwapMarketsListLazyColumn.kt | 13 +- .../preview/SwapSelectTokenPreviewProvider.kt | 2 + 13 files changed, 191 insertions(+), 67 deletions(-) diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt index f7b78b226f..eb7a80bd00 100644 --- a/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt @@ -14,11 +14,13 @@ sealed class BatchFetchResult { * @param data fetched data. * @param empty indicates that data is empty and [BatchListSource] shouldn't create new batch for this result * @param last indicates if this is the last batch for the request. + * @param total total number of items available on the server. */ data class Success( val data: TData, val empty: Boolean, val last: Boolean, + val total: Int? = null, ) : BatchFetchResult() /** diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt index dab020caa2..0e70379d40 100644 --- a/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt @@ -69,7 +69,7 @@ private class DefaultBatchListSource private val updateFetcher: BatchUpdateFetcher? = null, ) : BatchListSource { - override val state = MutableStateFlow(BatchListState(emptyList(), PaginationStatus.None)) + override val state = MutableStateFlow(BatchListState(emptyList(), PaginationStatus.None, null)) override val updateResults = MutableSharedFlow>>( extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST, @@ -254,6 +254,7 @@ private class DefaultBatchListSource } else { PaginationStatus.Paginating(res) }, + totalCount = res.total, ) } is BatchFetchResult.Error -> { @@ -262,6 +263,7 @@ private class DefaultBatchListSource status = PaginationStatus.InitialLoadingError( throwable = res.throwable, ), + totalCount = null, ) } } diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchListState.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchListState.kt index b53ba23dc0..e752da816b 100644 --- a/core/pagination/src/main/java/com/tangem/pagination/BatchListState.kt +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchListState.kt @@ -8,10 +8,12 @@ package com.tangem.pagination * * @property data list of loaded batches. * @property status current status of the pagination. + * @property totalCount total number of items available on the server. * * @see BatchListSource */ data class BatchListState( val data: List>, val status: PaginationStatus, + val totalCount: Int? = null, ) \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index d6df4d9333..9c1b3be3f1 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt @@ -98,6 +98,7 @@ internal class DefaultMarketsTokenRepository( data = tokenMarketListWithMaxApy.tokens, last = isLast, empty = res.tokens.isEmpty(), + total = res.total, ) } }, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt index a25fa8ec5c..51fe76f5af 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt @@ -48,22 +48,28 @@ internal class UpdateAccountTokenListTransformer( tokensList = accountListItemConverter.convertList(accountList).toPersistentList(), ) } else { - TokenListUMData.TokenList( - tokensList = persistentListOf( - TokensListItemUM.GroupTitle( - id = "available_tokens_title", - text = resourceReference(R.string.exchange_tokens_available_tokens_header), - ), - ) + accountList.flatMap { (_, currencyList) -> - currencyList.asSequence().map { (isAvailable, status) -> - if (isAvailable) { - availableConverter.convert(status) - } else { - unavailableConverter.convert(status) - } - }.map(TokensListItemUM::Token).toPersistentList() - }.toPersistentList(), - ) + val tokensList = accountList.flatMap { (_, currencyList) -> + currencyList.asSequence().map { (isAvailable, status) -> + if (isAvailable) { + availableConverter.convert(status) + } else { + unavailableConverter.convert(status) + } + }.map(TokensListItemUM::Token).toPersistentList() + }.toPersistentList() + + if (tokensList.isNotEmpty()) { + TokenListUMData.TokenList( + tokensList = persistentListOf( + TokensListItemUM.GroupTitle( + id = "available_tokens_title", + text = resourceReference(R.string.exchange_tokens_available_tokens_header), + ), + ) + tokensList, + ) + } else { + TokenListUMData.EmptyList + } }, isBalanceHidden = isBalanceHidden, warning = warning, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt index e7b9c5ee8c..af538747a6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt @@ -37,26 +37,36 @@ internal class TokensDataConverterV2( availableTokens = persistentListOf(), unavailableTokens = persistentListOf(), tokensListData = if (isAccountsMode) { + val portfolioList = accountListItemConverter.convertList(accountList).toPersistentList() + val totalTokensCount = portfolioList.sumOf { it.tokens.size } TokenListUMData.AccountList( - tokensList = accountListItemConverter.convertList(accountList).toPersistentList(), + tokensList = portfolioList, + totalTokensCount = totalTokensCount, ) } else { - TokenListUMData.TokenList( - tokensList = persistentListOf( - TokensListItemUM.GroupTitle( - id = "available_tokens_title", - text = resourceReference(R.string.exchange_tokens_available_tokens_header), - ), - ) + accountList.flatMap { (_, currencyList) -> - currencyList.asSequence().map { accountSwapCurrency -> - if (accountSwapCurrency.isAvailable) { - accountListItemConverter.createAvailableItemConverter() - } else { - accountListItemConverter.createUnavailableItemConverter() - }.convert(accountSwapCurrency.cryptoCurrencyStatus) - }.map(TokensListItemUM::Token).toPersistentList() - }.toPersistentList(), - ) + val tokensList = accountList.flatMap { (_, currencyList) -> + currencyList.asSequence().map { accountSwapCurrency -> + if (accountSwapCurrency.isAvailable) { + accountListItemConverter.createAvailableItemConverter() + } else { + accountListItemConverter.createUnavailableItemConverter() + }.convert(accountSwapCurrency.cryptoCurrencyStatus) + }.map(TokensListItemUM::Token).toPersistentList() + }.toPersistentList() + + if (tokensList.isNotEmpty()) { + TokenListUMData.TokenList( + tokensList = persistentListOf( + TokensListItemUM.GroupTitle( + id = "available_tokens_title", + text = resourceReference(R.string.exchange_tokens_available_tokens_header), + ), + ) + tokensList, + totalTokensCount = tokensList.size, + ) + } else { + TokenListUMData.EmptyList + } }, marketsState = currentMarketsState, onSearchEntered = onSearchEntered, 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 ce909de7e6..42b072c45d 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 @@ -261,6 +261,13 @@ internal class SwapModel @Inject constructor( override fun onSuccess(addedToken: CryptoCurrency) { modelScope.launch { bottomSheetNavigation.dismiss() + uiState.selectTokenState?.let { currentSelectState -> + uiState = uiState.copy( + selectTokenState = currentSelectState.copy( + marketsState = null, + ), + ) + } getAccountCurrencyStatusUseCase.invoke(userWalletId, addedToken) .firstOrNull { it.status.value is CryptoCurrencyStatus.Loaded @@ -341,7 +348,8 @@ internal class SwapModel @Inject constructor( flow2 = searchMarketsListManager.uiItems, flow3 = searchMarketsListManager.isInInitialLoadingErrorState, flow4 = searchMarketsListManager.isSearchNotFoundState, - ) { searchQuery, uiItems, isError, isSearchNotFound -> + flow5 = searchMarketsListManager.totalCount.filterNotNull(), + ) { searchQuery, uiItems, isError, isSearchNotFound, total -> when { searchQuery.isEmpty() -> { visibleMarketItemIds.value = emptyList() @@ -359,6 +367,7 @@ internal class SwapModel @Inject constructor( addToPortfolioItem(item) }, visibleIdsChanged = { visibleMarketItemIds.value = it }, + total = total, ) } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt index c6408b6467..96d1358d0a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt @@ -41,17 +41,25 @@ internal data class TokenBalanceData( internal sealed interface TokenListUMData { val tokensList: ImmutableList + val totalTokensCount: Int data class AccountList( override val tokensList: ImmutableList, + override val totalTokensCount: Int, ) : TokenListUMData data class TokenList( override val tokensList: ImmutableList, + override val totalTokensCount: Int, ) : TokenListUMData data object EmptyList : TokenListUMData { override val tokensList: ImmutableList = persistentListOf() + override val totalTokensCount: Int = EMPTY_TOKENS_COUNT + } + + private companion object { + const val EMPTY_TOKENS_COUNT = 0 } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt index 7070acfa61..3e7b9af39f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt @@ -78,6 +78,15 @@ internal class SwapMarketsListBatchFlowManager( initialValue = false, ) + val totalCount: StateFlow = batchFlow.state + .map { it.totalCount } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) + init { batchFlow.state .map { it.data } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt index 8a84fb4d8b..50e6d14a74 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt @@ -10,6 +10,7 @@ internal sealed class SwapMarketState { data class Content( val items: ImmutableList, + val total: Int, val loadMore: () -> Unit, val onItemClick: (MarketsListItemUM) -> Unit, val visibleIdsChanged: (List) -> Unit, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt index bd038744a8..2bc3cc5c61 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt @@ -23,8 +23,11 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -73,6 +76,11 @@ internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () when { state.isNotFoundState -> TokensNotFound(modifier) state.isEmptyState -> EmptyTokensList(modifier) + state.marketsState != null -> ListOfTokensWithMarkets( + state = state, + marketsState = state.marketsState, + modifier = modifier, + ) else -> ListOfTokens(state = state, modifier = modifier) } }, @@ -142,6 +150,34 @@ private fun TokensNotFound(modifier: Modifier = Modifier) { @Composable private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) { val screenBackgroundColor = TangemTheme.colors.background.secondary + + LazyColumn( + modifier = modifier + .background(color = screenBackgroundColor) + .fillMaxSize() + .imePadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + tokensListItems( + tokensListData = state.tokensListData, + isBalanceHidden = state.isBalanceHidden, + ) + + tokensToSelectItems(state.availableTokens, state.onTokenSelected) + + item { SpacerH12() } + + tokensToSelectItems(state.unavailableTokens, state.onTokenSelected) + } +} + +@Composable +private fun ListOfTokensWithMarkets( + state: SwapSelectTokenStateHolder, + marketsState: SwapMarketState, + modifier: Modifier = Modifier, +) { + val screenBackgroundColor = TangemTheme.colors.background.secondary val lazyListState = rememberLazyListState() LazyColumn( @@ -152,54 +188,37 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = horizontalAlignment = Alignment.CenterHorizontally, state = lazyListState, ) { - when (val list = state.tokensListData) { - is TokenListUMData.AccountList -> { - list.tokensList.forEach { item -> - portfolioTokensList( - portfolio = item, - isBalanceHidden = state.isBalanceHidden, - ) - } - } - is TokenListUMData.TokenList -> { - tokensList( - items = list.tokensList, - isBalanceHidden = state.isBalanceHidden, - ) - } - TokenListUMData.EmptyList -> Unit + if (state.tokensListData !is TokenListUMData.EmptyList) { + assetsTitle(count = state.tokensListData.totalTokensCount) } - if (state.availableTokens.isNotEmpty()) { - tokensToSelectItems(state.availableTokens, state.onTokenSelected) - } - if (state.unavailableTokens.isNotEmpty()) { + tokensListItems( + tokensListData = state.tokensListData, + isBalanceHidden = state.isBalanceHidden, + ) + + if (state.tokensListData is TokenListUMData.EmptyList) { item { SpacerH12() } - tokensToSelectItems(state.unavailableTokens, state.onTokenSelected) - } - - if (state.availableTokens.isNotEmpty() || state.unavailableTokens.isNotEmpty()) { - item { SpacerH32() } } else { - item { SpacerH12() } + item { SpacerH32() } } - state.marketsState?.let(::swapMarketsListItems) + swapMarketsListItems(marketsState) } - (state.marketsState as? SwapMarketState.Content)?.let { marketState -> + (marketsState as? SwapMarketState.Content)?.let { content -> VisibleItemsTracker( lazyListState = lazyListState, - marketState = marketState, + marketState = content, ) InfiniteListHandler( listState = lazyListState, buffer = LOAD_MORE_BUFFER, triggerLoadMoreCheckOnItemsCountChange = true, - onLoadMore = remember(state) { + onLoadMore = remember(content) { { - state.marketsState.loadMore() + content.loadMore() true } }, @@ -223,6 +242,48 @@ private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapM } } +private fun LazyListScope.assetsTitle(count: Int) { + item(key = "assets_title") { + Text( + text = buildAnnotatedString { + append(stringResourceSafe(R.string.swap_your_assets_title)) + withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { + append(" $count") + } + }, + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .fillMaxWidth() + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing12, + ), + ) + } +} + +private fun LazyListScope.tokensListItems(tokensListData: TokenListUMData, isBalanceHidden: Boolean) { + when (tokensListData) { + is TokenListUMData.AccountList -> { + tokensListData.tokensList.forEach { item -> + portfolioTokensList( + portfolio = item, + isBalanceHidden = isBalanceHidden, + ) + } + } + is TokenListUMData.TokenList -> { + tokensList( + items = tokensListData.tokensList, + isBalanceHidden = isBalanceHidden, + ) + } + TokenListUMData.EmptyList -> Unit + } +} + private fun LazyListScope.tokensList(items: ImmutableList, isBalanceHidden: Boolean) { itemsIndexed( items = items, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt index 0e7fb5e9bb..0f69e31f8b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt @@ -7,6 +7,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import com.tangem.common.ui.markets.MarketsListItem import com.tangem.common.ui.markets.MarketsListItemPlaceholder import com.tangem.core.ui.components.UnableToLoadData @@ -18,8 +21,16 @@ import com.tangem.feature.swap.presentation.R internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) { item { + val totalCount = (state as? SwapMarketState.Content)?.total Text( - text = stringResourceSafe(R.string.markets_common_title), + text = buildAnnotatedString { + append(stringResourceSafe(R.string.markets_common_title)) + if (totalCount != null) { + withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { + append(" $totalCount") + } + } + }, style = TangemTheme.typography.h3, color = TangemTheme.colors.text.primary1, modifier = Modifier.fillMaxWidth().padding(horizontal = TangemTheme.dimens.spacing16), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt index a0c8c9a1b1..b9a6ab2d40 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt @@ -34,6 +34,7 @@ internal class SwapSelectTokenPreviewProvider { loadMore = { }, onItemClick = { }, visibleIdsChanged = { }, + total = TOTAL_ITEMS, ) private fun createPreviewMarketItems() = listOf( @@ -114,6 +115,7 @@ internal class SwapSelectTokenPreviewProvider { private const val CHART_VALUE_4 = 2.0 private const val CHART_VALUE_5 = 5.0 private const val CHART_VALUE_6 = 3.0 + private const val TOTAL_ITEMS = 322 private val PREVIEW_CHART_DATA = MarketChartRawData( y = persistentListOf( From c10183d06a883c8ea67e86e0c6b7535284360f34 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 21 Jan 2026 15:45:43 +0300 Subject: [PATCH 154/172] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 3 +- .../com/tangem/scenarios/DialogScenarios.kt | 13 +++++++ .../screens/DataNotLoadedDialogPageObject.kt | 36 +++++++++++++++++++ .../com/tangem/tests/OrganizeTokensTest.kt | 20 +++++------ .../MainScreenActionButtonsTest.kt | 6 ++-- .../TokenDetailsScreenActionButtonsTest.kt | 6 +--- .../tests/send/feeScreen/SendFeeScreenTest.kt | 2 +- 7 files changed, 67 insertions(+), 19 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/DataNotLoadedDialogPageObject.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index ea5a538da3..96c353f84c 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -142,7 +142,8 @@ abstract class BaseTestCase : TestCase( "SWAP_REDESIGN_ENABLED" to false, "NEW_ONRAMP_MAIN_ENABLED" to true, "HOT_WALLET_ENABLED" to true, - "YIELD_SUPPLY_FEATURE_ENABLED" to true + "YIELD_SUPPLY_FEATURE_ENABLED" to true, + "ACCOUNTS_FEATURE_ENABLED" to true ) ) } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt index bca0130819..57d70b597e 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt @@ -9,6 +9,7 @@ import com.tangem.screens.AlreadyUsedWalletDialogPageObject.thisIsMyWalletButton import com.tangem.screens.AlreadyUsedWalletDialogPageObject.title import com.tangem.screens.ScanWarningDialogPageObject import com.tangem.screens.onActionIsUnavailableDialog +import com.tangem.screens.onDataNotLoadedDialog import com.tangem.screens.onFailedTransactionDialog import io.qameta.allure.kotlin.Allure.step @@ -76,4 +77,16 @@ fun BaseTestCase.checkActionIsUnavailableDialog() { step("Assert 'Action is unavailable' dialog 'Ok' button is displayed") { onActionIsUnavailableDialog { okButton.assertIsDisplayed() } } +} + +fun BaseTestCase.checkDataNotLoadedDialog() { + step("Assert 'The data has not loaded yet' dialog title is displayed") { + onDataNotLoadedDialog { title.assertIsDisplayed() } + } + step("Assert 'The data has not loaded yet' dialog text is displayed") { + onDataNotLoadedDialog { text.assertIsDisplayed() } + } + step("Assert 'The data has not loaded yet' dialog 'Ok' button is displayed") { + onDataNotLoadedDialog { okButton.assertIsDisplayed() } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DataNotLoadedDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DataNotLoadedDialogPageObject.kt new file mode 100644 index 0000000000..e7efab995c --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/DataNotLoadedDialogPageObject.kt @@ -0,0 +1,36 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.BaseDialogTestTags +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 +import com.tangem.common.ui.R as CommonUIR + +class DataNotLoadedDialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasTestTag(BaseDialogTestTags.TITLE) + hasText(getResourceString(CommonUIR.string.action_buttons_service_loading_alert_title)) + useUnmergedTree = true + } + + val text: KNode = child { + hasTestTag(BaseDialogTestTags.TEXT) + hasText(getResourceString(CommonUIR.string.action_buttons_service_loading_alert_message)) + useUnmergedTree = true + } + + val okButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_ok)) + } +} + +internal fun BaseTestCase.onDataNotLoadedDialog(function: DataNotLoadedDialogPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt index e4ac654ef2..fb132f5e1d 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt @@ -113,8 +113,8 @@ class OrganizeTokensTest : BaseTestCase() { } step("Check positions of tokens on 'Organize tokens' screen") { onOrganizeTokensScreen { - tokenWithTitleAndPosition(bitcoinTitle, 1).assertIsDisplayed() - tokenWithTitleAndPosition(ethereumTitle, 2).assertIsDisplayed() + tokenWithTitleAndPosition(bitcoinTitle, 0).assertIsDisplayed() + tokenWithTitleAndPosition(ethereumTitle, 1).assertIsDisplayed() } } } @@ -200,10 +200,10 @@ class OrganizeTokensTest : BaseTestCase() { } step("Check positions of tokens on 'Organize tokens' screen") { onOrganizeTokensScreen { - tokenWithTitleAndPosition(bitcoinTitle, 1).assertIsDisplayed() - tokenWithTitleAndPosition(ethereumTitle, 2).assertIsDisplayed() - tokenWithTitleAndPosition(polygonTitle, 3).assertIsDisplayed() - tokenWithTitleAndPosition(polExMaticTitle, 4).assertIsDisplayed() + tokenWithTitleAndPosition(bitcoinTitle, 0).assertIsDisplayed() + tokenWithTitleAndPosition(ethereumTitle, 1).assertIsDisplayed() + tokenWithTitleAndPosition(polygonTitle, 2).assertIsDisplayed() + tokenWithTitleAndPosition(polExMaticTitle, 3).assertIsDisplayed() } } step("Click 'By Balance' button") { @@ -213,10 +213,10 @@ class OrganizeTokensTest : BaseTestCase() { } step("Check positions of tokens by balance on 'Organize tokens' screen") { onOrganizeTokensScreen { - tokenWithTitleAndPosition(ethereumTitle, 1).assertIsDisplayed() - tokenWithTitleAndPosition(polExMaticTitle, 2).assertIsDisplayed() - tokenWithTitleAndPosition(polygonTitle, 3).assertIsDisplayed() - tokenWithTitleAndPosition(bitcoinTitle, 4).assertIsDisplayed() + tokenWithTitleAndPosition(ethereumTitle, 0).assertIsDisplayed() + tokenWithTitleAndPosition(polExMaticTitle, 1).assertIsDisplayed() + tokenWithTitleAndPosition(polygonTitle, 2).assertIsDisplayed() + tokenWithTitleAndPosition(bitcoinTitle, 3).assertIsDisplayed() } } step("Click 'Apply' button") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt index fd03d3bd1e..e807c84361 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -6,9 +6,11 @@ import com.tangem.common.annotations.ApiEnv import com.tangem.common.annotations.ApiEnvConfig import com.tangem.common.constants.TestConstants.BITCOIN_ADDRESS import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT -import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.* -import com.tangem.common.utils.* +import com.tangem.common.utils.assertClipboardTextEquals +import com.tangem.common.utils.clearClipboard +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.scenarios.* diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt index 13a36c3299..d14e14e295 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt @@ -12,10 +12,6 @@ import com.tangem.scenarios.goToQrCodeBottomSheet import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.* -import com.tangem.screens.onMainScreen -import com.tangem.screens.onSwapStoriesScreen -import com.tangem.screens.onSwapTokenScreen -import com.tangem.screens.onTokenDetailsScreen import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -239,7 +235,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() { waitForIdle() onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } } - step("Assert 'Receive' button is displayed") { + step("Click on 'Receive' button") { onTokenDetailsScreen { receiveButton().performClick() } } step("Go to QR code bottom sheet") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt index 3f6c0ea10f..d5049ab273 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt @@ -392,7 +392,7 @@ class SendFeeScreenTest : BaseTestCase() { val tokenName = "VeThor" val mockState = "Vechain" val tokenAmount = "0.1" - val feeAmount = "~$0.01" + val feeAmount = "<$0.01" val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market) val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast) val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow) From d3d91e33948bf907434df77c63f20e79d21c5d83 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 21 Jan 2026 15:30:30 +0100 Subject: [PATCH 155/172] Updated on 2026-08-14 --- .../feed/ui/news/list/components/NewsListLazyColumn.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt index 1b06a93082..465c76d0f9 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt @@ -112,9 +112,7 @@ private fun Content( key = ArticleConfigUM::id, ) { article -> ArticleCard( - modifier = Modifier - .fillMaxWidth() - .height(164.dp), + modifier = Modifier.fillMaxWidth(), colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), articleConfigUM = article, onArticleClick = { From 638cbc9d2347f4befac672a97ccf1d2cb157a3d5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 21 Jan 2026 16:23:56 +0200 Subject: [PATCH 156/172] Updated on 2026-08-14 --- app/src/main/java/com/tangem/tap/TangemApplication.kt | 2 +- app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt | 4 +--- .../tangem/common/ui/notifications/NotificationsFactory.kt | 2 +- .../components/bottomsheets/message/MessageBottomSheetV2.kt | 1 - .../tangem/core/ui/components/transactions/TransactionList.kt | 1 + .../main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt | 1 - .../details/impl/ui/components/TokenMarketDetailsBody.kt | 1 + tangem-android-tools | 2 +- 8 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index abbd2d80de..52be79032f 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -356,7 +356,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private fun createReduxStore(): Store { return Store( - reducer = { action, state -> appReducer(action, state) }, + reducer = { action, state -> appReducer(action, requireNotNull(state)) }, middleware = AppState.getMiddleware(), state = AppState( daggerGraphState = DaggerGraphState( diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt index 71fcd29497..65315fc4cd 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt @@ -6,9 +6,7 @@ import com.tangem.tap.features.welcome.redux.WelcomeReducer import com.tangem.tap.proxy.redux.DaggerGraphReducer import org.rekotlin.Action -@Suppress("CanBeNonNullable") -fun appReducer(action: Action, state: AppState?): AppState { - requireNotNull(state) +fun appReducer(action: Action, state: AppState): AppState { if (action is AppAction.RestoreState) return action.state return AppState( 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 668943b4c7..01eea336aa 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 @@ -21,7 +21,7 @@ import com.tangem.utils.extensions.isZero import com.tangem.utils.extensions.orZero import java.math.BigDecimal -@Suppress("LargeClass") +@Suppress("LargeClass", "CanBeNonNullable") object NotificationsFactory { fun MutableList.addFeeUnreachableNotification( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt index ab89d9ede7..75ed9271df 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt @@ -125,7 +125,6 @@ private fun ContentContainer(state: MessageBottomSheetUMV2.InfoBlock, modifier: } } -@Suppress("CanBeNonNullable") @Composable private fun BottomSheetIconContainer( icon: MessageBottomSheetUMV2.Icon?, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt index 5ff2f3c7bc..b5c5335aba 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt @@ -29,6 +29,7 @@ import kotlinx.collections.immutable.ImmutableList * @param txHistoryItems transactions * @param modifier modifier */ +@Suppress("CanBeNonNullable") fun LazyListScope.txHistoryItems( state: TxHistoryState, txHistoryItems: LazyPagingItems?, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index 92313b9261..88ebc8dc1f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -266,7 +266,6 @@ private fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCallb } } -@Suppress("CanBeNonNullable") @Composable private fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) { AnimatedContent(news.newsUMState) { newsUMState -> diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt index c94c78f605..13124c707d 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt @@ -16,6 +16,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM import com.tangem.features.markets.impl.R +@Suppress("CanBeNonNullable") internal fun LazyListScope.tokenMarketDetailsBody( state: MarketsTokenDetailsUM.Body, isAccountEnabled: Boolean, diff --git a/tangem-android-tools b/tangem-android-tools index fa10299aec..7c69a53ea8 160000 --- a/tangem-android-tools +++ b/tangem-android-tools @@ -1 +1 @@ -Subproject commit fa10299aec0b3a06fda9bf6d050e0aa79609d33b +Subproject commit 7c69a53ea8f5c7d94fa83c33d40f495fa24dbe02 From 32ae17c0166eaa90ae12e81caa55f25e29cb235e Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 21 Jan 2026 17:40:43 +0300 Subject: [PATCH 157/172] Updated on 2026-08-14 --- .../GaslessSignedTransactionResultDTO.kt | 13 ++-------- data/transaction/build.gradle.kts | 3 +++ .../DefaultGaslessTransactionRepository.kt | 6 +++++ .../MockedGaslessTransactionRepository.kt | 5 +--- ...GaslessSignedTransactionResultConverter.kt | 7 ++---- .../transaction/di/TransactionDataModule.kt | 9 ++++--- .../models/GaslessSignedTransactionResult.kt | 22 +++-------------- .../CreateAndSendGaslessTransactionUseCase.kt | 24 ++++--------------- .../v2/send/confirm/model/SendConfirmModel.kt | 8 +++---- 9 files changed, 31 insertions(+), 66 deletions(-) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResultDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResultDTO.kt index c8bd1b89b4..8184f49c1c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResultDTO.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResultDTO.kt @@ -9,15 +9,6 @@ import com.squareup.moshi.JsonClass */ @JsonClass(generateAdapter = true) data class GaslessSignedTransactionResultDTO( - @Json(name = "signedTransaction") - val signedTransaction: String, - - @Json(name = "gasLimit") - val gasLimit: String, - - @Json(name = "maxFeePerGas") - val maxFeePerGas: String, - - @Json(name = "maxPriorityFeePerGas") - val maxPriorityFeePerGas: String, + @Json(name = "txHash") + val txHash: String, ) \ No newline at end of file diff --git a/data/transaction/build.gradle.kts b/data/transaction/build.gradle.kts index cb26912c17..7becb2b968 100644 --- a/data/transaction/build.gradle.kts +++ b/data/transaction/build.gradle.kts @@ -37,6 +37,9 @@ dependencies { implementation(projects.domain.transaction) implementation(projects.domain.demo) + /** Api */ + implementation(projects.features.sendV2.api) + /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt index d99d586e16..b4d16fbe7e 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -13,6 +13,7 @@ import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.models.Eip7702Authorization import com.tangem.domain.transaction.models.GaslessSignedTransactionResult import com.tangem.domain.transaction.models.GaslessTransactionData +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -25,6 +26,7 @@ class DefaultGaslessTransactionRepository( private val gaslessTxServiceApi: GaslessTxServiceApi, private val coroutineDispatcherProvider: CoroutineDispatcherProvider, private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, + private val sendFeatureToggles: SendFeatureToggles, ) : GaslessTransactionRepository { private val supportedTokensState = MutableStateFlow>>(hashMapOf()) @@ -120,6 +122,9 @@ class DefaultGaslessTransactionRepository( } override suspend fun getGaslessFeeAddresses(): Set { + if (!sendFeatureToggles.isGaslessTransactionsEnabled) { + return EMPTY_ADDRESSES + } return addressesMutex.withLock { allFeeRecipientAddress.ifEmpty { val allFeeAddresses = getAllFeeRecipientAddresses() @@ -136,5 +141,6 @@ class DefaultGaslessTransactionRepository( private companion object { val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000") + val EMPTY_ADDRESSES = emptySet() } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt index 0a52be64e3..be0a8b25f4 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt @@ -49,10 +49,7 @@ class MockedGaslessTransactionRepository( network: Network, eip7702Auth: Eip7702Authorization?, ): GaslessSignedTransactionResult = GaslessSignedTransactionResult( - signedTransaction = "0x000", - gasLimit = 100000.toBigInteger(), - maxFeePerGas = 21000.toBigInteger(), - maxPriorityFeePerGas = 21000.toBigInteger(), + txHash = "0x000", ) override fun getBaseGasForTransaction(): BigInteger { diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessSignedTransactionResultConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessSignedTransactionResultConverter.kt index 4c8a6e62ef..b4188cd05a 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessSignedTransactionResultConverter.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessSignedTransactionResultConverter.kt @@ -1,6 +1,6 @@ package com.tangem.data.transaction.convertes -import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO as GaslessSignedTransactionResultDTO +import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO import com.tangem.domain.transaction.models.GaslessSignedTransactionResult import com.tangem.utils.converter.Converter @@ -13,10 +13,7 @@ class GaslessSignedTransactionResultConverter : override fun convert(value: GaslessSignedTransactionResultDTO): GaslessSignedTransactionResult { return GaslessSignedTransactionResult( - signedTransaction = value.signedTransaction, - gasLimit = value.gasLimit.toBigInteger(), - maxFeePerGas = value.maxFeePerGas.toBigInteger(), - maxPriorityFeePerGas = value.maxPriorityFeePerGas.toBigInteger(), + txHash = value.txHash, ) } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt index 731006b04d..8083f49496 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt @@ -16,6 +16,7 @@ import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.WalletAddressServiceRepository import com.tangem.domain.transaction.error.FeeErrorResolver import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -76,11 +77,13 @@ internal object TransactionDataModule { responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, gaslessTxServiceApi: GaslessTxServiceApi, coroutineDispatcherProvider: CoroutineDispatcherProvider, + sendFeatureToggles: SendFeatureToggles, ): GaslessTransactionRepository { return DefaultGaslessTransactionRepository( - gaslessTxServiceApi, - coroutineDispatcherProvider, - responseCryptoCurrenciesFactory, + gaslessTxServiceApi = gaslessTxServiceApi, + coroutineDispatcherProvider = coroutineDispatcherProvider, + responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, + sendFeatureToggles = sendFeatureToggles, ) } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessSignedTransactionResult.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessSignedTransactionResult.kt index 3d41d103dd..0bdb72570c 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessSignedTransactionResult.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessSignedTransactionResult.kt @@ -1,7 +1,5 @@ package com.tangem.domain.transaction.models -import java.math.BigInteger - /** * Result of gasless transaction signing from the gasless service. * Contains the fully signed transaction ready for broadcasting and gas parameters. @@ -12,26 +10,12 @@ import java.math.BigInteger * 3. Constructs the final transaction * 4. Returns this signed transaction with gas parameters * - * @property signedTransaction complete signed transaction in RLP-encoded hex format (0x...) - * ready to be broadcast to the blockchain network - * @property gasLimit maximum amount of gas units allocated for the transaction - * @property maxFeePerGas maximum total fee per gas unit (base fee + priority fee) in wei - * @property maxPriorityFeePerGas maximum priority fee (tip) per gas unit in wei for EIP-1559 + * @property txHash sent tx hash */ data class GaslessSignedTransactionResult( - val signedTransaction: String, - val gasLimit: BigInteger, - val maxFeePerGas: BigInteger, - val maxPriorityFeePerGas: BigInteger, + val txHash: String, ) { init { - require(signedTransaction.isNotBlank()) { "Signed transaction must not be blank" } - require(signedTransaction.startsWith("0x")) { "Signed transaction must be in hex format with 0x prefix" } - require(gasLimit > BigInteger.ZERO) { "Gas limit must be positive" } - require(maxFeePerGas > BigInteger.ZERO) { "Max fee per gas must be positive" } - require(maxPriorityFeePerGas >= BigInteger.ZERO) { "Max priority fee per gas must be non-negative" } - require(maxPriorityFeePerGas <= maxFeePerGas) { - "Max priority fee per gas cannot exceed max fee per gas" - } + require(txHash.isNotBlank()) { "Tx hash must not be blank" } } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt index 853e9b55e2..4d2d4e9a0d 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -26,7 +26,6 @@ import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.models.Eip7702Authorization -import com.tangem.domain.transaction.models.GaslessSignedTransactionResult import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.walletmanager.WalletManagersFacade @@ -50,8 +49,7 @@ class CreateAndSendGaslessTransactionUseCase( val uncompiledTxData = validateTransactionData(transactionData) val context = prepareGaslessContext(userWallet, uncompiledTxData, fee) val signedData = signGaslessTransactionByUser(userWallet, context, uncompiledTxData) - val remoteSignedData = signTransactionOnBackend(context, signedData, uncompiledTxData) - broadcastSignedTransaction(context, remoteSignedData) + signAndSendTransactionOnBackend(context, signedData, uncompiledTxData) }, catch = { raise(SendTransactionError.DataError(it.message)) @@ -185,32 +183,18 @@ class CreateAndSendGaslessTransactionUseCase( /** * Sends gasless transaction to the service. */ - private suspend fun signTransactionOnBackend( + private suspend fun signAndSendTransactionOnBackend( context: GaslessContext, signedData: SignedGaslessData, transactionData: TransactionData.Uncompiled, - ): GaslessSignedTransactionResult { + ): String { return gaslessTransactionRepository.signGaslessTransaction( network = context.tokenForFeeStatus.currency.network, gaslessTransactionData = context.gaslessTransactionData, signature = signedData.eip712Signature, userAddress = transactionData.sourceAddress, eip7702Auth = signedData.eip7702Auth, - ) - } - - private suspend fun broadcastSignedTransaction( - context: GaslessContext, - remoteSignedData: GaslessSignedTransactionResult, - ): String { - val transactionSender = context.walletManager as? TransactionSender ?: error( - "WalletManager for network ${context.tokenForFeeStatus.currency.network.id} " + - "does not support transaction sending", - ) - return when (val result = transactionSender.broadcastTransaction(remoteSignedData.signedTransaction)) { - is Result.Failure -> throw result.error - is Result.Success -> result.data.hash - } + ).txHash } private suspend fun signHashes( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt index a83a0a796c..2479812b57 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -425,8 +425,8 @@ internal class SendConfirmModel @Inject constructor( ), ) }, - ifRight = { - updateTransactionStatus(txData) + ifRight = { txHash -> + updateTransactionStatus(txData, txHash) addTokenToWalletIfNeeded() sendBalanceUpdater.scheduleUpdates() sendAnalyticHelper.sendSuccessAnalytics( @@ -479,9 +479,9 @@ internal class SendConfirmModel @Inject constructor( } } - private fun updateTransactionStatus(txData: TransactionData.Uncompiled) { + private fun updateTransactionStatus(txData: TransactionData.Uncompiled, txHash: String) { val txUrl = getExplorerTransactionUrlUseCase( - txHash = txData.hash.orEmpty(), + txHash = txHash.ifEmpty { txData.hash.orEmpty() }, networkId = cryptoCurrency.network.id, ).getOrNull().orEmpty() _uiState.update(SendConfirmSentStateTransformer(txData, txUrl)) From d594af91fc5416096a7e1555c6ed955af6ee7d15 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 22 Jan 2026 12:31:40 +0500 Subject: [PATCH 158/172] Updated on 2026-08-14 --- .../account/AccountIconItemStateConverter.kt | 1 + .../AccountPortfolioItemUMConverter.kt | 29 ++++++++++--------- .../domain/account/models/AccountList.kt | 1 + .../DefaultSingleAccountStatusListProducer.kt | 2 ++ .../tangem/domain/models/account/Account.kt | 11 +++++++ .../entity/AccountCreateEditUMBuilder.kt | 1 + .../account/details/AccountDetailsModel.kt | 1 + .../converter/AvailableToAddDataConverter.kt | 1 + .../portfolio/add/impl/model/AddTokenModel.kt | 1 + .../impl/model/NewMarketsPortfolioDelegate.kt | 1 + .../model/CustomTokenSelectorModel.kt | 1 + .../converter/AvailableToAddDataConverter.kt | 1 + .../portfolio/add/impl/model/AddTokenModel.kt | 1 + .../impl/model/NewMarketsPortfolioDelegate.kt | 1 + .../transformer/UpdateDataStateTransformer.kt | 1 + .../connections/model/WcPairModel.kt | 2 ++ .../WcSessionsAccountModeTransformer.kt | 1 + .../converter/WcPortfolioNameDelegate.kt | 1 + 18 files changed, 45 insertions(+), 13 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt index f89a37e3db..102be5a837 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt @@ -20,5 +20,6 @@ object AccountIconItemStateConverter : Converter TODO("[REDACTED_JIRA]") } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt index d45ef488d1..baed1af4d7 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt @@ -22,21 +22,24 @@ class AccountPortfolioItemUMConverter( ) : Converter { override fun convert(value: Account): UserWalletItemUM { - return with(value) { - UserWalletItemUM( - id = accountId.value, - name = accountName.toUM().value, - information = getInfo(account = this), - balance = getBalanceInfo(), - isEnabled = isEnabled, - endIcon = endIcon, - onClick = onClick, - imageState = getImageState(account = this), - ) + return when (value) { + is Account.CryptoPortfolio -> with(value) { + UserWalletItemUM( + id = accountId.value, + name = accountName.toUM().value, + information = getInfo(account = this), + balance = getBalanceInfo(), + isEnabled = isEnabled, + endIcon = endIcon, + onClick = onClick, + imageState = getImageState(account = this), + ) + } + is Account.Payment -> TODO("[REDACTED_JIRA]") } } - private fun getInfo(account: Account): UserWalletItemUM.Information.Loaded = when (account) { + private fun getInfo(account: Account.CryptoPortfolio): UserWalletItemUM.Information.Loaded = when (account) { is Account.CryptoPortfolio -> { val text = pluralReference( R.plurals.common_tokens_count, @@ -47,7 +50,7 @@ class AccountPortfolioItemUMConverter( } } - private fun getImageState(account: Account) = when (account) { + private fun getImageState(account: Account.CryptoPortfolio) = when (account) { is Account.CryptoPortfolio -> UserWalletItemUM.ImageState.Account( name = account.accountName.toUM().value, icon = account.icon.toUM(), diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt index db2dc28e25..0431d854da 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt @@ -99,6 +99,7 @@ data class AccountList private constructor( return accounts.flatMap { account -> when (account) { is Account.CryptoPortfolio -> account.cryptoCurrencies + is Account.Payment -> TODO("[REDACTED_JIRA]") } } } diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt index bf10f519ad..77367a7909 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt @@ -97,6 +97,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo } .distinctUntilChanged() } + is Account.Payment -> TODO("[REDACTED_JIRA]") } } } @@ -192,6 +193,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo priceChangeLce = lceLoading(), ) } + is Account.Payment -> TODO("[REDACTED_JIRA]") } }, totalAccounts = accountList.totalAccounts, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt index 4b0f60f75a..af4f1fd430 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt @@ -174,4 +174,15 @@ sealed interface Account { } } } + + class Payment : Account { + override val accountId: AccountId + get() = TODO("Not yet implemented") + override val accountName: AccountName + get() = TODO("Not yet implemented") + + init { + error("Not yet implemented") + } + } } \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt index a3e921cf2d..b9944a8996 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt @@ -88,6 +88,7 @@ internal class AccountCreateEditUMBuilder( val Account.portfolioIcon: CryptoPortfolioIcon get() = when (this) { is Account.CryptoPortfolio -> this.icon + is Account.Payment -> TODO("[REDACTED_JIRA]") } fun AccountCreateEditUM.updateColorSelect(color: CryptoPortfolioIcon.Color): AccountCreateEditUM { diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt index cb61bf3e8f..fad8eeb69f 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt @@ -153,6 +153,7 @@ internal class AccountDetailsModel @Inject constructor( isLoading = false, ) } + is Account.Payment -> TODO("[REDACTED_JIRA]") } val isMultiCurrency = getUserWalletUseCase(account.accountId.userWalletId).getOrNull() ?.isMultiCurrency == true diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt index dd013d17ba..49d3820aa2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt @@ -108,6 +108,7 @@ internal class AvailableToAddDataConverter @Inject constructor( ): CryptoCurrency? { val derivationIndex = when (account) { is Account.CryptoPortfolio -> account.derivationIndex + is Account.Payment -> TODO("[REDACTED_JIRA]") } return getTokenMarketCryptoCurrency( userWalletId = userWallet.walletId, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt index cedc618422..b8547e0118 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt @@ -96,6 +96,7 @@ internal class AddTokenModel @Inject constructor( is Account.CryptoPortfolio -> if (!account.isMainAccount) { analyticsEventHandler.send(analyticsEventBuilder.addToNotMainAccount()) } + is Account.Payment -> TODO("[REDACTED_JIRA]") } params.callbacks.onTokenAdded(status.status) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/NewMarketsPortfolioDelegate.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/NewMarketsPortfolioDelegate.kt index 9fdad17c66..02e0ec8a02 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/NewMarketsPortfolioDelegate.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/NewMarketsPortfolioDelegate.kt @@ -271,6 +271,7 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor( name = this.accountName.toUM().value, icon = when (this) { is Account.CryptoPortfolio -> this.icon.toUM() + is Account.Payment -> TODO("[REDACTED_JIRA]") }, ), ) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt index c57d03a4e8..2b6df47f38 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt @@ -215,6 +215,7 @@ internal class CustomTokenSelectorModel @Inject constructor( val accountName = when (account) { is Account.CryptoPortfolio -> account.accountName + is Account.Payment -> TODO("[REDACTED_JIRA]") null -> null } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt index 7004e37dfc..a9a11d6eb7 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt @@ -108,6 +108,7 @@ internal class AvailableToAddDataConverter @Inject constructor( ): CryptoCurrency? { val derivationIndex = when (account) { is Account.CryptoPortfolio -> account.derivationIndex + is Account.Payment -> TODO("[REDACTED_JIRA]") } return getTokenMarketCryptoCurrency( userWalletId = userWallet.walletId, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt index 7c5ba2defd..c00c3f15da 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt @@ -96,6 +96,7 @@ internal class AddTokenModel @Inject constructor( is Account.CryptoPortfolio -> if (!account.isMainAccount) { analyticsEventHandler.send(analyticsEventBuilder.addToNotMainAccount()) } + is Account.Payment -> TODO("[REDACTED_JIRA]") } params.callbacks.onTokenAdded(status.status) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt index 9a6de7c950..eecadfc1a5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt @@ -272,6 +272,7 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor( name = this.accountName.toUM().value, icon = when (this) { is Account.CryptoPortfolio -> this.icon.toUM() + is Account.Payment -> TODO("[REDACTED_JIRA]") }, ), ) diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt index 0e4605da31..e0256c93ba 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt @@ -101,6 +101,7 @@ internal class UpdateDataStateTransformer( name = this.accountName.toUM().value, icon = when (this) { is Account.CryptoPortfolio -> this.icon.toUM() + is Account.Payment -> TODO("[REDACTED_JIRA]") }, ), ) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt index e0a0182370..bdc6f34464 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt @@ -273,6 +273,7 @@ internal class WcPairModel @Inject constructor( val (wallet, portfolioAccount) = selectedPortfolio val account = when (val account = portfolioAccount.account) { is Account.CryptoPortfolio -> account + is Account.Payment -> TODO("[REDACTED_JIRA]") } val isAccountMode = selectorController.isAccountMode.first() val icon: CryptoPortfolioIconUM? @@ -325,6 +326,7 @@ internal class WcPairModel @Inject constructor( if (selectorController.isAccountModeSync() && account != null) { val derivationIndex = when (account) { is Account.CryptoPortfolio -> account.derivationIndex.value + is Account.Payment -> TODO("[REDACTED_JIRA]") } analytics.send(WcAnalyticAccountEvents.PairButtonConnect(derivationIndex)) } else { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsAccountModeTransformer.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsAccountModeTransformer.kt index a36142ec24..598ca31911 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsAccountModeTransformer.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsAccountModeTransformer.kt @@ -64,6 +64,7 @@ internal class WcSessionsAccountModeTransformer( } val accountIcon = when (account) { is Account.CryptoPortfolio -> account.icon + is Account.Payment -> TODO("[REDACTED_JIRA]") } val accountTitle = AccountTitleUM.Account( diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcPortfolioNameDelegate.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcPortfolioNameDelegate.kt index 7273f94398..ce57447912 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcPortfolioNameDelegate.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcPortfolioNameDelegate.kt @@ -24,6 +24,7 @@ internal class WcPortfolioNameDelegate @AssistedInject constructor( fun createAccountTitleUM(value: WcSession): AccountTitleUM? { val account = when (val account = value.account) { is Account.CryptoPortfolio -> account + is Account.Payment -> TODO("[REDACTED_JIRA]") null -> null } From c3f5ea4284208c5cdcfca5461b07364410ec6246 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 22 Jan 2026 11:42:23 +0400 Subject: [PATCH 159/172] Updated on 2026-08-14 --- .../tap/di/domain/YieldSupplyDomainModule.kt | 17 + .../supply/DefaultYieldSupplyRepository.kt | 8 +- .../yield/supply/YieldSupplyRepository.kt | 8 +- .../usecase/YieldSupplyEnterStatusUseCase.kt | 2 +- .../usecase/YieldSupplyPendingTracker.kt | 147 +++++++++ .../YieldSupplyEnterStatusUseCaseTest.kt | 12 +- .../usecase/YieldSupplyPendingTrackerTest.kt | 308 ++++++++++++++++++ .../impl/main/model/YieldSupplyModel.kt | 24 -- .../model/YieldSupplyStartEarningModel.kt | 6 + .../model/YieldSupplyStopEarningModel.kt | 7 + 10 files changed, 496 insertions(+), 43 deletions(-) create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTracker.kt create mode 100644 domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt index 3301b9c21a..d74844c082 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt @@ -1,6 +1,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.blockaid.BlockAidGasEstimate +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.FeeRepository @@ -14,6 +15,8 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Suppress("TooManyFunctions") @@ -234,4 +237,18 @@ internal object YieldSupplyDomainModule { ): YieldSupplyGetAvailabilityUseCase { return YieldSupplyGetAvailabilityUseCase(yieldSupplyRepository) } + + @Provides + @Singleton + fun provideYieldSupplyPendingProcessorUseCase( + yieldSupplyRepository: YieldSupplyRepository, + singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + dispatcherProvider: CoroutineDispatcherProvider, + ): YieldSupplyPendingTracker { + return YieldSupplyPendingTracker( + yieldSupplyRepository = yieldSupplyRepository, + singleNetworkStatusFetcher = singleNetworkStatusFetcher, + coroutineScope = CoroutineScope(SupervisorJob() + dispatcherProvider.io), + ) + } } \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt index a2f8f3700f..3565a65952 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt @@ -19,7 +19,6 @@ import com.tangem.datasource.local.preferences.utils.get import com.tangem.datasource.local.preferences.utils.store import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.yield.supply.YieldSupplyRepository @@ -137,13 +136,10 @@ internal class DefaultYieldSupplyRepository( } } - override suspend fun getPendingTxHashes( - userWalletId: UserWalletId, - cryptoCurrencyStatus: CryptoCurrencyStatus, - ): List { + override suspend fun getPendingTxHashes(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): List { val walletManager = walletManagersFacade.getOrCreateWalletManager( userWalletId = userWalletId, - network = cryptoCurrencyStatus.currency.network, + network = cryptoCurrency.network, ) ?: return emptyList() return walletManager.wallet.recentTransactions diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt index 7e371b0a14..c8841c32c5 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt @@ -1,7 +1,6 @@ package com.tangem.domain.yield.supply import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.yield.supply.models.YieldMarketToken import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus @@ -84,13 +83,10 @@ interface YieldSupplyRepository { * Retrieve the hashes of pending (unconfirmed) transactions for the given wallet and currency. * * @param userWalletId the wallet to query - * @param cryptoCurrencyStatus the currency status containing network information + * @param cryptoCurrency the currency containing network information * @return list of transaction hashes that are still unconfirmed, or an empty list if none exist */ - suspend fun getPendingTxHashes( - userWalletId: UserWalletId, - cryptoCurrencyStatus: CryptoCurrencyStatus, - ): List + suspend fun getPendingTxHashes(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): List /** * Get the last saved user‑initiated yield protocol action for the given wallet and currency, if any. diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCase.kt index 726f1cd3df..271e08be2a 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCase.kt @@ -20,7 +20,7 @@ class YieldSupplyEnterStatusUseCase( cryptoCurrencyStatus.currency, ) val pendingTxHashes = yieldSupplyRepository - .getPendingTxHashes(userWalletId, cryptoCurrencyStatus) + .getPendingTxHashes(userWalletId, cryptoCurrencyStatus.currency) .toSet() val hasPendingTx = status?.txIds?.any { it in pendingTxHashes } == true diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTracker.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTracker.kt new file mode 100644 index 0000000000..2b2068de6c --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTracker.kt @@ -0,0 +1,147 @@ +package com.tangem.domain.yield.supply.usecase + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.yield.supply.YieldSupplyRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap + +/** + * Periodically checks pending Yield Supply transaction hashes for a given set of wallet/currency pairs. + * + * - Call [addPending] right after sending a transaction to start tracking its hashes. + * - The checker runs every [CHECK_INTERVAL_MS] and queries repository for the current pending tx hashes. + * - If none of the stored hashes are pending anymore for a tracked entry, it triggers a single-network status refresh + * via [singleNetworkStatusFetcher] and removes the entry from tracking. + * - The periodic job stops automatically when there is nothing left to track. + */ +class YieldSupplyPendingTracker( + private val yieldSupplyRepository: YieldSupplyRepository, + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + private val coroutineScope: CoroutineScope, +) { + + private data class TrackedKey( + val userWalletId: UserWalletId, + val cryptoCurrencyId: CryptoCurrency.ID, + ) + + private data class TrackedEntry( + val cryptoCurrency: CryptoCurrency, + val txIds: Set, + val attempts: Int = 0, + ) + + private val trackedEntries = ConcurrentHashMap() + private val mutex = Mutex() + private var checkingJob: Job? = null + + /** + * Add tx ids to track for a wallet/currency pair. Starts periodic checking if needed. + */ + suspend fun addPending(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, txIds: List) { + val key = TrackedKey(userWalletId, cryptoCurrency.id) + trackedEntries.compute(key) { _, existing -> + if (existing == null) { + TrackedEntry( + cryptoCurrency = cryptoCurrency, + txIds = txIds.toSet(), + ) + } else { + existing.copy(txIds = existing.txIds + txIds) + } + } + + startAutomaticCheckingIfNeeded() + } + + private suspend fun startAutomaticCheckingIfNeeded() { + mutex.withLock { + if (checkingJob?.isActive == true) return + + checkingJob = coroutineScope.launch { + while (isActive) { + try { + checkAllTracked() + } catch (ex: Exception) { + Timber.e(ex) + } + delay(CHECK_INTERVAL_MS) + } + } + } + } + + private suspend fun stopAutomaticCheckingIfEmpty() { + if (trackedEntries.isEmpty()) { + mutex.withLock { + if (trackedEntries.isEmpty()) { + checkingJob?.cancel() + checkingJob = null + } + } + } + } + + private suspend fun checkAllTracked() { + val keysSnapshot = trackedEntries.keys().toList() + + if (keysSnapshot.isEmpty()) { + stopAutomaticCheckingIfEmpty() + return + } + + val networksToFetch = mutableSetOf() + + for (key in keysSnapshot) { + val entry = trackedEntries[key] ?: continue + if (entry.txIds.isEmpty()) { + trackedEntries.remove(key) + continue + } + + val pendingTxHashes = + yieldSupplyRepository.getPendingTxHashes( + userWalletId = key.userWalletId, + cryptoCurrency = entry.cryptoCurrency, + ) + .toSet() + + val hasStillPending = entry.txIds.any { it in pendingTxHashes } + + if (hasStillPending) { + trackedEntries.computeIfPresent(key) { _, current -> + val newAttempts = current.attempts + 1 + if (newAttempts >= MAX_ATTEMPTS) null else current.copy(attempts = newAttempts) + } + } else { + trackedEntries.remove(key) + } + networksToFetch.add( + SingleNetworkStatusFetcher.Params( + userWalletId = key.userWalletId, + network = entry.cryptoCurrency.network, + ), + ) + } + + for (params in networksToFetch) { + singleNetworkStatusFetcher(params) + } + + stopAutomaticCheckingIfEmpty() + } + + private companion object { + private const val CHECK_INTERVAL_MS = 10_000L + private const val MAX_ATTEMPTS = 6 + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt index 54955b5312..3929c947ab 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt @@ -45,7 +45,7 @@ class YieldSupplyEnterStatusUseCaseTest { yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) } returns status coEvery { - yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus) + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency) } returns listOf(pendingTxHash) val result = useCase(userWalletId, cryptoStatus) @@ -65,7 +65,7 @@ class YieldSupplyEnterStatusUseCaseTest { yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) } returns status coEvery { - yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus) + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency) } returns listOf("0xdifferent") coEvery { yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null) @@ -88,7 +88,7 @@ class YieldSupplyEnterStatusUseCaseTest { yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) } returns null coEvery { - yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus) + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency) } returns emptyList() coEvery { yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null) @@ -111,7 +111,7 @@ class YieldSupplyEnterStatusUseCaseTest { yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) } returns status coEvery { - yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus) + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency) } returns emptyList() coEvery { yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null) @@ -153,7 +153,7 @@ class YieldSupplyEnterStatusUseCaseTest { yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) } returns status coEvery { - yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus) + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency) } returns listOf(pendingTxHash) val result = useCase(userWalletId, cryptoStatus) @@ -174,7 +174,7 @@ class YieldSupplyEnterStatusUseCaseTest { yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) } returns status coEvery { - yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus) + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency) } returns listOf(matchingTxHash) val result = useCase(userWalletId, cryptoStatus) diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt new file mode 100644 index 0000000000..986b720a55 --- /dev/null +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt @@ -0,0 +1,308 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.yield.supply.YieldSupplyRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class YieldSupplyPendingTrackerTest { + + private val yieldSupplyRepository: YieldSupplyRepository = mockk() + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher = mockk() + + private lateinit var testScope: TestScope + private lateinit var useCase: YieldSupplyPendingTracker + + private val userWalletId = UserWalletId("00") + + @BeforeEach + fun setUp() { + testScope = TestScope(StandardTestDispatcher()) + useCase = YieldSupplyPendingTracker( + yieldSupplyRepository = yieldSupplyRepository, + singleNetworkStatusFetcher = singleNetworkStatusFetcher, + coroutineScope = testScope, + ) + } + + @Test + fun `GIVEN new tx ids WHEN addPending THEN stores entry for tracking`() = runTest { + val token = createToken() + val txIds = listOf("0xabc123", "0xdef456") + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } returns txIds + coEvery { + singleNetworkStatusFetcher.invoke(any()) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId, token, txIds) + + testScope.advanceTimeBy(10_001L) + + coVerify { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } + } + + @Test + fun `GIVEN existing entry WHEN addPending THEN merges tx ids`() = runTest { + val token = createToken() + val firstTxIds = listOf("0xfirst") + val secondTxIds = listOf("0xsecond") + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } returns firstTxIds + secondTxIds + coEvery { + singleNetworkStatusFetcher.invoke(any()) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId, token, firstTxIds) + useCase.addPending(userWalletId, token, secondTxIds) + + testScope.advanceTimeBy(10_001L) + + coVerify { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } + } + + @Test + fun `GIVEN tx still pending WHEN addPending THEN triggers network status refresh`() = runTest { + val token = createToken() + val txIds = listOf("0xpending") + val paramsSlot = slot() + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } returns txIds + coEvery { + singleNetworkStatusFetcher.invoke(capture(paramsSlot)) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId, token, txIds) + + testScope.advanceTimeBy(10_001L) + + coVerify { + singleNetworkStatusFetcher.invoke(any()) + } + assertThat(paramsSlot.captured.userWalletId).isEqualTo(userWalletId) + assertThat(paramsSlot.captured.network).isEqualTo(token.network) + } + + @Test + fun `GIVEN tx no longer pending WHEN addPending THEN removes entry from tracking and refreshes network`() = runTest { + val token = createToken() + val txIds = listOf("0xconfirmed") + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } returns emptyList() + coEvery { + singleNetworkStatusFetcher.invoke(any()) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId, token, txIds) + + testScope.advanceTimeBy(10_001L) + + coVerify(exactly = 1) { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } + coVerify(exactly = 1) { + singleNetworkStatusFetcher.invoke(any()) + } + } + + @Test + fun `GIVEN max attempts reached WHEN addPending THEN stops tracking entry`() = runTest { + val token = createToken() + val txIds = listOf("0xstuck") + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } returns txIds + coEvery { + singleNetworkStatusFetcher.invoke(any()) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId, token, txIds) + + testScope.advanceTimeBy(10_001L * 6) + + coVerify(exactly = 6) { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } + + testScope.advanceTimeBy(10_001L) + + coVerify(exactly = 6) { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } + } + + @Test + fun `GIVEN multiple wallets WHEN addPending THEN tracks each separately`() = runTest { + val token = createToken() + val userWalletId1 = UserWalletId("00") + val userWalletId2 = UserWalletId("01") + val txIds1 = listOf("0xtx1") + val txIds2 = listOf("0xtx2") + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId1, token) + } returns txIds1 + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId2, token) + } returns txIds2 + coEvery { + singleNetworkStatusFetcher.invoke(any()) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId1, token, txIds1) + useCase.addPending(userWalletId2, token, txIds2) + + testScope.advanceTimeBy(10_001L) + + coVerify { + yieldSupplyRepository.getPendingTxHashes(userWalletId1, token) + yieldSupplyRepository.getPendingTxHashes(userWalletId2, token) + } + } + + @Test + fun `GIVEN multiple wallets with same network WHEN checkAllTracked THEN fetches network status once per unique pair`() = runTest { + val token = createToken() + val userWalletId1 = UserWalletId("00") + val userWalletId2 = UserWalletId("01") + val txIds1 = listOf("0xtx1") + val txIds2 = listOf("0xtx2") + val capturedParams = mutableListOf() + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId1, token) + } returns txIds1 + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId2, token) + } returns txIds2 + coEvery { + singleNetworkStatusFetcher.invoke(capture(capturedParams)) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId1, token, txIds1) + useCase.addPending(userWalletId2, token, txIds2) + + testScope.advanceTimeBy(1000) + + assertThat(capturedParams).hasSize(2) + assertThat(capturedParams.map { it.userWalletId }.toSet()) + .containsExactly(userWalletId1, userWalletId2) + assertThat(capturedParams.map { it.network }.toSet()) + .containsExactly(token.network) + } + + @Test + fun `GIVEN same wallet with different currencies WHEN addPending THEN tracks each currency separately`() = runTest { + val token1 = createToken(networkId = "ethereum", contractAddress = "0xToken1") + val token2 = createToken(networkId = "polygon", contractAddress = "0xToken2") + val txIds1 = listOf("0xtx1") + val txIds2 = listOf("0xtx2") + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token1) + } returns txIds1 + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token2) + } returns txIds2 + coEvery { + singleNetworkStatusFetcher.invoke(any()) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId, token1, txIds1) + useCase.addPending(userWalletId, token2, txIds2) + + testScope.advanceTimeBy(10_001L) + + coVerify { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token1) + yieldSupplyRepository.getPendingTxHashes(userWalletId, token2) + } + } + + @Test + fun `GIVEN partial tx match WHEN addPending THEN still triggers refresh`() = runTest { + val token = createToken() + val trackedTxIds = listOf("0xtracked1", "0xtracked2") + val pendingTxIds = listOf("0xtracked1") + val paramsSlot = slot() + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } returns pendingTxIds + coEvery { + singleNetworkStatusFetcher.invoke(capture(paramsSlot)) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId, token, trackedTxIds) + + testScope.advanceTimeBy(10_001L) + + coVerify { + singleNetworkStatusFetcher.invoke(any()) + } + assertThat(paramsSlot.captured.userWalletId).isEqualTo(userWalletId) + } + + private fun createToken( + networkId: String = "ethereum", + contractAddress: String = "0xToken", + ): CryptoCurrency.Token { + val derivationPath = Network.DerivationPath.None + val network = Network( + id = Network.ID(value = networkId, derivationPath = derivationPath), + backendId = networkId, + name = networkId, + currencySymbol = networkId.take(3).uppercase(), + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + 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(networkId), + suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress), + ), + network = network, + name = "TEST_TOKEN", + symbol = "TTK", + decimals = 18, + iconUrl = null, + isCustom = false, + contractAddress = contractAddress, + ) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index bbca4eb5ec..6aef4d48d4 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -31,12 +31,9 @@ import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM import com.tangem.features.yield.supply.impl.main.model.transformers.YieldSupplyTokenStatusSuccessTransformer import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.DelayedWork import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import com.tangem.utils.transformer.update -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber @@ -55,7 +52,6 @@ internal class YieldSupplyModel @Inject constructor( private val getUserWalletUseCase: GetUserWalletUseCase, private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, - @DelayedWork private val coroutineScope: CoroutineScope, private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase, private val yieldSupplyIsAvailableUseCase: YieldSupplyIsAvailableUseCase, private val yieldSupplyActivateUseCase: YieldSupplyActivateUseCase, @@ -75,7 +71,6 @@ internal class YieldSupplyModel @Inject constructor( var userWallet: UserWallet by Delegates.notNull() private var latestCryptoCurrencyStatus: CryptoCurrencyStatus? = null - private val fetchCurrencyJobHolder = JobHolder() private val loadStatusJobHolder = JobHolder() private val isFirstCryptoCurrencyStatusEmission = AtomicBoolean(true) @@ -201,7 +196,6 @@ internal class YieldSupplyModel @Inject constructor( is YieldSupplyPendingStatus.Exit -> YieldSupplyUM.Processing.Exit } } - fetchCurrencyWithDelay() } private suspend fun loadStatus(cryptoCurrencyStatus: CryptoCurrencyStatus) { @@ -216,20 +210,6 @@ internal class YieldSupplyModel @Inject constructor( } } - private fun fetchCurrencyWithDelay() { - coroutineScope.launch(dispatchers.io) { - delay(PROCESSING_UPDATE_DELAY) - singleNetworkStatusFetcher( - params = SingleNetworkStatusFetcher.Params( - userWalletId = userWallet.walletId, - network = cryptoCurrency.network, - ), - ).onLeft { - fetchCurrencyWithDelay() - } - }.saveIn(fetchCurrencyJobHolder) - } - private suspend fun loadActiveState( cryptoCurrencyStatus: CryptoCurrencyStatus, yieldSupplyStatus: YieldSupplyStatus, @@ -334,8 +314,4 @@ internal class YieldSupplyModel @Inject constructor( } } } - - private companion object { - const val PROCESSING_UPDATE_DELAY = 10_000L - } } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt index 144e0dc546..aae5996e1a 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt @@ -65,6 +65,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor( private val yieldSupplyGetMaxFeeUseCase: YieldSupplyGetMaxFeeUseCase, private val yieldSupplyGetCurrentFeeUseCase: YieldSupplyGetCurrentFeeUseCase, private val yieldSupplyRepository: YieldSupplyRepository, + private val yieldSupplyPendingTracker: YieldSupplyPendingTracker, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyStartEarningComponent.Params = paramsContainer.require() @@ -291,6 +292,11 @@ internal class YieldSupplyStartEarningModel @Inject constructor( } modelScope.launch { + yieldSupplyPendingTracker.addPending( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + txIds = txsData, + ) params.callback.onTransactionSent() } } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt index 3a0b8b4398..e79191dc86 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt @@ -23,6 +23,7 @@ import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.increaseGasLimitBy import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import com.tangem.domain.yield.supply.usecase.YieldSupplyDeactivateUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyPendingTracker import com.tangem.domain.yield.supply.usecase.YieldSupplyStopEarningUseCase import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.R @@ -61,6 +62,7 @@ internal class YieldSupplyStopEarningModel @Inject constructor( private val yieldSupplyAlertFactory: YieldSupplyAlertFactory, private val yieldSupplyDeactivateUseCase: YieldSupplyDeactivateUseCase, private val yieldSupplyRepository: YieldSupplyRepository, + private val yieldSupplyPendingTracker: YieldSupplyPendingTracker, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyStopEarningComponent.Params = paramsContainer.require() @@ -203,6 +205,11 @@ internal class YieldSupplyStopEarningModel @Inject constructor( } modelScope.launch { + yieldSupplyPendingTracker.addPending( + userWalletId = userWallet.walletId, + cryptoCurrency = cryptoCurrency, + txIds = listOf(txId), + ) params.callback.onStopEarningTransactionSent() } } From b0963883d9f9a6f26842f02db6b8987ddc0fde6d Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 21 Jan 2026 15:54:42 +0300 Subject: [PATCH 160/172] Updated on 2026-08-14 --- .../component/preview/PreviewCustomTokenSelectorComponent.kt | 1 + .../component/preview/PreviewManageTokensComponent.kt | 1 + .../component/preview/PreviewOnboardingManageTokensComponent.kt | 1 + .../features/managetokens/entity/item/CurrencyNetworkUM.kt | 1 + .../features/managetokens/ui/CustomTokenSelectorContent.kt | 2 +- .../managetokens/utils/mapper/CurrencyNetworksMapper.kt | 2 ++ 6 files changed, 7 insertions(+), 1 deletion(-) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt index 71f9caee9a..bcdfa62787 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt @@ -70,6 +70,7 @@ internal class PreviewCustomTokenSelectorComponent( ), name = "Network $index", type = "N$index", + currencySymbol = "N$index", iconResId = R.drawable.ic_eth_16, isMainNetwork = false, isSelected = n.id == params.selectedNetwork?.id, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt index 59513c7d2e..bf0778fc87 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt @@ -169,6 +169,7 @@ internal class PreviewManageTokensComponent( ), name = "NETWORK$networkIndex", type = "N$networkIndex", + currencySymbol = "N$networkIndex", iconResId = R.drawable.ic_eth_16, isMainNetwork = networkIndex == 0, isSelected = false, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt index 790bffdd7c..7907f421fa 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt @@ -108,6 +108,7 @@ internal class PreviewOnboardingManageTokensComponent( ), name = "NETWORK$networkIndex", type = "N$networkIndex", + currencySymbol = "N$networkIndex", iconResId = R.drawable.ic_eth_16, isMainNetwork = networkIndex == 0, isSelected = false, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/CurrencyNetworkUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/CurrencyNetworkUM.kt index 8376d37b21..73d1c1ecf8 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/CurrencyNetworkUM.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/CurrencyNetworkUM.kt @@ -6,6 +6,7 @@ internal data class CurrencyNetworkUM( val network: Network, val name: String, val type: String, + val currencySymbol: String, val iconResId: Int, val isMainNetwork: Boolean, val onLongClick: () -> Unit, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt index b023409947..29cb995a99 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt @@ -158,7 +158,7 @@ private fun NetworkItem(model: CurrencyNetworkUM, modifier: Modifier = Modifier) model = with(model) { ChainRowUM( name = name, - type = type, + type = currencySymbol, icon = CurrencyIconState.CoinIcon( url = null, fallbackResId = model.iconResId, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/CurrencyNetworksMapper.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/CurrencyNetworksMapper.kt index c92243274e..04c677fe6e 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/CurrencyNetworksMapper.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/CurrencyNetworksMapper.kt @@ -48,6 +48,7 @@ internal fun Network.toCurrencyNetworkModel( iconResId = id.getIconRes(isColored = true), isSelected = isSelected, type = standardType.name, + currencySymbol = currencySymbol, onLongClick = {}, isMainNetwork = false, onSelectedStateChange = onSelectedStateChange, @@ -66,6 +67,7 @@ internal fun SourceNetwork.toCurrencyNetworkModel( iconResId = id.getIconRes(isColored = isSelected || !isEditable), isSelected = isSelected || !isEditable, type = typeName, + currencySymbol = network.currencySymbol, onLongClick = { onLongTap(this) }, isMainNetwork = this is SourceNetwork.Main, onSelectedStateChange = { selected -> From efecce2d10b09df0f4f1c44b4a9130338f9f1807 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 22 Jan 2026 14:35:35 +0300 Subject: [PATCH 161/172] Updated on 2026-08-14 --- .../wallet/ui/components/WalletsList.kt | 5 +- .../wallet/ui/components/common/WalletCard.kt | 134 ++++++++---------- 2 files changed, 62 insertions(+), 77 deletions(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt index 73f9b11041..e09c48d1c0 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt @@ -18,12 +18,11 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.MainScreenTestTags @@ -50,7 +49,7 @@ internal fun WalletsList( isBalanceHidden: Boolean, ) { val horizontalCardPadding = TangemTheme.dimens.spacing16 - val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val screenWidth = LocalWindowSize.current.width val itemWidth by remember(screenWidth) { derivedStateOf { screenWidth - horizontalCardPadding * 2 } } LazyRow( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt index dcfaea4d35..01044070d2 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.* @@ -33,17 +34,11 @@ 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.datasource.CollectionPreviewParameterProvider -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.sp -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.ConstraintLayoutScope -import androidx.constraintlayout.compose.Dimension -import androidx.constraintlayout.compose.Visibility +import androidx.compose.ui.unit.* import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.text.applyBladeBrush import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.conditional import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemDimens @@ -51,6 +46,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.MainScreenTestTags import com.tangem.feature.wallet.presentation.common.WalletPreviewData +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDropDownItems import com.tangem.feature.wallet.presentation.wallet.ui.components.fastForEach @@ -107,7 +103,7 @@ internal fun WalletCard(state: WalletCardState, isBalanceHidden: Boolean, modifi }, ), ) { - ConstraintLayout( + Box( modifier = Modifier .fillMaxWidth() .clip(TangemTheme.shapes.roundedCornersXMedium) @@ -136,77 +132,57 @@ internal fun WalletCard(state: WalletCardState, isBalanceHidden: Boolean, modifi @Suppress("DestructuringDeclarationWithTooManyEntries") @Composable -private fun ConstraintLayoutScope.CardContainer(state: WalletCardState, isBalanceHidden: Boolean, itemSize: IntSize) { - val (titleRef, balanceRef, additionalTextRef, imageRef) = createRefs() - - val contentVerticalMargin = TangemTheme.dimens.spacing12 - TitleText( - text = state.title, - modifier = Modifier.constrainAs(titleRef) { - start.linkTo(anchor = parent.start) - top.linkTo(anchor = parent.top, margin = contentVerticalMargin) - end.linkTo(anchor = imageRef.start) - width = Dimension.fillToConstraints - }, - ) - +private fun CardContainer(state: WalletCardState, isBalanceHidden: Boolean, itemSize: IntSize) { var balanceWidth by remember { mutableIntStateOf(value = Int.MIN_VALUE) } - Balance( - state = state, - isBalanceHidden = isBalanceHidden, - modifier = Modifier - .onSizeChanged { balanceWidth = it.width } - .padding(vertical = TangemTheme.dimens.spacing8) - .constrainAs(balanceRef) { - start.linkTo(anchor = parent.start) - top.linkTo(anchor = titleRef.bottom) - bottom.linkTo(anchor = additionalTextRef.top) - }, - ) - val additionalText by remember(state.additionalInfo, isBalanceHidden) { - mutableStateOf( - state.additionalInfo?.content?.orMaskWithStars( - maskWithStars = state.additionalInfo?.hideable == true && isBalanceHidden, - ), - ) - } - AdditionalInfo( - text = additionalText, - modifier = Modifier.constrainAs(additionalTextRef) { - start.linkTo(parent.start) - top.linkTo(balanceRef.bottom) - bottom.linkTo(anchor = parent.bottom, margin = contentVerticalMargin) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + Modifier + .weight(1f) + .padding(vertical = 12.dp), + ) { + TitleText( + text = state.title, + modifier = Modifier, + ) + Balance( + state = state, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .onSizeChanged { balanceWidth = it.width } + .padding(vertical = TangemTheme.dimens.spacing8), + ) - if (additionalText != null) { - width = if (state.imageResId != null) { - end.linkTo(imageRef.start) - Dimension.fillToConstraints - } else { - Dimension.wrapContent - } + val additionalText by remember(state.additionalInfo, isBalanceHidden) { + mutableStateOf( + state.additionalInfo?.content?.orMaskWithStars( + maskWithStars = state.additionalInfo?.hideable == true && isBalanceHidden, + ), + ) } - }, - ) + AdditionalInfo( + text = additionalText, + modifier = Modifier.conditional( + state.imageResId == null, + ) { fillMaxWidth() }, + ) + } - // If balance has a large width then image must be hidden - val hasSpaceForImage by remember(key1 = balanceWidth, key2 = itemSize.width) { - mutableStateOf(value = balanceWidth < itemSize.width * HALF_OF_ITEM_WIDTH) + // If balance has a large width then image must be hidden + val hasSpaceForImage by remember(key1 = balanceWidth, key2 = itemSize.width) { + mutableStateOf(value = balanceWidth < itemSize.width * HALF_OF_ITEM_WIDTH) + } + + if (hasSpaceForImage) { + Image( + id = state.imageResId, + modifier = Modifier.wrapContentWidth(), + ) + } } - - Image( - id = state.imageResId, - modifier = Modifier.constrainAs(imageRef) { - end.linkTo(parent.end) - bottom.linkTo(parent.bottom) - height = Dimension.fillToConstraints - visibility = if (hasSpaceForImage) { - Visibility.Visible - } else { - Visibility.Gone - } - }, - ) } @Suppress("LongParameterList") @@ -400,6 +376,16 @@ private fun Preview_WalletCard( private class WalletCardStateProvider : CollectionPreviewParameterProvider( collection = listOf( WalletPreviewData.walletCardContentState, + WalletPreviewData.walletCardContentState.copy( + balance = "0.00", + ), + WalletPreviewData.walletCardContentState.copy( + title = "Title", + additionalInfo = WalletAdditionalInfo( + hideable = false, + content = TextReference.Str("3 cards"), + ), + ), WalletPreviewData.walletCardContentState.copy( isBalanceFlickering = true, ), From 11798ca83f5ba1c20c4c9bc458df90bcbb2f0de1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 22 Jan 2026 13:14:35 +0000 Subject: [PATCH 162/172] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 370da97987..be628ea2c1 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.32-1382" +tangemBlockchainSdk = "develop-1371" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.32-574" +tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-541" +tangemHotSdk = "develop-539" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From ad8145f29ff6a1aabd28864a6e9b364522184222 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 22 Jan 2026 17:07:57 +0300 Subject: [PATCH 163/172] Updated on 2026-08-14 --- core/res/src/main/res/values-es/strings.xml | 24 ++++++++++++++++----- gradle/tangem_dependencies.toml | 2 +- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 70a5296c78..b15f9aba6a 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -327,6 +327,7 @@ Ahora OK Abrir en el navegador + o Tarjeta principal Anillo primario Frase de contraseña @@ -437,6 +438,7 @@ Este mecanismo protege contra los ataques de proximidad a una tarjeta o anillo. Aplicará un retardo entre la recepción y la ejecución de un comando. Contraseña Antes de ejecutar un comando que cambie el estado de la tarjeta, deberá ingresar una contraseña. + Actualizar a billetera de hardware NFT Programa de referidos Gire la pantalla de su dispositivo hacia abajo para ocultar y mostrar rápidamente los saldos @@ -450,6 +452,7 @@ Firmado Enviar comentarios Detalles + Puedes tener solo una billetera móvil a la vez. Puede actualizarse a una billetera fría Tangem o usarse junto con una nueva billetera fría. Compruebe su conexión a internet o cambia a una red diferente Condiciones de uso Dirección por defecto @@ -569,8 +572,8 @@ Para continuar, conceda a los contratos inteligentes de %1s permiso para utilizar su %2s Dar autorización Ilimitado - Su copia de seguridad se crea utilizando 2 o 3 tarjetas Tangem. Guárdalas en lugares seguros y separados para protegerlas de pérdidas o daños. - Copia de seguridad con varias tarjetas + Se generarán nuevas direcciones dentro de su billetera física, listas para su uso y totalmente seguras. + Nuevas direcciones Agregar billetera Tangem Su clave privada se genera directamente dentro de la tarjeta Tangem y nunca sale de ella. Generación de claves @@ -611,11 +614,15 @@ Frase de recuperación Para proteger su billetera con un código de acceso, complete el proceso de copia de seguridad. Para actualizar a una billetera de hardware, complete el proceso de copia de seguridad. + Actualice su billetera móvil a una billetera de hardware Tangem para obtener la máxima seguridad. Importe su billetera o transfiera fondos a una nueva. + Actualizar a billetera fría Sus claves privadas están encriptadas de forma segura y almacenadas en su teléfono Las claves privadas permanecen en su dispositivo Cree o restaure su billetera con una frase de recuperación. Copia de seguridad de la frase semilla Crear una billetera móvil + Transfiera su billetera móvil a una tarjeta o anillo Tangem en cualquier momento, de forma segura. + Actualización a billetera de hardware Importar billetera existente Esta frase de recuperación ya ha sido importada Billetera móvil @@ -633,14 +640,14 @@ Entiendo que si no he hecho una copia de seguridad de mi billetera antes de eliminarla, perderé el acceso a ella. Entiendo que quitar mi billetera no la borra, solo la elimina de mi dispositivo. Actualizar - No se requiere frase semilla. Su tarjeta o anillo Tangem se convierte en su copia de seguridad. - Copia de seguridad con Tangem + Todas sus direcciones y saldos permanecen completamente accesibles al actualizar su billetera móvil a una tarjeta o anillo Tangem. + Mismas direcciones No se puede actualizar. Ya existe una billetera en este dispositivo. Elija otro dispositivo. Este no se puede usar para la actualización. Se produjo un error durante la operación. Sus fondos permanecen seguros y totalmente accesibles durante el proceso Acceso a los fondos - Los datos de su billetera se borrarán de la aplicación y se almacenarán en su billetera de hardware. + Después de la actualización, su billetera móvil se eliminará de la aplicación y se almacenará en su billetera de hardware; su frase de recuperación permanecerá bajo su control. Seguridad general Las claves privadas se transferirán de la aplicación a su billetera de hardware Tangem Migración de claves @@ -1242,6 +1249,11 @@ Escanee la tarjeta/anillo que quiere configurar Olvidar la billetera Esto eliminará la wallet de la aplicación. La wallet en sí puede\nañadirse de nuevo. + Fácil de usar + Mantenga sus criptos seguras y sin conexión. Tan delgadas como una tarjeta de crédito, más seguras que una bóveda bancaria. + Sin seed phrase + La mejor billetera de hardware de su clase + Billetera Fría Tangem Nombre Haga que su token trabaje para Ud. Una tarifa de red es un pequeño pago necesario para procesar y confirmar su transacción en la blockchain. @@ -1613,6 +1625,7 @@ Hemos encontrado un error. Código de error: %s. Póngase en contacto con nuestro servicio de soporte. Use %s o escanee una tarjeta/anillo para tener acceso a su billetera Error de conexión: Esta dApp utiliza la versión 1.0 de Wallet Connect, que no es compatible. Asegúrese de que la dApp sea compatible con la versión 2.0 de Wallet Connect para conectarse correctamente. + Actualización a billetera de hardware Manténgase actualizado con las últimas funciones y noticias Alertas en tiempo real de transacciones, intercambios y actualizaciones críticas. Alertas de transacciones @@ -1982,6 +1995,7 @@ Entrega rápida Empiece con un solo toque Fácil y seguro + Sin seed phrase Fácil de usar Cree una billetera de hardware con Tangem. Delgada como una tarjeta bancaria, segura como una bóveda. Crear o importar una billetera de software diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index be628ea2c1..d9fd4bc8f9 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-1371" +tangemBlockchainSdk = "develop-1383" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 9d87f17c9169fe3c7c60c8f529af6975b6daa80d Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 22 Jan 2026 14:27:05 +0300 Subject: [PATCH 164/172] Updated on 2026-08-14 --- .../DefaultWalletManagersFacade.kt | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index c26cf7ee59..9a216d9b94 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -316,13 +316,13 @@ internal class DefaultWalletManagersFacade @Inject constructor( return UpdateWalletManagerResult.Unreachable() } - updateWalletManagerTokensIfNeeded(walletManager, extraTokens) + val isUpdated = updateWalletManagerTokensIfNeeded(walletManager, extraTokens) return try { if (userWallet is UserWallet.Cold && demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)) { updateDemoWalletManager(walletManager) } else { - updateWalletManager(walletManager) + updateWalletManager(walletManager = walletManager, forceUpdate = isUpdated) } } finally { walletManagersStore.store(userWallet.walletId, walletManager) @@ -336,9 +336,12 @@ internal class DefaultWalletManagersFacade @Inject constructor( return resultFactory.getDemoResult(walletManager, amount) } - private suspend fun updateWalletManager(walletManager: WalletManager): UpdateWalletManagerResult { + private suspend fun updateWalletManager( + walletManager: WalletManager, + forceUpdate: Boolean, + ): UpdateWalletManagerResult { return try { - walletManager.update() + walletManager.update(forceUpdate) resultFactory.getResult(walletManager) } catch (e: BlockchainSdkError.AccountNotFound) { @@ -768,14 +771,19 @@ internal class DefaultWalletManagersFacade @Inject constructor( .joinToString(separator = "|") } - private fun updateWalletManagerTokensIfNeeded(walletManager: WalletManager, tokens: Set) { - if (tokens.isEmpty()) return + private fun updateWalletManagerTokensIfNeeded( + walletManager: WalletManager, + tokens: Set, + ): Boolean { + if (tokens.isEmpty()) return false val tokensToAdd = sdkTokenConverter .convertList(tokens) .filter { it !in walletManager.cardTokens } walletManager.addTokens(tokensToAdd) + + return tokensToAdd.isNotEmpty() } private suspend fun readSmartContractMethods(): Map { From 45e78f3985aa002193f2685c47ad8b3532c4d74b Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 22 Jan 2026 17:22:06 +0300 Subject: [PATCH 165/172] Updated on 2026-08-14 --- features/manage-tokens/impl/build.gradle.kts | 3 ++ ...aultCustomTokenDerivationInputComponent.kt | 17 +++++++++ .../model/CustomTokenSelectorModel.kt | 10 ++++++ .../utils/CardanoDerivationPathValidator.kt | 36 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CardanoDerivationPathValidator.kt diff --git a/features/manage-tokens/impl/build.gradle.kts b/features/manage-tokens/impl/build.gradle.kts index 6fc7f5115a..4a0329dcad 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -12,6 +12,9 @@ android { } dependencies { + /* Tangem libraries */ + implementation(tangemDeps.card.core) + /* Project - API */ implementation(projects.features.manageTokens.api) implementation(projects.features.swapV2.api) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt index 7a8ab034ac..adef3798eb 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt @@ -8,6 +8,7 @@ import arrow.core.getOrElse import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.managetokens.ValidateDerivationPathUseCase +import com.tangem.features.managetokens.utils.CardanoDerivationPathValidator import com.tangem.domain.managetokens.model.exceptoin.DerivationPathValidationException import com.tangem.domain.models.network.Network import com.tangem.features.managetokens.component.CustomTokenDerivationInputComponent @@ -27,6 +28,8 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr private val validateDerivationPathUseCase: ValidateDerivationPathUseCase, ) : CustomTokenDerivationInputComponent, AppComponentContext by context { + private val cardanoDerivationPathValidator = CardanoDerivationPathValidator() + private val state: MutableStateFlow = MutableStateFlow( value = getInitialState(), ) @@ -73,6 +76,20 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr return } + val isInvalidForCardano = cardanoDerivationPathValidator.isInvalidForCardano( + networkId = params.selectedNetwork.id, + path = value, + ) + if (isInvalidForCardano) { + state.update { state -> + state.copy( + error = resourceReference(R.string.custom_token_invalid_derivation_path), + isConfirmEnabled = false, + ) + } + return + } + state.update { state -> state.copy( error = null, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt index 2b6df47f38..2cbfe3dc7a 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt @@ -33,6 +33,7 @@ import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork import com.tangem.features.managetokens.entity.item.DerivationPathUM import com.tangem.features.managetokens.entity.item.SelectableItemUM import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.utils.CardanoDerivationPathValidator import com.tangem.features.managetokens.utils.mapper.toCurrencyNetworkModel import com.tangem.features.managetokens.utils.mapper.toDerivationPathModel import com.tangem.lib.crypto.derivation.AccountNodeRecognizer @@ -56,6 +57,7 @@ internal class CustomTokenSelectorModel @Inject constructor( ) : Model() { private val params: CustomTokenSelectorComponent.Params = paramsContainer.require() + private val cardanoDerivationPathValidator = CardanoDerivationPathValidator() val dialogNavigation: SlotNavigation = SlotNavigation() @@ -142,6 +144,14 @@ internal class CustomTokenSelectorModel @Inject constructor( return@mapNotNullTo null // Skip default path } + val isInvalidForCardano = cardanoDerivationPathValidator.isInvalidForCardano( + networkId = selector.selectedNetwork.id, + path = network.derivationPath.value, + ) + if (isInvalidForCardano) { + return@mapNotNullTo null + } + network.toDerivationPathModel( isSelected = network.id == selector.selectedDerivationPath?.id, onSelectedStateChange = { diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CardanoDerivationPathValidator.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CardanoDerivationPathValidator.kt new file mode 100644 index 0000000000..9e3b11e015 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CardanoDerivationPathValidator.kt @@ -0,0 +1,36 @@ +package com.tangem.features.managetokens.utils + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.domain.models.network.Network + +/** + * Validator for Cardano derivation paths. + * Cardano requires exactly 5 nodes in derivation path according to CIP-1852 standard. + */ +internal class CardanoDerivationPathValidator { + + /** + * Checks if the derivation path is invalid for Cardano network. + * @return true if network is Cardano AND path has insufficient nodes + */ + fun isInvalidForCardano(networkId: Network.ID, path: String?): Boolean { + if (!isCardano(networkId) || path == null) return false + return !isValidDerivationPath(path) + } + + private fun isCardano(networkId: Network.ID): Boolean { + return networkId.toBlockchain() == Blockchain.Cardano + } + + private fun isValidDerivationPath(path: String): Boolean { + return runCatching { + DerivationPath(rawPath = path).nodes.size == REQUIRED_DERIVATION_NODES + }.getOrDefault(false) + } + + private companion object { + const val REQUIRED_DERIVATION_NODES = 5 + } +} \ No newline at end of file From 4eb9f0e87cb10ee897d5392a81c2ba4d3e095c5f Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 22 Jan 2026 14:33:20 +0300 Subject: [PATCH 166/172] Updated on 2026-08-14 --- .../fetcher/DefaultWalletAccountsFetcher.kt | 7 +- .../DefaultWalletAccountsFetcherTest.kt | 89 ++++++++++++++++++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt index bd25eb1add..12c40f608d 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 @@ -206,7 +206,12 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( store(userWalletId = userWalletId, response = response) - push(userWalletId = userWalletId, accounts = response.accounts) + val isFailed = push(userWalletId = userWalletId, accounts = response.accounts) == null + if (isFailed) { + // Clear ETags if push failed to avoid different state in the cache and API + eTagsStore.clear(userWalletId, ETagsStore.Key.WalletAccounts) + } + userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse()) return response diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt index 4f358b2d09..49586e40b0 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt @@ -18,6 +18,7 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* @@ -37,10 +38,10 @@ class DefaultWalletAccountsFetcherTest { private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() private val accountsResponseStore: AccountsResponseStore = mockk() private val accountsResponseStoreFlow = MutableStateFlow(value = null) - private val tokensMigration: DefaultMainAccountTokensMigration = mockk(relaxed = true) + private val tokensMigration: DefaultMainAccountTokensMigration = mockk() private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) - private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler = mockk(relaxUnitFun = true) + private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler = mockk() private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk() private val eTagsStore: ETagsStore = mockk(relaxUnitFun = true) @@ -253,6 +254,90 @@ class DefaultWalletAccountsFetcherTest { userTokensSaver.push(userWalletId = any(), response = any()) } } + + @Test + fun `GIVEN response with empty accounts and push request is failed THEN eTag will be cleared`() = runTest { + // Arrange + val savedAccountsResponse = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = null, + sort = null, + totalAccounts = 0, + totalArchivedAccounts = 0, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ) + val getResponse = ApiResponse.Error( + cause = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.NOT_MODIFIED, + message = null, + errorBody = null, + ), + headers = mapOf(ETAG_HEADER to listOf(eTag)), + ) + + accountsResponseStoreFlow.value = savedAccountsResponse + + coEvery { + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + } returns getResponse as ApiResponse + + coEvery { + fetchWalletAccountsErrorHandler.handle( + error = getResponse.cause, + userWalletId = userWalletId, + savedAccountsResponse = savedAccountsResponse, + pushWalletAccounts = any(), + storeWalletAccounts = any(), + ) + } returns FetchResult(savedAccountsResponse) + + coEvery { + defaultWalletAccountsResponseFactory.create(userWalletId = userWalletId, userTokensResponse = null) + } returns savedAccountsResponse + + coEvery { accountsResponseStore.updateData(any()) } returns savedAccountsResponse + + val saveResponse = ApiResponse.Error(ApiResponseError.TimeoutException()) + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = SaveWalletAccountsResponse(savedAccountsResponse.accounts), + ) + } returns saveResponse as ApiResponse + + // Act + fetcher.fetch(userWalletId) + + // Assert + coVerify { + accountsResponseStoreFactory.create(userWalletId = userWalletId) + accountsResponseStore.data + eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) + eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = eTag) + accountsResponseStore.updateData(any()) + fetchWalletAccountsErrorHandler.handle( + error = getResponse.cause, + userWalletId = userWalletId, + savedAccountsResponse = savedAccountsResponse, + pushWalletAccounts = any(), + storeWalletAccounts = any(), + ) + defaultWalletAccountsResponseFactory.create(userWalletId = userWalletId, userTokensResponse = null) + eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + body = SaveWalletAccountsResponse(savedAccountsResponse.accounts), + ) + eTagsStore.clear(userWalletId, ETagsStore.Key.WalletAccounts) + userTokensSaver.push(userWalletId = userWalletId, response = savedAccountsResponse.toUserTokensResponse()) + tokensMigration.migrate(userWalletId) + } + } } @Nested From d03e0a0ddc28d58895d0f66ca01ab22ff713f65e Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 23 Jan 2026 10:09:14 +0100 Subject: [PATCH 167/172] Updated on 2026-08-14 --- .../feed/model/feed/FeedComponentModel.kt | 2 +- .../feed/state/FeedMarketsBatchFlowManager.kt | 4 ++++ .../UpdateMarketChartsTransformer.kt | 20 +++++++++---------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 29cf1833dd..769c975519 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -120,7 +120,7 @@ internal class FeedComponentModel @Inject constructor( itemsByOrder = itemsByOrder, loadingStatesByOrder = loadingStatesByOrder, errorStatesByOrder = errorStatesByOrder, - onReloadAll = { marketsBatchFlowManager.reloadAll() }, + onReload = { marketsBatchFlowManager.reloadManager(it.toOrder()) }, analyticsEventHandler = analyticsEventHandler, ), UpdateTrendingNewsStateTransformer( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt index 5f229e09b8..4aa583ce84 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt @@ -121,6 +121,10 @@ internal class FeedMarketsBatchFlowManager( } } + fun reloadManager(order: TokenMarketListConfig.Order) { + managersByOrder[order]?.reload(currentAppCurrency().code) + } + fun updateQuotes() { managersByOrder.values.forEach { manager -> manager.updateQuotes(currentAppCurrency().code) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateMarketChartsTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateMarketChartsTransformer.kt index d556835e7a..f843d1b3eb 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateMarketChartsTransformer.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateMarketChartsTransformer.kt @@ -16,7 +16,7 @@ internal class UpdateMarketChartsTransformer( private val itemsByOrder: Map>, private val loadingStatesByOrder: Map, private val errorStatesByOrder: Map, - private val onReloadAll: () -> Unit, + private val onReload: (SortByTypeUM) -> Unit, private val analyticsEventHandler: AnalyticsEventHandler, ) : FeedListUMTransformer { @@ -30,27 +30,27 @@ internal class UpdateMarketChartsTransformer( when { hasError -> { put( - sortByType, - MarketChartUM.LoadingError( - onRetryClicked = onReloadAll, + key = sortByType, + value = MarketChartUM.LoadingError( + onRetryClicked = { onReload(sortByType) }, ), ) } isLoading -> { - put(sortByType, MarketChartUM.Loading) + put(key = sortByType, value = MarketChartUM.Loading) } items.isEmpty() -> { put( - sortByType, - MarketChartUM.LoadingError( - onRetryClicked = onReloadAll, + key = sortByType, + value = MarketChartUM.LoadingError( + onRetryClicked = { onReload(sortByType) }, ), ) } else -> { put( - sortByType, - MarketChartUM.Content( + key = sortByType, + value = MarketChartUM.Content( items = items, sortChartConfig = SortChartConfigUM( sortByType = sortByType, From c2a912b4560bc0eb4015b287039c0d9128eb43fd Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 23 Jan 2026 10:29:40 +0300 Subject: [PATCH 168/172] Updated on 2026-08-14 --- .../main/assets/configs/feature_toggles_config.json | 2 +- .../gasless/CreateAndSendGaslessTransactionUseCase.kt | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 72e92884a0..a2fc843b9c 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -57,7 +57,7 @@ }, { "name": "YIELD_SUPPLY_PENDING_TRANSACTIONS_ENABLED", - "version": "undefined" + "version": "5.33.0" }, { "name": "NEW_ONRAMP_MAIN_ENABLED", diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt index 4d2d4e9a0d..06f2f1a04b 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -188,13 +188,21 @@ class CreateAndSendGaslessTransactionUseCase( signedData: SignedGaslessData, transactionData: TransactionData.Uncompiled, ): String { - return gaslessTransactionRepository.signGaslessTransaction( + val txHash = gaslessTransactionRepository.signGaslessTransaction( network = context.tokenForFeeStatus.currency.network, gaslessTransactionData = context.gaslessTransactionData, signature = signedData.eip712Signature, userAddress = transactionData.sourceAddress, eip7702Auth = signedData.eip7702Auth, ).txHash + + (context.walletManager as? PendingTransactionHandler)?.addPendingGaslessTransaction( + transactionData = transactionData, + txHash = txHash, + contractAddress = transactionData.contractAddress, + ) + + return txHash } private suspend fun signHashes( From 48f8d2aaaaec766caded69ff1c74c245e172d900 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 23 Jan 2026 14:27:12 +0400 Subject: [PATCH 169/172] Updated on 2026-08-14 --- .../ui/tokens/TokenItemStateConverter.kt | 17 +++++----- .../converter/TokenListStateConverter.kt | 4 +-- ....kt => YieldSupplyPromoBannerConverter.kt} | 11 +++---- ...=> YieldSupplyPromoBannerConverterTest.kt} | 32 +++++++++---------- 4 files changed, 31 insertions(+), 33 deletions(-) rename features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/{YieldSupplyPromoBannerKeyConverter.kt => YieldSupplyPromoBannerConverter.kt} (85%) rename features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/{YieldSupplyPromoBannerKeyConverterTest.kt => YieldSupplyPromoBannerConverterTest.kt} (88%) diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt index b66a09112f..92a3fbf830 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt @@ -46,7 +46,7 @@ class TokenItemStateConverter( private val appCurrency: AppCurrency, private val yieldModuleApyMap: Map = emptyMap(), private val stakingApyMap: Map = emptyMap(), - private val yieldSupplyPromoBannerKey: String? = null, + private val promoCryptoCurrencyStatus: CryptoCurrencyStatus? = null, private val iconStateProvider: (CryptoCurrencyStatus) -> CurrencyIconState = { CryptoCurrencyToIconStateConverter().convert(it) }, @@ -75,7 +75,7 @@ class TokenItemStateConverter( createPromoBannerState( status = status, yieldModuleApyMap = yieldModuleApyMap, - yieldSupplyPromoBannerKey = yieldSupplyPromoBannerKey, + promoCryptoCurrencyStatus = promoCryptoCurrencyStatus, onApyLabelClick = onApyLabelClick, onYieldPromoCloseClick = onYieldPromoCloseClick, onYieldPromoShown = onYieldPromoShown, @@ -404,7 +404,7 @@ class TokenItemStateConverter( private fun createPromoBannerState( status: CryptoCurrencyStatus, yieldModuleApyMap: Map, - yieldSupplyPromoBannerKey: String?, + promoCryptoCurrencyStatus: CryptoCurrencyStatus?, onApyLabelClick: ((CryptoCurrencyStatus, ApySource, String) -> Unit)?, onYieldPromoCloseClick: (() -> Unit)?, onYieldPromoShown: ((cryptoCurrency: CryptoCurrency) -> Unit)?, @@ -414,13 +414,14 @@ class TokenItemStateConverter( if (status.value !is CryptoCurrencyStatus.Loaded) { return TokenItemState.PromoBannerState.Empty } - if (yieldSupplyPromoBannerKey == null || yieldSupplyPromoBannerKey != token.yieldSupplyKey() || - yieldModuleApyMap[token.yieldSupplyKey()] == null - ) { + if (promoCryptoCurrencyStatus == null || status.currency.id != promoCryptoCurrencyStatus.currency.id) { return TokenItemState.PromoBannerState.Empty } - val yieldSupplyApy = - yieldModuleApyMap[token.yieldSupplyKey()] ?: return TokenItemState.PromoBannerState.Empty + val tokenKey = "${token.network.rawId}_${token.contractAddress}" + val shouldIgnoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId) + val yieldSupplyApy = yieldModuleApyMap.entries.firstOrNull { (key, _) -> + key.equals(tokenKey, shouldIgnoreCase) + }?.value ?: return TokenItemState.PromoBannerState.Empty return TokenItemState.PromoBannerState.Content( title = resourceReference( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt index 2d42286d42..1a6b19c186 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt @@ -43,7 +43,7 @@ internal class TokenListStateConverter( private val shouldShowMainPromo: Boolean, ) : Converter { - private val yieldSupplyPromoBannerKeyConverter = YieldSupplyPromoBannerKeyConverter( + private val yieldSupplyPromoBannerConverter = YieldSupplyPromoBannerConverter( yieldModuleApyMap, shouldShowMainPromo, ) @@ -72,7 +72,7 @@ internal class TokenListStateConverter( private fun tokenStatusConverter(accountId: AccountId? = null) = TokenItemStateConverter( appCurrency = appCurrency, yieldModuleApyMap = yieldModuleApyMap, - yieldSupplyPromoBannerKey = yieldSupplyPromoBannerKeyConverter.convert(params), + promoCryptoCurrencyStatus = yieldSupplyPromoBannerConverter.convert(params), stakingApyMap = stakingAvailabilityMap, onItemClick = { _, status -> onTokenClick(accountId, status) }, onItemLongClick = { _, status -> onTokenLongClick(accountId, status) }, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerKeyConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerConverter.kt similarity index 85% rename from features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerKeyConverter.kt rename to features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerConverter.kt index f335bb30d1..ef64e543bd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerKeyConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerConverter.kt @@ -2,18 +2,17 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.feature.wallet.presentation.wallet.state.transformers.TokenConverterParams import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.converter.Converter import java.math.BigDecimal -internal class YieldSupplyPromoBannerKeyConverter( +internal class YieldSupplyPromoBannerConverter( private val yieldModuleApyMap: Map, private val shouldShowMainPromo: Boolean, -) : Converter { +) : Converter { - override fun convert(value: TokenConverterParams): String? { + override fun convert(value: TokenConverterParams): CryptoCurrencyStatus? { if (!shouldShowMainPromo) return null val currencies = when (value) { @@ -31,8 +30,8 @@ internal class YieldSupplyPromoBannerKeyConverter( val max = cryptoCurrencyStatuses.asSequence() .mapNotNull { status -> val token = status.currency as? CryptoCurrency.Token ?: return@mapNotNull null - val tokenKey = token.yieldSupplyKey() val shouldIgnoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId) + val tokenKey = "${token.network.rawId}_${token.contractAddress}" val matchedKey = yieldModuleApyMap.keys.firstOrNull { mapKey -> mapKey.equals(tokenKey, shouldIgnoreCase) @@ -42,6 +41,6 @@ internal class YieldSupplyPromoBannerKeyConverter( } .maxByOrNull { (status, _) -> status.value.amount ?: BigDecimal.ZERO } - return max?.second + return max?.first } } \ No newline at end of file diff --git a/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerKeyConverterTest.kt b/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerConverterTest.kt similarity index 88% rename from features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerKeyConverterTest.kt rename to features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerConverterTest.kt index 8584c74aac..b9e4370853 100644 --- a/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerKeyConverterTest.kt +++ b/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerConverterTest.kt @@ -5,7 +5,6 @@ import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.tokenlist.TokenList @@ -15,7 +14,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.transformers.TokenCon import org.junit.Test import java.math.BigDecimal -class YieldSupplyPromoBannerKeyConverterTest { +class YieldSupplyPromoBannerConverterTest { @Test fun `GIVEN promo disabled WHEN convert THEN return null`() { @@ -26,8 +25,8 @@ class YieldSupplyPromoBannerKeyConverterTest { portfolioId = PortfolioId.Wallet(UserWalletId("00")), tokenList = tokenList, ) - val converter = YieldSupplyPromoBannerKeyConverter( - yieldModuleApyMap = mapOf(token.yieldSupplyKey() to BigDecimal("0.10")), + val converter = YieldSupplyPromoBannerConverter( + yieldModuleApyMap = mapOf("${token.network.rawId}_${token.contractAddress}" to BigDecimal("0.10")), shouldShowMainPromo = false, ) @@ -44,7 +43,7 @@ class YieldSupplyPromoBannerKeyConverterTest { portfolioId = PortfolioId.Wallet(UserWalletId("00")), tokenList = ungroupedTokenList(status), ) - val converter = YieldSupplyPromoBannerKeyConverter( + val converter = YieldSupplyPromoBannerConverter( yieldModuleApyMap = emptyMap(), shouldShowMainPromo = true, ) @@ -62,8 +61,8 @@ class YieldSupplyPromoBannerKeyConverterTest { portfolioId = PortfolioId.Wallet(UserWalletId("00")), tokenList = ungroupedTokenList(statusActive), ) - val converter = YieldSupplyPromoBannerKeyConverter( - yieldModuleApyMap = mapOf(token.yieldSupplyKey() to BigDecimal("0.12")), + val converter = YieldSupplyPromoBannerConverter( + yieldModuleApyMap = mapOf("${token.network.rawId}_${token.contractAddress}" to BigDecimal("0.12")), shouldShowMainPromo = true, ) @@ -73,7 +72,7 @@ class YieldSupplyPromoBannerKeyConverterTest { } @Test - fun `GIVEN multiple candidates EVM case insensitive WHEN convert THEN return key of max amount`() { + fun `GIVEN multiple candidates EVM case insensitive WHEN convert THEN return status of max amount`() { val evmNetworkId = "ETH" val tokenSmall = createToken(networkId = evmNetworkId, backendId = evmNetworkId, contract = "0xAbCd") val tokenBig = createToken(networkId = evmNetworkId, backendId = evmNetworkId, contract = "0xBEEF") @@ -82,23 +81,22 @@ class YieldSupplyPromoBannerKeyConverterTest { val statusBig = createLoadedStatus(token = tokenBig, amount = BigDecimal("10.00"), isYieldActive = false) val apyMap = mapOf( - "${tokenSmall.network.backendId}_${tokenSmall.contractAddress.lowercase()}" to BigDecimal("0.05"), - "${tokenBig.network.backendId}_${tokenBig.contractAddress.uppercase()}" to BigDecimal("0.15"), + "${tokenSmall.network.rawId}_${tokenSmall.contractAddress.lowercase()}" to BigDecimal("0.05"), + "${tokenBig.network.rawId}_${tokenBig.contractAddress.uppercase()}" to BigDecimal("0.15"), ) val params = TokenConverterParams.Wallet( portfolioId = PortfolioId.Wallet(UserWalletId("00")), tokenList = ungroupedTokenList(statusSmall, statusBig), ) - val converter = YieldSupplyPromoBannerKeyConverter( + val converter = YieldSupplyPromoBannerConverter( yieldModuleApyMap = apyMap, shouldShowMainPromo = true, ) val result = converter.convert(params) - val expectedKey = "${tokenBig.network.backendId}_${tokenBig.contractAddress.uppercase()}" - assertThat(result).isEqualTo(expectedKey) + assertThat(result).isEqualTo(statusBig) } @Test @@ -107,14 +105,14 @@ class YieldSupplyPromoBannerKeyConverterTest { val token = createToken(networkId = nonEvmId, backendId = nonEvmId, contract = "rAbC123") val status = createLoadedStatus(token = token, amount = BigDecimal("3"), isYieldActive = false) - val mismatchedKey = "${token.network.backendId}_${token.contractAddress.lowercase()}" + val mismatchedKey = "${token.network.rawId}_${token.contractAddress.lowercase()}" val apyMap = mapOf(mismatchedKey to BigDecimal("0.07")) val params = TokenConverterParams.Wallet( portfolioId = PortfolioId.Wallet(UserWalletId("00")), tokenList = ungroupedTokenList(status), ) - val converter = YieldSupplyPromoBannerKeyConverter( + val converter = YieldSupplyPromoBannerConverter( yieldModuleApyMap = apyMap, shouldShowMainPromo = true, ) @@ -132,8 +130,8 @@ class YieldSupplyPromoBannerKeyConverterTest { portfolioId = PortfolioId.Wallet(UserWalletId("00")), tokenList = ungroupedTokenList(status), ) - val converter = YieldSupplyPromoBannerKeyConverter( - yieldModuleApyMap = mapOf(token.yieldSupplyKey() to BigDecimal("0.10")), + val converter = YieldSupplyPromoBannerConverter( + yieldModuleApyMap = mapOf("${token.network.rawId}_${token.contractAddress}" to BigDecimal("0.10")), shouldShowMainPromo = true, ) From d2f28313e990394f7a5b05d55fa71017dec376e6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 23 Jan 2026 14:19:58 +0300 Subject: [PATCH 170/172] Updated on 2026-08-14 --- .../list/CustomTokenFormUseCasesFacade.kt | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt index ca9d684abe..4d2071cf69 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt @@ -7,6 +7,7 @@ import arrow.core.raise.ensureNotNull import arrow.core.right import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.producer.SingleAccountListProducer import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase @@ -14,6 +15,7 @@ import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.managetokens.CheckIsCurrencyNotAddedUseCase import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase @@ -34,6 +36,7 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val singleAccountListSupplier: SingleAccountListSupplier, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, ) { suspend fun addCryptoCurrenciesUseCase(currency: CryptoCurrency): Either = when (mode) { @@ -62,18 +65,17 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( networkId: Network.ID, derivationPath: Network.DerivationPath, contractAddress: String?, - ): Either = when (mode) { - is AddCustomTokenMode.Account -> { - getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = mode.accountId.userWalletId, - networkId = networkId, - derivationPath = derivationPath, - contractAddress = contractAddress, - ) - .fold(ifEmpty = { true }, ifSome = { false }) - .right() - } - is AddCustomTokenMode.Wallet -> checkIsCurrencyNotAddedUseCase.invoke( + ): Either = if (accountsFeatureToggles.isFeatureEnabled) { + getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = mode.userWalletId, + networkId = networkId, + derivationPath = derivationPath, + contractAddress = contractAddress, + ) + .fold(ifEmpty = { true }, ifSome = { false }) + .right() + } else { + checkIsCurrencyNotAddedUseCase.invoke( userWalletId = mode.userWalletId, networkId = networkId, derivationPath = derivationPath, @@ -124,10 +126,15 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( val accountNodeRecognizer = AccountNodeRecognizer(blockchain) val index = accountNodeRecognizer.recognize(derivationPathValue)?.toInt() - ensureNotNull(index) { - val exception = IllegalStateException("Token has unrecognized derivation path: ${currency.id}") - Timber.e(exception) - exception + if (index == null) { + Timber.e( + "%s%s", + "Unable to determine account index for derivation path: $derivationPathValue. ", + "Use main account index instead.", + ) + DerivationIndex.Main.value + } else { + index } } From 96d5f31d82a84a311df037044a4e0d170d7133f3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 23 Jan 2026 17:39:23 +0500 Subject: [PATCH 171/172] Updated on 2026-08-14 --- .../news/repository/DefaultNewsRepository.kt | 7 +++-- .../domain/news/repository/NewsRepository.kt | 2 +- .../domain/news/usecase/GetNewsUseCase.kt | 12 ++++---- .../details/MarketsTokenDetailsModel.kt | 30 +++++++++---------- 4 files changed, 25 insertions(+), 26 deletions(-) diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt index ec3ad96779..6a43110f51 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -58,7 +58,7 @@ internal class DefaultNewsRepository( return updateViewedStatusForNewsBatch(newsBatchFlow, context.coroutineScope) } - override fun getNews(config: NewsListConfig, limit: Int): Flow> { + override fun getNews(config: NewsListConfig, limit: Int): Flow>> { return flow { val items = newsApi.getNews( page = FIRST_PAGE, @@ -73,7 +73,7 @@ internal class DefaultNewsRepository( }, onError = { error -> Timber.e(error, "Failed to get list of news") - emptyList() + throw error }, ) @@ -85,8 +85,9 @@ internal class DefaultNewsRepository( articlesToUpdate.map { article -> val isViewed = viewedFlags[article.id] == true article.copy(viewed = isViewed) - }.sortedBy { it.viewed } + }.sortedBy { it.viewed }.right() } + .catch { it.left() } } override fun observeDetailedArticles(): Flow> { diff --git a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt index 44266d3935..d122f330f6 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt @@ -28,7 +28,7 @@ interface NewsRepository { * * @param config config for getting news list */ - fun getNews(config: NewsListConfig, limit: Int): Flow> + fun getNews(config: NewsListConfig, limit: Int): Flow>> /** * Observes cached detailed articles. diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt index adaa426bfd..66b61406a3 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt @@ -8,11 +8,9 @@ import kotlinx.coroutines.flow.Flow class GetNewsUseCase(private val repository: NewsRepository) { - fun getNews(limit: Int, newsListConfig: NewsListConfig): Either>> = - Either.catch { - repository.getNews( - config = newsListConfig, - limit = limit, - ) - } + fun getNews(limit: Int, newsListConfig: NewsListConfig): Flow>> = + repository.getNews( + config = newsListConfig, + limit = limit, + ) } \ No newline at end of file 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 f1c5bb2319..b3675953a2 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 @@ -39,6 +39,7 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.feed.components.market.details.analytics.MarketTokenAnalyticsEvent import com.tangem.features.feed.impl.R +import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.feed.model.market.details.converter.DescriptionConverter import com.tangem.features.feed.model.market.details.converter.ExchangeItemStateConverter @@ -46,7 +47,6 @@ import com.tangem.features.feed.model.market.details.converter.TokenMarketInfoCo import com.tangem.features.feed.model.market.details.formatter.* import com.tangem.features.feed.model.market.details.state.QuotesStateUpdater import com.tangem.features.feed.model.market.details.state.TokenNetworksState -import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM import com.tangem.lib.crypto.BlockchainUtils @@ -313,8 +313,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( snapshot = null, tokenIds = listOf(params.token.id.value), ), - ).onRight { listOfArticles -> - listOfArticles.collect { articles -> + ).collect { result -> + result.onRight { articles -> state.update { marketsTokenDetailsUM -> val relatedNews = shortArticleToArticleConfigUMConverter.convert(articles) marketsTokenDetailsUM.copy( @@ -343,19 +343,19 @@ internal class MarketsTokenDetailsModel @Inject constructor( ), ) } + }.onLeft { throwable -> + val (code, message) = if (throwable is ApiResponseError.HttpException) { + throwable.code.numericCode to throwable.message.orEmpty() + } else { + null to "" + } + analyticsEventHandler.send( + MarketTokenAnalyticsEvent.TokenNewsLoadError( + code = code, + message = message, + ), + ) } - }.onLeft { throwable -> - val (code, message) = if (throwable is ApiResponseError.HttpException) { - throwable.code.numericCode to throwable.message.orEmpty() - } else { - null to "" - } - analyticsEventHandler.send( - MarketTokenAnalyticsEvent.TokenNewsLoadError( - code = code, - message = message, - ), - ) } } } From 0747bf7ce08e0c8172f3f7150d5fa8cca822213a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 23 Jan 2026 15:50:27 +0300 Subject: [PATCH 172/172] Updated on 2026-08-14 --- .../ui/account/AccountCryptoPortfolioItemStateConverter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt index 376d0c8d8e..943fb98735 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt @@ -116,7 +116,7 @@ class AccountCryptoPortfolioItemStateConverter( ) } - private fun AccountId.toItemId() = this.userWalletId.stringValue + this.value + private fun AccountId.toItemId() = this.value private fun BigDecimal.getPriceChangeType(): PriceChangeType = PriceChangeConverter.fromBigDecimal(value = this)