From 752aaeecd7848cda2cd09d444281bf758d92ff6a Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 17 Dec 2025 14:21:08 +0000 Subject: [PATCH 01/41] 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 02/41] 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 03/41] 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 04/41] 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 05/41] 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 06/41] 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 07/41] 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 08/41] 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 09/41] 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 10/41] 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 11/41] 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 12/41] 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 13/41] 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 14/41] 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 15/41] 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 16/41] 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 17/41] 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 18/41] 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 19/41] 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 20/41] 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 21/41] 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 22/41] 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 23/41] 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 24/41] 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 25/41] 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 26/41] 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 27/41] 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 28/41] 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 29/41] 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 30/41] 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 31/41] 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 32/41] 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 33/41] 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 34/41] 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 35/41] 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 36/41] 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 37/41] 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 38/41] 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 39/41] 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 40/41] 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 41/41] 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