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/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/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/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) } 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/di/domain/StakingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt index 08a4bc7a3b..a823079f33 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 @@ -94,12 +94,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, ) } 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/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/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..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( @@ -311,7 +331,7 @@ internal class ChildFactory @Inject constructor( params = StakingComponent.Params( userWalletId = route.userWalletId, cryptoCurrency = route.cryptoCurrency, - yieldId = route.yieldId, + integrationId = route.integrationId, ), componentFactory = stakingComponentFactory, ) @@ -424,6 +444,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/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..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 @@ -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 @@ -112,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}" + @@ -208,8 +210,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/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), 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/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..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 @@ -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,9 @@ 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.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -82,6 +86,7 @@ private fun TrendingArticle(articleConfigUM: ArticleConfigUM) { style = TangemTheme.typography.h3, maxLines = 3, overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, ) SpacerH(8.dp) @@ -100,6 +105,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/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 dedbd42301..424ce82802 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 @@ -23,7 +23,8 @@ import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingOption -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 @@ -247,9 +248,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 } @@ -281,10 +282,11 @@ class TokenItemStateConverter( val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit val rateInfo = when (val stakingOptions = stakingAvailability.option) { - is StakingOption.P2P -> { - // P2P or no balance: use preferred validators - // TODO add p2p logic - null + is StakingOption.P2PEthPool -> { + RewardInfo( + rate = stakingOptions.apy, + type = RewardType.APY, + ) } is StakingOption.StakeKit -> if (stakeKitBalance != null) { val validatorsByAddress = stakingOptions.yield.validators.associateBy { it.address } @@ -458,7 +460,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..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 @@ -15,7 +16,7 @@ data class P2PEthPoolVaultsResponse( ) /** - * Network identifier in P2P API + * Network identifier in P2PEthPool API */ @JsonClass(generateAdapter = false) enum class P2PEthPoolNetworkDTO { @@ -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/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/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/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/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/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/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/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..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 @@ -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) @@ -92,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), @@ -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..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 @@ -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,20 +142,23 @@ 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), - text = description.resolveReference(), + .clip(CircleShape) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_TOOLTIP_ICON), + text = description, content = { contentModifier -> Icon( modifier = contentModifier.size(16.dp), @@ -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/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/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/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/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/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/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt index 1f2f75086b..4a7f708311 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 @@ -26,10 +26,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, private val stakingFeatureToggles: StakingFeatureToggles, @@ -44,7 +44,7 @@ internal class DefaultP2PEthPoolRepository( override suspend fun fetchVaults(network: P2PEthPoolNetwork) { val vaults = if (stakingFeatureToggles.isEthStakingEnabled) { getVaults(network).getOrElse { error -> - Timber.e("Error fetching P2P vaults: $error") + Timber.e("Error fetching P2PEthPool vaults: $error") emptyList() } } else { @@ -56,8 +56,7 @@ internal class DefaultP2PEthPoolRepository( override suspend fun getVaults(network: P2PEthPoolNetwork): Either> = either { withContext(dispatchers.io) { - val response = p2pApi.getVaults(network.value) - when (response) { + when (val response = p2pEthPoolApi.getVaults(network.value)) { is ApiResponse.Success -> { val data = response.data ensure(data.error == null) { @@ -83,7 +82,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 @@ -108,7 +107,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 @@ -140,7 +139,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 @@ -161,7 +160,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 @@ -182,7 +181,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 @@ -204,7 +203,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, @@ -224,6 +223,10 @@ internal class DefaultP2PEthPoolRepository( } } + override fun getVaultsFlow(): Flow> { + return p2pEthPoolVaultsStore.get() + } + override fun getStakingAvailability(): Flow { return getVaultsFlow() .distinctUntilChanged() @@ -231,7 +234,7 @@ internal class DefaultP2PEthPoolRepository( if (vaults.isEmpty()) { return@map StakingAvailability.TemporaryUnavailable } else { - StakingAvailability.Available(StakingOption.P2P(vaults)) + StakingAvailability.Available(StakingOption.P2PEthPool(vaults)) } } } @@ -241,15 +244,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 62% 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..9940c0d306 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 @@ -8,17 +8,18 @@ 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 P2P ETH Pool API response to [StakingBalance.Data.P2P] */ -internal object P2PStakingBalanceConverter { +/** Converts P2PEthPool API response to [StakingBalance] */ +internal object P2PEthPoolStakingBalanceConverter { - fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance.Data.P2P { + fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance { 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 +28,40 @@ internal object P2PStakingBalanceConverter { exitQueue = convertExitQueue(response.exitQueue), ) - return StakingBalance.Data.P2P( - 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): 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..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 P2P Vault DTO to Domain model + * 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/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 95f7430209..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 @@ -75,13 +75,13 @@ internal object StakingDataModule { @Provides @Singleton fun provideP2PEthPoolRepository( - p2pApi: P2PEthPoolApi, + p2pEthPoolApi: P2PEthPoolApi, p2pEthPoolVaultsStore: P2PEthPoolVaultsStore, dispatchers: CoroutineDispatcherProvider, stakingFeatureToggles: StakingFeatureToggles, ): P2PEthPoolRepository { return DefaultP2PEthPoolRepository( - p2pApi = p2pApi, + p2pEthPoolApi = p2pEthPoolApi, p2pEthPoolVaultsStore = p2pEthPoolVaultsStore, dispatchers = dispatchers, stakingFeatureToggles = stakingFeatureToggles, 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..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 @@ -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, storing empty balances") + p2PEthPoolBalancesStore.storeEmpty(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 86% 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..35f65f95da 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, ) @@ -90,6 +90,15 @@ internal class DefaultP2PBalancesStore( } } + 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, @@ -108,7 +117,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 +161,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() } @@ -187,5 +196,8 @@ internal class DefaultP2PBalancesStore( } } + 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/P2PBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt similarity index 86% 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..06d300af6e 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> @@ -23,6 +23,8 @@ interface P2PBalancesStore { 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 a0bacfe34f..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 @@ -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 { + 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/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/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/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/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/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/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..052cb4509b --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt @@ -0,0 +1,79 @@ +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 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: StakingActionArgs = StakingActionArgs( + amountRequirement = StakingAmountRequirement( + isRequired = true, + minimum = DEFAULT_MINIMUM_STAKE, + maximum = null, + ), + isPartialAmountDisabled = false, + ) + + override val exitArgs: StakingActionArgs = StakingActionArgs( + amountRequirement = StakingAmountRequirement( + isRequired = true, + minimum = null, + maximum = null, + ), + isPartialAmountDisabled = false, + ) + + // Metadata + + override val warmupPeriodDays: Int = 0 + + override val cooldownPeriodDays: Int = DEFAULT_COOLDOWN_DAYS + + override val rewardSchedule: RewardSchedule = RewardSchedule.DAY + + override val rewardClaiming: RewardClaiming = 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..fa2d4542a7 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt @@ -0,0 +1,99 @@ +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 + +/** + * 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: StakingActionArgs = yield.args.enter.toStakingActionArgs() + + override val exitArgs: StakingActionArgs? = yield.args.exit?.toStakingActionArgs() + + // Metadata + + override val warmupPeriodDays: Int = yield.metadata.warmupPeriod.days + + override val cooldownPeriodDays: Int? = yield.metadata.cooldownPeriod?.days + + override val rewardSchedule: RewardSchedule = yield.metadata.rewardSchedule.toRewardSchedule() + + 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/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..37f76ede7f --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt @@ -0,0 +1,57 @@ +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 java.math.BigDecimal + +/** + * Strategy interface for staking integrations. + * Abstracts over StakeKit and P2PEthPool staking providers. + */ +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: StakingActionArgs? + + val exitArgs: StakingActionArgs? + + // Metadata + + val warmupPeriodDays: Int + + val cooldownPeriodDays: Int? + + val rewardSchedule: RewardSchedule + + val rewardClaiming: 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/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/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()) { 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 dbcfde3de2..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,30 +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.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 @@ -32,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 { @@ -54,13 +58,23 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( blockchain = null, source = "Market", ), + onBackClicked = { onChildBack() }, ), ), ) } - override fun onMarketOpenClick(sortBy: SortByTypeUM) { - innerRouter.push(FeedEntryChildFactory.Child.TokenList) + override fun onMarketOpenClick(sortBy: SortByTypeUM?) { + innerRouter.push( + route = FeedEntryChildFactory.Child.TokenList( + params = DefaultMarketsTokenListComponent.Params( + onBackClicked = { onChildBack() }, + onTokenClick = { token, currency -> onMarketItemClick(token, currency) }, + preselectedSortType = sortBy ?: SortByTypeUM.Rating, + shouldAlwaysShowSearchBar = sortBy == null, + ), + ), + ) } override fun onArticleClick(articleId: Int) { @@ -72,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( @@ -96,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 081ec2ab4d..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 @@ -24,7 +26,7 @@ internal class FeedEntryChildFactory @Inject constructor() { @Serializable @Immutable - data object TokenList : Child + data class TokenList(val params: DefaultMarketsTokenListComponent.Params) : Child @Serializable @Immutable @@ -43,23 +45,22 @@ 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 -> { 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/feed/DefaultFeedComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt index 0b6ca24074..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.searchBar) + 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 ca5d351c48..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,27 +1,69 @@ 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.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.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 kotlinx.serialization.Serializable -@Suppress("UnusedPrivateProperty") // TODO will be remove in next PR internal class DefaultMarketsTokenListComponent( appComponentContext: AppComponentContext, - private val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit)? = null, -) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + private val params: Params, +) : 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() { - } + @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/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 f00b75a4b1..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,8 @@ 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 import dagger.hilt.InstallIn @@ -17,4 +19,14 @@ internal interface ModelModule { @IntoMap @ClassKey(FeedComponentModel::class) fun bindsFeedComponentModel(model: FeedComponentModel): Model + + @Binds + @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 6bc58aab6e..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.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 6b7fb68da4..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 @@ -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 @@ -18,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.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 @@ -65,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 }, @@ -136,12 +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 = { }, + onBarClick = { params.feedClickIntents.onMarketOpenClick(null) }, ), feedListCallbacks = FeedListCallbacks( onSearchClick = {}, @@ -304,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 7e518f9c26..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,14 +2,14 @@ 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.model.market.list.state.SortByTypeUM /** * Callback interface for feed model navigation actions. */ 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/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 new file mode 100644 index 0000000000..19e70cf231 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt @@ -0,0 +1,341 @@ +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.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 +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.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 +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 }, + onBackClick = params.onBackClicked, + ) + } + + 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 containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED) + 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() { + 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 { + 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) + // 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() + } + }.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..2bd7212326 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/analytics/MarketsListAnalyticsEvent.kt @@ -0,0 +1,51 @@ +package com.tangem.features.feed.model.market.list.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +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, + ) : 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/ui/market/state/MarketsListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/MarketsListUM.kt similarity index 81% 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/model/market/list/state/MarketsListUM.kt index 88dc1834e7..9529c3db53 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/model/market/list/state/MarketsListUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.ui.market.state +package com.tangem.features.feed.model.market.list.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.markets.models.MarketsListItemUM @@ -10,22 +10,21 @@ 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, - val searchBar: SearchBarUM, + val marketsSearchBar: MarketsSearchBar, val selectedSortBy: SortByTypeUM, val sortByBottomSheet: TangemBottomSheetConfig, val selectedInterval: TrendInterval, val onIntervalClick: (TrendInterval) -> Unit, val onSortByButtonClick: () -> Unit, - val stakingNotificationMaxApy: BigDecimal?, - val onStakingNotificationClick: () -> Unit, - val onStakingNotificationCloseClick: () -> 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)), @@ -34,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)), @@ -45,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/model/market/list/state/MarketsNotificationUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/MarketsNotificationUM.kt new file mode 100644 index 0000000000..f51b1dd0fa --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/MarketsNotificationUM.kt @@ -0,0 +1,22 @@ +package com.tangem.features.feed.model.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/model/market/list/state/SortByBottomSheetContentUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/SortByBottomSheetContentUM.kt new file mode 100644 index 0000000000..2b279aa592 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/state/SortByBottomSheetContentUM.kt @@ -0,0 +1,8 @@ +package com.tangem.features.feed.model.market.list.state + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent + +internal data class SortByBottomSheetContentUM( + val selectedOption: SortByTypeUM, + val onOptionClicked: (SortByTypeUM) -> Unit, +) : TangemBottomSheetConfigContent \ 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..c4632a41af --- /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.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 +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..92e8aeafea --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt @@ -0,0 +1,289 @@ +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.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 +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, + private val onBackClick: () -> 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.marketsSearchBar.searchBarUM.query + private set(value) = state.update { marketsListUM -> + marketsListUM.copy( + marketsSearchBar = marketsListUM.marketsSearchBar.copy( + searchBarUM = marketsListUM.marketsSearchBar.searchBarUM.copy(query = value), + ), + ) + } + + val isInSearchState + get() = state.value.marketsSearchBar.searchBarUM.isActive + + 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.marketsSearchBar.searchBarUM.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, + 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, + onIntervalClick = { selectedInterval = it }, + onSortByButtonClick = { isSortByBottomSheetShown = true }, + sortByBottomSheet = TangemBottomSheetConfig( + isShown = false, + onDismissRequest = { isSortByBottomSheetShown = false }, + content = SortByBottomSheetContentUM( + selectedOption = preselectedSortType(), + onOptionClicked = ::onBottomSheetOptionClicked, + ), + ), + marketsNotificationUM = null, + onSearchClicked = { changeSearchBarIsActive(true) }, + ) + + 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 + }, + ) + } + } + + 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/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 deleted file mode 100644 index cf87b0cefd..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryBottomSheetContent.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.tangem.features.feed.ui - -import androidx.compose.animation.AnimatedContent -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.features.feed.components.FeedEntryChildFactory - -@Composable -internal fun EntryBottomSheetContent( - stackState: ChildStack, - onHeaderSizeChange: (Dp) -> Unit, -) { - val density = LocalDensity.current - - Scaffold( - 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()) - } - } - }, - ) { currentState -> - currentState.Title() - } - }, - content = { contentPadding -> - AnimatedContent( - stackState.active.instance, - ) { 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 c35da92100..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 @@ -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 @@ -52,28 +51,23 @@ 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.model.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 @@ -269,6 +290,7 @@ private fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendi } } +@Suppress("LongMethod") @Composable private fun NewsContentBlock( feedListCallbacks: FeedListCallbacks, @@ -307,7 +329,7 @@ private fun NewsContentBlock( append(stringResourceSafe(R.string.feed_tangem_ai)) } }, - style = TangemTheme.typography.h3, + style = TangemTheme.typography.subtitle1, ) } }, @@ -315,21 +337,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(), ) { @@ -342,11 +366,19 @@ 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) } } @@ -388,10 +420,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 +506,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 +527,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..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 @@ -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 @@ -11,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.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import kotlinx.collections.immutable.* @Suppress("MagicNumber") @@ -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 3b613ccc86..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 @@ -3,15 +3,15 @@ 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.features.feed.ui.market.state.SortByTypeUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toPersistentList 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/FeedMarketsBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt index b053465040..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.state.MarketsListUM -import com.tangem.features.feed.ui.market.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/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/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 new file mode 100644 index 0000000000..0268627666 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt @@ -0,0 +1,379 @@ +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 +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.focus.FocusRequester +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.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 +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.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 kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay + +private const val SHOW_MORE_KEY = "privacyPolicy" + +@Composable +internal fun TopBarWithSearch( + buttonsEnabled: Boolean, + onBackClick: () -> Unit, + onSearchClick: () -> Unit, + marketsSearchBar: MarketsSearchBar, +) { + val background = LocalMainBottomSheetColor.current.value + val focusRequester: FocusRequester = remember { FocusRequester() } + + AnimatedContent( + targetState = !marketsSearchBar.shouldAlwaysShowSearchBar && !marketsSearchBar.searchBarUM.isActive, + ) { showAppBarWithBackIcon -> + if (showAppBarWithBackIcon) { + AppBarWithBackButtonAndIcon( + onBackClick = onBackClick, + backButtonEnabled = buttonsEnabled, + endButtonEnabled = buttonsEnabled, + 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 +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() && + state.isInSearchMode && + state.marketsSearchBar.searchBarUM.query.isNotEmpty(), + ) { + Column { + SpacerH8() + Text( + text = stringResourceSafe(id = R.string.markets_search_result_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + SpacerH12() + } + } + Column { + AnimatedVisibility(!state.isInSearchMode && !state.marketsSearchBar.shouldAlwaysShowSearchBar) { + 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 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 = {}, + ), + 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, + onIntervalClick = {}, + onSortByButtonClick = {}, + sortByBottomSheet = TangemBottomSheetConfig( + isShown = false, + onDismissRequest = {}, + content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {}, + ), + marketsNotificationUM = MarketsNotificationUM.YieldSupplyPromo( + onClick = {}, + onCloseClick = {}, + ), + onSearchClicked = {}, + ), + ) + } + } +} + +//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..86d36b10cb --- /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.model.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..3b4d9ab05d --- /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.model.market.list.state.SortByBottomSheetContentUM +import com.tangem.features.feed.model.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/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/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/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/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/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/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/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..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 @@ -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() @@ -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 a24e274c2e..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 @@ -45,13 +46,12 @@ 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.StakingApproval -import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.model.* 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.model.stakekit.action.StakingActionCommonType import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction +import com.tangem.domain.staking.repositories.P2PEthPoolRepository import com.tangem.domain.staking.utils.getValidatorsCount import com.tangem.domain.tokens.* import com.tangem.domain.transaction.error.GetFeeError @@ -135,6 +135,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, @@ -165,9 +166,19 @@ 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 -> { + // TODO p2p avoid network call + val vaults = p2pEthPoolRepository.getVaults().getOrElse { emptyList() } + P2PEthPoolIntegration(integrationId, vaults) + } } } @@ -195,7 +206,7 @@ internal class StakingModel @Inject constructor( return invalidatePendingTransactionsUseCase( balanceItems = stakeKitBalance?.balance?.items.orEmpty(), stakingActions = stakingActions, - token = yield.token, + token = integration.token, ).getOrElse { emptyList() } } @@ -206,7 +217,7 @@ internal class StakingModel @Inject constructor( stakingBalanceUpdater.create( cryptoCurrencyStatus, userWallet, - yield, + integration, ) } @@ -214,7 +225,7 @@ internal class StakingModel @Inject constructor( stakingFeeTransactionLoader.create( cryptoCurrencyStatus = cryptoCurrencyStatus, userWallet = userWallet, - yield = yield, + integration = integration, ) } @@ -222,7 +233,7 @@ internal class StakingModel @Inject constructor( stakingTransactionLoader.create( cryptoCurrencyStatus = cryptoCurrencyStatus, userWallet = userWallet, - yield = yield, + integration = integration, isAmountSubtractAvailable = isAmountSubtractAvailable, ) } @@ -281,7 +292,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 } @@ -294,12 +305,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, @@ -314,7 +325,7 @@ internal class StakingModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, minimumTransactionAmount = minimumTransactionAmount, actionType = uiState.value.actionType, - yield = yield, + integration = integration, ).let(::add) } } @@ -328,7 +339,7 @@ internal class StakingModel @Inject constructor( override fun getFee() { stateController.update( SetConfirmationStateLoadingTransformer( - yield = yield, + integration = integration, appCurrency = appCurrency, cryptoCurrency = cryptoCurrencyStatus.currency, ), @@ -480,7 +491,7 @@ internal class StakingModel @Inject constructor( } override fun onAmountEnterClick() { - if (yield.preferredValidators.isEmpty()) { + if (integration.preferredTargets.isEmpty()) { stateController.updateEvent( StakingEvent.ShowAlert(StakingAlertUM.NoAvailableValidators), ) @@ -488,8 +499,8 @@ internal class StakingModel @Inject constructor( if (uiState.value.actionType is StakingActionCommonType.Enter) { stateController.updateAll( ValidatorSelectChangeTransformer( - selectedValidator = null, - yield = yield, + selectedTarget = null, + integration = integration, ), ) } @@ -503,7 +514,7 @@ internal class StakingModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, minimumTransactionAmount = minimumTransactionAmount, value = value, - yield = yield, + integration = integration, ), ) } @@ -519,7 +530,7 @@ internal class StakingModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, minimumTransactionAmount = minimumTransactionAmount, actionType = uiState.value.actionType, - yield = yield, + integration = integration, ), ) } @@ -540,16 +551,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, ), ) } @@ -602,7 +613,10 @@ internal class StakingModel @Inject constructor( override fun onActiveStake(activeStake: BalanceState) { val networkId = cryptoCurrencyStatus.currency.network.rawId - val preferredValidators = yield.validators.filter { it.preferred } + val preferredValidators = (integration as? StakeKitIntegration)?.targets + ?.filterIsInstance() + ?.filter { it.delegate.preferred } + .orEmpty() val pendingActions = activeStake.pendingActions.mapNotNull { action -> if (action.type in listOf(StakingActionType.RESTAKE, StakingActionType.STAKE) && preferredValidators.isSingleItem() @@ -617,7 +631,7 @@ internal class StakingModel @Inject constructor( balanceType = activeStake.type, pendingActions = pendingActions, balanceState = activeStake, - validator = activeStake.validator, + target = activeStake.target, amountValue = activeStake.cryptoValue, ) onNextClick(activeStake) @@ -630,7 +644,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) @@ -799,7 +813,7 @@ internal class StakingModel @Inject constructor( isSubtractAvailable = isAmountSubtractAvailable, feeError = feeError, stakingError = stakingError, - yield = yield, + integration = integration, ), ) } @@ -922,7 +936,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( @@ -930,7 +944,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(), @@ -939,7 +953,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 }, ) @@ -1068,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 @@ -1240,7 +1265,7 @@ internal class StakingModel @Inject constructor( stateController.updateAll( SetInitialDataStateTransformer( clickIntents = this@StakingModel, - yield = yield, + integration = integration, isAnyTokenStaked = isAnyTokenStaked, cryptoCurrencyStatus = status, userWalletProvider = Provider { userWallet }, @@ -1259,7 +1284,7 @@ internal class StakingModel @Inject constructor( balanceState: BalanceState, pendingActions: ImmutableList = persistentListOf(), pendingAction: PendingAction? = pendingActions.firstOrNull(), - validator: Yield.Validator?, + target: StakingTarget?, amountValue: String, ) { stateController.updateAll( @@ -1272,11 +1297,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, @@ -1291,7 +1316,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/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/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..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 @@ -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() {} @@ -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/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..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,7 +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.stakekit.Yield +import com.tangem.domain.staking.model.StakingIntegration +import com.tangem.domain.staking.model.StakingTarget +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 @@ -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 } @@ -286,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/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..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 @@ -12,8 +12,8 @@ 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.stakekit.AddressArgument -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.StakingIntegration +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 @@ -26,7 +26,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 +96,11 @@ internal class AmountRequirementStateTransformer( return when (actionType) { is StakingActionCommonType.Enter -> { - val enterRequirements = yield.args.enter.args[Yield.Args.ArgType.AMOUNT] + val enterRequirements = integration.enterArgs?.amountRequirement 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?.amountRequirement exitRequirements?.getError(amountDecimal, R.string.staking_unstake_amount_requirement_error) } else -> null @@ -118,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 @@ -142,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/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..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 @@ -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 @@ -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 @@ -47,38 +48,53 @@ 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, ) - @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 = yield.args.enter.args[Yield.Args.ArgType.AMOUNT]?.minimum.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 bdda0a7ac5..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,14 +1,18 @@ 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 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.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState @@ -26,7 +30,7 @@ import java.math.BigDecimal internal class StakingInfoNotificationsFactory( private val cryptoCurrencyStatusProvider: Provider, - private val yield: Yield, + private val integration: StakingIntegration, private val isSubtractAvailable: Boolean, ) { @@ -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) { @@ -94,7 +115,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, @@ -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, + ) + }, + ), + ) } } @@ -213,18 +265,18 @@ 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?.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) } } 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 +300,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), @@ -287,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 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 21af44fe79..39bcf9c206 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,11 +1,12 @@ package com.tangem.features.staking.impl.presentation.state.utils import com.tangem.core.ui.extensions.* -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.common.RewardSchedule +import com.tangem.domain.staking.model.common.RewardType 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 @@ -13,57 +14,57 @@ 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, ) ?: stringReference(rewardSchedule.name.lowercase().capitalize()) - else -> null + RewardSchedule.UNKNOWN -> null } } -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/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/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/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/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/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/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")) 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/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/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 02aaf086d1..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 @@ -39,9 +40,8 @@ import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayoutScope import androidx.constraintlayout.compose.Dimension -import com.tangem.core.ui.components.FontSizeRange +import androidx.constraintlayout.compose.Visibility 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 @@ -69,89 +69,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 +83,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 +106,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 +130,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 + } + }, ) } @@ -282,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, 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"