diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 8a40e56185..96c353f84c 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -139,10 +139,11 @@ 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, + "ACCOUNTS_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/DialogScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt index bca0130819..57d70b597e 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt @@ -9,6 +9,7 @@ import com.tangem.screens.AlreadyUsedWalletDialogPageObject.thisIsMyWalletButton import com.tangem.screens.AlreadyUsedWalletDialogPageObject.title import com.tangem.screens.ScanWarningDialogPageObject import com.tangem.screens.onActionIsUnavailableDialog +import com.tangem.screens.onDataNotLoadedDialog import com.tangem.screens.onFailedTransactionDialog import io.qameta.allure.kotlin.Allure.step @@ -76,4 +77,16 @@ fun BaseTestCase.checkActionIsUnavailableDialog() { step("Assert 'Action is unavailable' dialog 'Ok' button is displayed") { onActionIsUnavailableDialog { okButton.assertIsDisplayed() } } +} + +fun BaseTestCase.checkDataNotLoadedDialog() { + step("Assert 'The data has not loaded yet' dialog title is displayed") { + onDataNotLoadedDialog { title.assertIsDisplayed() } + } + step("Assert 'The data has not loaded yet' dialog text is displayed") { + onDataNotLoadedDialog { text.assertIsDisplayed() } + } + step("Assert 'The data has not loaded yet' dialog 'Ok' button is displayed") { + onDataNotLoadedDialog { okButton.assertIsDisplayed() } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/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/DataNotLoadedDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DataNotLoadedDialogPageObject.kt new file mode 100644 index 0000000000..e7efab995c --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/DataNotLoadedDialogPageObject.kt @@ -0,0 +1,36 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.BaseDialogTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import com.tangem.common.ui.R as CommonUIR + +class DataNotLoadedDialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasTestTag(BaseDialogTestTags.TITLE) + hasText(getResourceString(CommonUIR.string.action_buttons_service_loading_alert_title)) + useUnmergedTree = true + } + + val text: KNode = child { + hasTestTag(BaseDialogTestTags.TEXT) + hasText(getResourceString(CommonUIR.string.action_buttons_service_loading_alert_message)) + useUnmergedTree = true + } + + val okButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_ok)) + } +} + +internal fun BaseTestCase.onDataNotLoadedDialog(function: DataNotLoadedDialogPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt index aa4b7b3647..0458682ee0 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt @@ -40,8 +40,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider } val addressTextFieldHint: KNode = child { - hasParent(withTestTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD)) - useUnmergedTree = true + hasTestTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD) } fun recipientNetworkCaution(network: String): KNode = child { @@ -95,10 +94,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 +116,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..80e61ca2c0 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendSelectNetworkFeeBottomSheetPageObject.kt @@ -0,0 +1,114 @@ +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 + } + + val chooseSpeedTitle: KNode = child { + hasTestTag(BaseBottomSheetTestTags.TITLE) + hasText(getResourceString(R.string.fee_selector_choose_speed_title)) + useUnmergedTree = true + } + + fun regularFeeSelectorItem(title: String): KNode = child { + hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_FEE_ITEM) + hasAnyDescendant(withText(title)) + 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 + } + + val confirmButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_confirm)) + 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/OrganizeTokensTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt index e4ac654ef2..fb132f5e1d 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt @@ -113,8 +113,8 @@ class OrganizeTokensTest : BaseTestCase() { } step("Check positions of tokens on 'Organize tokens' screen") { onOrganizeTokensScreen { - tokenWithTitleAndPosition(bitcoinTitle, 1).assertIsDisplayed() - tokenWithTitleAndPosition(ethereumTitle, 2).assertIsDisplayed() + tokenWithTitleAndPosition(bitcoinTitle, 0).assertIsDisplayed() + tokenWithTitleAndPosition(ethereumTitle, 1).assertIsDisplayed() } } } @@ -200,10 +200,10 @@ class OrganizeTokensTest : BaseTestCase() { } step("Check positions of tokens on 'Organize tokens' screen") { onOrganizeTokensScreen { - tokenWithTitleAndPosition(bitcoinTitle, 1).assertIsDisplayed() - tokenWithTitleAndPosition(ethereumTitle, 2).assertIsDisplayed() - tokenWithTitleAndPosition(polygonTitle, 3).assertIsDisplayed() - tokenWithTitleAndPosition(polExMaticTitle, 4).assertIsDisplayed() + tokenWithTitleAndPosition(bitcoinTitle, 0).assertIsDisplayed() + tokenWithTitleAndPosition(ethereumTitle, 1).assertIsDisplayed() + tokenWithTitleAndPosition(polygonTitle, 2).assertIsDisplayed() + tokenWithTitleAndPosition(polExMaticTitle, 3).assertIsDisplayed() } } step("Click 'By Balance' button") { @@ -213,10 +213,10 @@ class OrganizeTokensTest : BaseTestCase() { } step("Check positions of tokens by balance on 'Organize tokens' screen") { onOrganizeTokensScreen { - tokenWithTitleAndPosition(ethereumTitle, 1).assertIsDisplayed() - tokenWithTitleAndPosition(polExMaticTitle, 2).assertIsDisplayed() - tokenWithTitleAndPosition(polygonTitle, 3).assertIsDisplayed() - tokenWithTitleAndPosition(bitcoinTitle, 4).assertIsDisplayed() + tokenWithTitleAndPosition(ethereumTitle, 0).assertIsDisplayed() + tokenWithTitleAndPosition(polExMaticTitle, 1).assertIsDisplayed() + tokenWithTitleAndPosition(polygonTitle, 2).assertIsDisplayed() + tokenWithTitleAndPosition(bitcoinTitle, 3).assertIsDisplayed() } } step("Click 'Apply' button") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/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/MainScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt index fd03d3bd1e..e807c84361 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -6,9 +6,11 @@ import com.tangem.common.annotations.ApiEnv import com.tangem.common.annotations.ApiEnvConfig import com.tangem.common.constants.TestConstants.BITCOIN_ADDRESS import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT -import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.* -import com.tangem.common.utils.* +import com.tangem.common.utils.assertClipboardTextEquals +import com.tangem.common.utils.clearClipboard +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.scenarios.* diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt index a531a3cc1a..d14e14e295 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt @@ -12,10 +12,6 @@ import com.tangem.scenarios.goToQrCodeBottomSheet import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.* -import com.tangem.screens.onMainScreen -import com.tangem.screens.onSwapStoriesScreen -import com.tangem.screens.onSwapTokenScreen -import com.tangem.screens.onTokenDetailsScreen import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -84,8 +80,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) } @@ -239,7 +235,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() { waitForIdle() onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } } - step("Assert 'Receive' button is displayed") { + step("Click on 'Receive' button") { onTokenDetailsScreen { receiveButton().performClick() } } step("Go to QR code bottom sheet") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt new file mode 100644 index 0000000000..d5049ab273 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt @@ -0,0 +1,444 @@ +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("Click on '$marketSelectorItem' selector item") { + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSelectorItem).performClick() } + } + 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("Click on 'Confirm' button") { + onSendSelectNetworkFeeBottomSheet { confirmButton.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 'Choose speed' bottom sheet title is displayed") { + onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.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 'Choose speed' bottom sheet title is displayed") { + onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ab376d6124..bb289cd06c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -290,6 +290,17 @@ android:host="tangem.com" android:path="/pay-app" /> + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index abbd2d80de..52be79032f 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -356,7 +356,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private fun createReduxStore(): Store { return Store( - reducer = { action, state -> appReducer(action, state) }, + reducer = { action, state -> appReducer(action, requireNotNull(state)) }, middleware = AppState.getMiddleware(), state = AppState( daggerGraphState = DaggerGraphState( diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt index 71fcd29497..65315fc4cd 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt @@ -6,9 +6,7 @@ import com.tangem.tap.features.welcome.redux.WelcomeReducer import com.tangem.tap.proxy.redux.DaggerGraphReducer import org.rekotlin.Action -@Suppress("CanBeNonNullable") -fun appReducer(action: Action, state: AppState?): AppState { - requireNotNull(state) +fun appReducer(action: Action, state: AppState): AppState { if (action is AppAction.RestoreState) return action.state return AppState( diff --git a/app/src/main/java/com/tangem/tap/core/ui/DefaultDesignFeatureToggles.kt b/app/src/main/java/com/tangem/tap/core/ui/DefaultDesignFeatureToggles.kt new file mode 100644 index 0000000000..ca241efce8 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/core/ui/DefaultDesignFeatureToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.tap.core.ui + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.core.ui.DesignFeatureToggles +import javax.inject.Inject + +class DefaultDesignFeatureToggles @Inject constructor( + featureTogglesManager: FeatureTogglesManager, +) : DesignFeatureToggles { + override val isRedesignEnabled: Boolean = featureTogglesManager.isFeatureEnabled("APP_REDESIGN_ENABLED") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt b/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt index de5f7ef9e3..b5b3c5ea29 100644 --- a/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt +++ b/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt @@ -4,6 +4,7 @@ import androidx.compose.material3.SnackbarHostState import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.ui.DefaultUiMessageSender import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.EventMessageHandler @@ -23,12 +24,14 @@ internal object UiDependenciesModule { fun provideUiDependencies( vibratorHapticManager: VibratorHapticManager, appThemeModeHolder: AppThemeModeHolder, + designFeatureToggles: DesignFeatureToggles, ): UiDependencies { return object : UiDependencies { override val vibratorHapticManager = vibratorHapticManager override val appThemeModeHolder = appThemeModeHolder override val globalSnackbarHostState: SnackbarHostState = SnackbarHostState() override val eventMessageHandler: EventMessageHandler = EventMessageHandler() + override val designFeatureToggles: DesignFeatureToggles = designFeatureToggles } } diff --git a/app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt b/app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt new file mode 100644 index 0000000000..b02028c5bf --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt @@ -0,0 +1,16 @@ +package com.tangem.tap.di.core.ui + +import com.tangem.core.ui.DesignFeatureToggles +import com.tangem.tap.core.ui.DefaultDesignFeatureToggles +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface CoreUiBindsModule { + + @Binds + fun bindDesignFeatureToggles(impl: DefaultDesignFeatureToggles): DesignFeatureToggles +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt index 6e154f3fc1..e56953ffdc 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt @@ -115,7 +115,7 @@ object MarketsDomainModule { return FilterAvailableNetworksForWalletUseCase( userWalletsListManager = userWalletsListManager, userWalletsListRepository = userWalletsListRepository, - useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled, + shouldUseNewRepository = hotWalletFeatureToggles.isHotWalletEnabled, excludedBlockchains = excludedBlockchains, ) } diff --git a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt index 2fbc11170b..cf1a7707c6 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt @@ -142,22 +142,6 @@ internal object NFTDomainModule { return GetWalletNFTEnabledUseCase(walletsRepository) } - @Provides - @Singleton - fun provideClearNFTCacheUseCase( - nftCleaner: NFTCleaner, - currenciesRepository: CurrenciesRepository, - accountsFeatureToggles: AccountsFeatureToggles, - singleAccountListSupplier: SingleAccountListSupplier, - ): ObserveAndClearNFTCacheIfNeedUseCase { - return ObserveAndClearNFTCacheIfNeedUseCase( - nftCleaner = nftCleaner, - currenciesRepository = currenciesRepository, - accountsFeatureToggles = accountsFeatureToggles, - singleAccountListSupplier = singleAccountListSupplier, - ) - } - @Provides @Singleton fun provideGetNftCurrencyUseCase(nftRepository: NFTRepository): GetNFTCurrencyUseCase { diff --git a/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt index 55b1457a55..d3e9da60e7 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt @@ -6,39 +6,48 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) internal object NewsDomainModule { @Provides - @Singleton fun provideGetNewsCategoriesUseCase(repository: NewsRepository): GetNewsCategoriesUseCase { return GetNewsCategoriesUseCase(repository) } @Provides - @Singleton fun provideObserveNewsDetailsUseCase(repository: NewsRepository): ObserveNewsDetailsUseCase { return ObserveNewsDetailsUseCase(repository) } @Provides - @Singleton fun provideObserveTrendingNewsUseCase(repository: NewsRepository): ManageTrendingNewsUseCase { return ManageTrendingNewsUseCase(repository) } @Provides - @Singleton fun provideGetNewsListBatchFlowUseCase(repository: NewsRepository): GetNewsListBatchFlowUseCase { return GetNewsListBatchFlowUseCase(repository) } @Provides - @Singleton fun provideFetchTrendingNewsUseCase(repository: NewsRepository): FetchTrendingNewsUseCase { return FetchTrendingNewsUseCase(repository) } + + @Provides + fun provideMarkArticleAsViewedUseCase(repository: NewsRepository): MarkArticleAsViewedUseCase { + return MarkArticleAsViewedUseCase(repository) + } + + @Provides + fun provideToggleArticleLikedUseCase(repository: NewsRepository): ToggleArticleLikedUseCase { + return ToggleArticleLikedUseCase(repository) + } + + @Provides + fun provideGetNewsUseCase(repository: NewsRepository): GetNewsUseCase { + return GetNewsUseCase(repository) + } } \ 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/di/domain/TokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt index 7528f0f18b..5bb200ad04 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt @@ -282,11 +282,13 @@ internal object TokensDomainModule { currenciesRepository: CurrenciesRepository, multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, dispatchers: CoroutineDispatcherProvider, + currencyChecksRepository: CurrencyChecksRepository, ): GetBalanceNotEnoughForFeeWarningUseCase { return GetBalanceNotEnoughForFeeWarningUseCase( currenciesRepository = currenciesRepository, multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, dispatchers = dispatchers, + currencyChecksRepository = currencyChecksRepository, ) } @@ -294,9 +296,8 @@ internal object TokensDomainModule { @Singleton fun provideIsAmountSubtractAvailableUseCase( currenciesRepository: CurrenciesRepository, - dispatchers: CoroutineDispatcherProvider, ): IsAmountSubtractAvailableUseCase { - return IsAmountSubtractAvailableUseCase(currenciesRepository, dispatchers) + return IsAmountSubtractAvailableUseCase(currenciesRepository) } @Provides diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 8965f60090..ff1b6bb97a 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -5,12 +5,18 @@ import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.WalletAddressServiceRepository import com.tangem.domain.transaction.usecase.* +import com.tangem.domain.transaction.usecase.gasless.* import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -21,7 +27,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") @Module @InstallIn(SingletonComponent::class) internal object TransactionDomainModule { @@ -265,4 +271,126 @@ internal object TransactionDomainModule { ): SendLargeSolanaTransactionUseCase { return SendLargeSolanaTransactionUseCase(cardSdkConfigRepository, walletManagersFacade) } + + @Provides + @Singleton + fun provideGetAvailableFeeTokensUseCase( + gaslessTransactionRepository: GaslessTransactionRepository, + currenciesRepository: CurrenciesRepository, + getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + currencyChecksRepository: CurrencyChecksRepository, + ): GetAvailableFeeTokensUseCase { + return GetAvailableFeeTokensUseCase( + gaslessTransactionRepository = gaslessTransactionRepository, + currenciesRepository = currenciesRepository, + getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + currencyChecksRepository = currencyChecksRepository, + ) + } + + @Provides + @Singleton + fun provideGetFeeForGaslessUseCase( + walletManagersFacade: WalletManagersFacade, + gaslessTransactionRepository: GaslessTransactionRepository, + currenciesRepository: CurrenciesRepository, + getFeeUseCase: GetFeeUseCase, + getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + currencyChecksRepository: CurrencyChecksRepository, + ): GetFeeForGaslessUseCase { + return GetFeeForGaslessUseCase( + walletManagersFacade = walletManagersFacade, + demoConfig = DemoConfig, + gaslessTransactionRepository = gaslessTransactionRepository, + currenciesRepository = currenciesRepository, + getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + getFeeUseCase = getFeeUseCase, + currencyChecksRepository = currencyChecksRepository, + ) + } + + @Provides + @Singleton + fun provideGetFeeForTokenUseCase( + walletManagersFacade: WalletManagersFacade, + gaslessTransactionRepository: GaslessTransactionRepository, + currenciesRepository: CurrenciesRepository, + getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + currencyChecksRepository: CurrencyChecksRepository, + ): GetFeeForTokenUseCase { + return GetFeeForTokenUseCase( + gaslessTransactionRepository = gaslessTransactionRepository, + walletManagersFacade = walletManagersFacade, + demoConfig = DemoConfig, + currenciesRepository = currenciesRepository, + getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + currencyChecksRepository = currencyChecksRepository, + ) + } + + @Provides + @Singleton + fun provideIsGaslessFeeSupportedForNetwork( + currencyChecksRepository: CurrencyChecksRepository, + ): IsGaslessFeeSupportedForNetwork { + return IsGaslessFeeSupportedForNetwork(currencyChecksRepository) + } + + @Provides + @Singleton + fun provideCreateAndSendGaslessTransactionUseCase( + walletManagersFacade: WalletManagersFacade, + gaslessTransactionRepository: GaslessTransactionRepository, + getSingCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + cardSdkConfigRepository: CardSdkConfigRepository, + tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory, + ): CreateAndSendGaslessTransactionUseCase { + return CreateAndSendGaslessTransactionUseCase( + walletManagersFacade = walletManagersFacade, + getSingleCryptoCurrencyStatusUseCase = getSingCryptoCurrencyStatusUseCase, + gaslessTransactionRepository = gaslessTransactionRepository, + cardSdkConfigRepository = cardSdkConfigRepository, + getHotWalletSigner = tangemHotWalletSignerFactory::create, + ) + } + + @Provides + @Singleton + fun provideEstimateFeeForTokenUseCase( + walletManagersFacade: WalletManagersFacade, + gaslessTransactionRepository: GaslessTransactionRepository, + currenciesRepository: CurrenciesRepository, + getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + currencyChecksRepository: CurrencyChecksRepository, + ): EstimateFeeForTokenUseCase { + return EstimateFeeForTokenUseCase( + gaslessTransactionRepository = gaslessTransactionRepository, + walletManagersFacade = walletManagersFacade, + demoConfig = DemoConfig, + currenciesRepository = currenciesRepository, + getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + currencyChecksRepository = currencyChecksRepository, + ) + } + + @Provides + @Singleton + fun provideEstimateFeeForGaslessTxUseCase( + walletManagersFacade: WalletManagersFacade, + gaslessTransactionRepository: GaslessTransactionRepository, + currenciesRepository: CurrenciesRepository, + getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + estimateFeeUseCase: EstimateFeeUseCase, + currencyChecksRepository: CurrencyChecksRepository, + ): EstimateFeeForGaslessTxUseCase { + return EstimateFeeForGaslessTxUseCase( + gaslessTransactionRepository = gaslessTransactionRepository, + walletManagersFacade = walletManagersFacade, + demoConfig = DemoConfig, + currenciesRepository = currenciesRepository, + getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + estimateFeeUseCase = estimateFeeUseCase, + currencyChecksRepository = currencyChecksRepository, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt index 3301b9c21a..d74844c082 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt @@ -1,6 +1,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.blockaid.BlockAidGasEstimate +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.FeeRepository @@ -14,6 +15,8 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Suppress("TooManyFunctions") @@ -234,4 +237,18 @@ internal object YieldSupplyDomainModule { ): YieldSupplyGetAvailabilityUseCase { return YieldSupplyGetAvailabilityUseCase(yieldSupplyRepository) } + + @Provides + @Singleton + fun provideYieldSupplyPendingProcessorUseCase( + yieldSupplyRepository: YieldSupplyRepository, + singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + dispatcherProvider: CoroutineDispatcherProvider, + ): YieldSupplyPendingTracker { + return YieldSupplyPendingTracker( + yieldSupplyRepository = yieldSupplyRepository, + singleNetworkStatusFetcher = singleNetworkStatusFetcher, + coroutineScope = CoroutineScope(SupervisorJob() + dispatcherProvider.io), + ) + } } \ No newline at end of file diff --git a/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/network/auth/DefaultAuthProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt index 597915ff66..79a50c1403 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt @@ -59,6 +59,17 @@ internal class DefaultAuthProvider( } } + override fun getGaslessServiceApiKey(apiEnvironment: Provider): ProviderSuspend { + return ProviderSuspend { + when (apiEnvironment.invoke()) { + ApiEnvironment.DEV, + -> environmentConfigStorage.getConfigSync().gaslessTxApiKeyDev + ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().gaslessTxApiKey + else -> error("No gasless tx api config provided for ${apiEnvironment.invoke()}") + } ?: error("No gasless tx api config provided") + } + } + private suspend fun getWallets(): List { return if (shouldUseNewListRepository) { userWalletsListRepository.userWalletsSync() 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 d7f3a0cc33..f182a2555b 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,15 +41,11 @@ 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 -import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent -import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent +import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent import com.tangem.tap.features.details.ui.appcurrency.api.AppCurrencySelectorComponent import com.tangem.tap.features.details.ui.appsettings.api.AppSettingsComponent import com.tangem.tap.features.details.ui.cardsettings.api.CardSettingsComponent @@ -118,9 +117,10 @@ internal class ChildFactory @Inject constructor( private val tangemPayDetailsContainerComponentFactory: TangemPayDetailsContainerComponent.Factory, private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, private val kycComponentFactory: KycComponent.Factory, - private val yieldSupplyPromoComponentFactory: YieldSupplyPromoComponent.Factory, - private val yieldSupplyActiveComponentFactory: YieldSupplyActiveComponent.Factory, + private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory, private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val feedEntryComponentFactory: FeedEntryComponent.Factory, + private val feedFeatureToggle: FeedFeatureToggle, ) { @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -205,21 +205,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( @@ -312,7 +330,7 @@ internal class ChildFactory @Inject constructor( params = StakingComponent.Params( userWalletId = route.userWalletId, cryptoCurrency = route.cryptoCurrency, - yieldId = route.yieldId, + integrationId = route.integrationId, ), componentFactory = stakingComponentFactory, ) @@ -687,25 +705,25 @@ internal class ChildFactory @Inject constructor( componentFactory = kycComponentFactory, ) } - is AppRoute.YieldSupplyPromo -> { + is AppRoute.YieldSupplyEntry -> { createComponentChild( context = context, - params = YieldSupplyPromoComponent.Params( - userWalletId = route.userWalletId, - currency = route.cryptoCurrency, - apy = route.apy, - ), - componentFactory = yieldSupplyPromoComponentFactory, - ) - } - is AppRoute.YieldSupplyActive -> { - createComponentChild( - context = context, - params = YieldSupplyActiveComponent.Params( + params = YieldSupplyEntryComponent.Params( userWalletId = route.userWalletId, cryptoCurrency = route.cryptoCurrency, + apy = route.apy, ), - componentFactory = yieldSupplyActiveComponentFactory, + componentFactory = yieldSupplyEntryComponentFactory, + ) + } + is AppRoute.NewsDetails -> { + createComponentChild( + context = context, + params = FeedEntryRoute.NewsDetail( + articleId = route.newsId, + preselectedArticlesId = listOf(route.newsId), + ), + componentFactory = feedEntryComponentFactory, ) } } diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 097b57b518..27c065e869 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -6,6 +6,8 @@ import com.tangem.common.routing.DeepLinkRoute import com.tangem.common.routing.DeepLinkScheme import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler +import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler @@ -50,6 +52,8 @@ internal class DeepLinkFactory @Inject constructor( private val swapDeepLink: SwapDeepLinkHandler.Factory, private val promoDeepLink: PromoDeeplinkHandler.Factory, private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory, + private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory, + private val feedFeatureToggle: FeedFeatureToggle, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -102,7 +106,7 @@ internal class DeepLinkFactory @Inject constructor( private fun launchDeepLink(deeplinkUri: Uri, coroutineScope: CoroutineScope, isFromOnNewIntent: Boolean) { when (deeplinkUri.scheme) { - DeepLinkScheme.Https.scheme -> handleHttpDeepLinks(deeplinkUri) + DeepLinkScheme.Https.scheme -> handleHttpDeepLinks(deeplinkUri, coroutineScope) DeepLinkScheme.Tangem.scheme -> handleTangemDeepLinks(deeplinkUri, coroutineScope, isFromOnNewIntent) DeepLinkScheme.WalletConnect.scheme -> walletConnectDeepLink.create(deeplinkUri) else -> { @@ -116,10 +120,18 @@ internal class DeepLinkFactory @Inject constructor( } } - private fun handleHttpDeepLinks(deeplinkUri: Uri) { - if (deeplinkUri.host == DeepLinkRoute.PayApp.host && deeplinkUri.path?.startsWith("/pay-app") == true) { - onboardVisaDeepLink.create(deeplinkUri) - return + private fun handleHttpDeepLinks(deeplinkUri: Uri, coroutineScope: CoroutineScope) { + if (deeplinkUri.host == DeepLinkRoute.PayApp.host) { + when { + deeplinkUri.path?.startsWith("/pay-app") == true -> { + onboardVisaDeepLink.create(deeplinkUri) + return + } + deeplinkUri.path?.startsWith("/news") == true && feedFeatureToggle.isFeedEnabled -> { + newsDetailsDeepLink.create(coroutineScope, deeplinkUri) + return + } + } } } diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index d91dbb1dbe..6ec92245ac 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -16,7 +16,7 @@ diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index ea548398d0..19782fca91 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -4,6 +4,8 @@ import android.net.Uri import com.tangem.common.routing.AppRoute import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler +import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler @@ -81,6 +83,12 @@ class DeepLinkFactoryTest { private val cardSdkProvider = mockk(relaxed = true) { every { sdk.uiVisibility() } returns MutableStateFlow(false) } + + private val newsDeeplink = mockk(relaxed = true) { + every { create(any(), any()) } returns mockk() + } + private val feedFeatureToggle = mockk() + private val mockedUri = mockk(relaxed = true) private val isFromOnNewIntent: Boolean = false @@ -103,6 +111,8 @@ class DeepLinkFactoryTest { swapDeepLink = swapDeepLinkFactory, promoDeepLink = promoDeepLinkFactory, onboardVisaDeepLink = onboardVisaDeepLink, + newsDetailsDeepLink = newsDeeplink, + feedFeatureToggle = feedFeatureToggle, ) @OptIn(ExperimentalCoroutinesApi::class) diff --git a/common/routing/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 7242c08646..2be124d092 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 @@ -209,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( @@ -456,16 +457,12 @@ sealed class AppRoute(val path: String) : Route { data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc") @Serializable - data class YieldSupplyPromo( + data class YieldSupplyEntry( val userWalletId: UserWalletId, val cryptoCurrency: CryptoCurrency, val apy: String, - ) : AppRoute(path = "/yield_supply_promo/${userWalletId.stringValue}/${cryptoCurrency.symbol}") + ) : AppRoute(path = "/yield_supply_entry/${userWalletId.stringValue}/${cryptoCurrency.symbol}") @Serializable - data class YieldSupplyActive( - val userWalletId: UserWalletId, - val cryptoCurrency: CryptoCurrency, - val apy: String, - ) : AppRoute(path = "/yield_supply_active/${userWalletId.stringValue}/${cryptoCurrency.symbol}") + data class NewsDetails(val newsId: Int) : AppRoute(path = "/news_details/$newsId") } \ No newline at end of file diff --git a/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt b/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt index 0319e1c59f..1ba2efe988 100644 --- a/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt +++ b/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt @@ -31,5 +31,9 @@ object TangemBlogUrlBuilder { data object WhatWalletToChoose : Post { override val path: String = "mobile-wallet" } + + data object WhatIsTransactionFee : Post { + override val path: String = "what-is-a-transaction-fee-and-why-do-we-need-it" + } } } \ No newline at end of file diff --git a/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..4dc0b16d03 100644 --- a/common/test/src/main/java/com/tangem/common/test/data/staking/MockP2PEthPoolAccountResponseFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/data/staking/MockP2PEthPoolAccountResponseFactory.kt @@ -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 { @@ -35,7 +35,7 @@ object MockP2PEthPoolAccountResponseFactory { availableToUnstake = stakedAmount, availableToWithdraw = BigDecimal.ZERO, exitQueue = P2PEthPoolExitQueueDTO( - total = 0.0, + total = BigDecimal.ZERO, requests = emptyList(), ), ) @@ -55,7 +55,7 @@ object MockP2PEthPoolAccountResponseFactory { availableToUnstake = BigDecimal.ZERO, availableToWithdraw = BigDecimal.ZERO, exitQueue = P2PEthPoolExitQueueDTO( - total = 0.0, + total = BigDecimal.ZERO, requests = emptyList(), ), ) diff --git a/common/ui/detekt-baseline-debug.xml b/common/ui/detekt-baseline-debug.xml index 13ac4c3486..312ce47852 100644 --- a/common/ui/detekt-baseline-debug.xml +++ b/common/ui/detekt-baseline-debug.xml @@ -9,20 +9,11 @@ BooleanPropertyNaming:NotificationUM.kt$NotificationUM.Error.ExceedsBalance$val mergeFeeNetworkName: Boolean = false BooleanPropertyNaming:NotificationsFactory.kt$NotificationsFactory$val showNotification = sendingAmount + feeAmount > balance - minimumRequirement.orZero() BooleanPropertyNaming:TokenReceiveBottomSheetConfig.kt$TokenReceiveBottomSheetConfig$val showMemoDisclaimer: Boolean - CanBeNonNullable:AmountBlockV2.kt$onClick: (() -> Unit)? = null - CanBeNonNullable:NavigationButtonsBlock.kt$footerText: TextReference? - CanBeNonNullable:NavigationButtonsBlock.kt$pairButtons: Pair<NavigationButton, NavigationButton>? - CanBeNonNullable:NavigationButtonsBlock.kt$prevButton: NavigationButton? - CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$dustValue: BigDecimal? - CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$minAdaValue: BigDecimal? - CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$minimumSendAmount: BigDecimal? - CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$rentWarning: CryptoCurrencyWarning.Rent? MultilineLambdaItParameter:ExpressStatusItems.kt${ val itemInfo = expressTxs[it].info val (iconRes, tint) = when (itemInfo.iconState) { ExpressTransactionStateIconUM.Warning -> { R.drawable.ic_alert_triangle_20 to TangemTheme.colors.icon.attention } ExpressTransactionStateIconUM.Error -> { R.drawable.ic_alert_circle_24 to TangemTheme.colors.icon.warning } ExpressTransactionStateIconUM.None -> null to null } ExpressStatusItem( title = itemInfo.title, fromTokenIconState = itemInfo.fromCurrencyIcon, toTokenIconState = itemInfo.toCurrencyIcon, fromAmount = itemInfo.fromAmount, fromSymbol = itemInfo.fromAmountSymbol, toAmount = itemInfo.toAmount, toSymbol = itemInfo.toAmountSymbol, onClick = itemInfo.onClick, infoIconRes = iconRes, infoIconTint = tint, modifier = modifier.animateItem(), ) } MultilineLambdaItParameter:TokenItemStateConverter.kt$TokenItemStateConverter.Companion${ it.key.equals( other = token.yieldSupplyKey(), ignoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId), ) } NoNameShadowing:NavigationButtonsBlock.kt$navigationUM NoNameShadowing:UserWalletItem.kt$balance NullableBooleanCheck:TokenItemStateConverter.kt$TokenItemStateConverter.Companion$cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive ?: false - NullableToStringCall:TokenItemStateConverter.kt$TokenItemStateConverter.Companion$${id.rawCurrencyId} ReusedModifierInstance:AddTokenContent.kt$AddButton( modifier = modifier.fillMaxWidth(), state = state.button, ) UnnecessaryEventHandlerParameter:SendDoneButtons.kt$onShareClick: (String) -> Unit UnnecessaryLet:TokenItemStateConverter.kt$TokenItemStateConverter.Companion$let(::add) diff --git a/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/account/AccountIconItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt index f89a37e3db..102be5a837 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt @@ -20,5 +20,6 @@ object AccountIconItemStateConverter : Converter TODO("[REDACTED_JIRA]") } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt index d45ef488d1..baed1af4d7 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt @@ -22,21 +22,24 @@ class AccountPortfolioItemUMConverter( ) : Converter { override fun convert(value: Account): UserWalletItemUM { - return with(value) { - UserWalletItemUM( - id = accountId.value, - name = accountName.toUM().value, - information = getInfo(account = this), - balance = getBalanceInfo(), - isEnabled = isEnabled, - endIcon = endIcon, - onClick = onClick, - imageState = getImageState(account = this), - ) + return when (value) { + is Account.CryptoPortfolio -> with(value) { + UserWalletItemUM( + id = accountId.value, + name = accountName.toUM().value, + information = getInfo(account = this), + balance = getBalanceInfo(), + isEnabled = isEnabled, + endIcon = endIcon, + onClick = onClick, + imageState = getImageState(account = this), + ) + } + is Account.Payment -> TODO("[REDACTED_JIRA]") } } - private fun getInfo(account: Account): UserWalletItemUM.Information.Loaded = when (account) { + private fun getInfo(account: Account.CryptoPortfolio): UserWalletItemUM.Information.Loaded = when (account) { is Account.CryptoPortfolio -> { val text = pluralReference( R.plurals.common_tokens_count, @@ -47,7 +50,7 @@ class AccountPortfolioItemUMConverter( } } - private fun getImageState(account: Account) = when (account) { + private fun getImageState(account: Account.CryptoPortfolio) = when (account) { is Account.CryptoPortfolio -> UserWalletItemUM.ImageState.Account( name = account.accountName.toUM().value, icon = account.icon.toUM(), diff --git a/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/expressStatus/state/ExpressTransactionsBlockState.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionsBlockState.kt new file mode 100644 index 0000000000..870d30bc07 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionsBlockState.kt @@ -0,0 +1,22 @@ +package com.tangem.common.ui.expressStatus.state + +import androidx.compose.runtime.Composable +import com.tangem.common.ui.tokendetails.TokenDetailsDialogConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import kotlinx.collections.immutable.PersistentList + +data class ExpressTransactionsBlockState( + val transactions: PersistentList, + val bottomSheetSlot: BottomSheetSlot?, + val dialogSlot: DialogSlot?, +) + +data class BottomSheetSlot( + val config: TangemBottomSheetConfig, + val content: @Composable () -> Unit, +) + +data class DialogSlot( + val config: TokenDetailsDialogConfig, + val content: @Composable () -> Unit, +) \ No newline at end of file diff --git a/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..06cf750dac 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt @@ -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)) { @@ -134,7 +174,12 @@ private fun Tags(tags: ImmutableList, modifier: Modifier = Modifier) { val expandIndicator = remember { ContextualFlowRowOverflow.expandIndicator { val remainingItems = tags.size - shownItemCount - Label(state = LabelUM(TextReference.Str("${StringsSigns.PLUS}$remainingItems"))) + Label( + state = LabelUM( + text = TextReference.Str("${StringsSigns.PLUS}$remainingItems"), + maxLines = 1, + ), + ) } } ContextualFlowRow( diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleInfo.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleInfo.kt index f7cc656eae..a2834e2afb 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleInfo.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleInfo.kt @@ -1,7 +1,10 @@ package com.tangem.common.ui.news import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -9,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.res.TangemTheme @@ -49,6 +53,8 @@ internal fun ArticleInfo(score: Float, createdAt: String, modifier: Modifier = M Text( text = createdAt, style = TangemTheme.typography.subtitle2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, color = TangemTheme.colors.text.secondary, ) } diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleLoadingCard.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleLoadingCard.kt index 4ef5f1ce75..300d41f104 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleLoadingCard.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleLoadingCard.kt @@ -41,8 +41,9 @@ fun TrendingLoadingArticle(modifier: Modifier = Modifier) { } @Composable -fun DefaultLoadingArticle() { +fun DefaultLoadingArticle(modifier: Modifier = Modifier) { BlockCard( + modifier = modifier, colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), ) { Column(modifier = Modifier.padding(12.dp)) { diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt index 668943b4c7..01eea336aa 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt @@ -21,7 +21,7 @@ import com.tangem.utils.extensions.isZero import com.tangem.utils.extensions.orZero import java.math.BigDecimal -@Suppress("LargeClass") +@Suppress("LargeClass", "CanBeNonNullable") object NotificationsFactory { fun MutableList.addFeeUnreachableNotification( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsDialogConfig.kt b/common/ui/src/main/java/com/tangem/common/ui/tokendetails/TokenDetailsDialogConfig.kt similarity index 95% rename from features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsDialogConfig.kt rename to common/ui/src/main/java/com/tangem/common/ui/tokendetails/TokenDetailsDialogConfig.kt index 7df750dad1..8681f259a6 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsDialogConfig.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokendetails/TokenDetailsDialogConfig.kt @@ -1,9 +1,9 @@ -package com.tangem.feature.tokendetails.presentation.tokendetails.state.components +package com.tangem.common.ui.tokendetails +import com.tangem.core.ui.R import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList -import com.tangem.features.tokendetails.impl.R /** * Wallet bottom sheet config @@ -12,7 +12,7 @@ import com.tangem.features.tokendetails.impl.R * @property onDismissRequest lambda be invoked when bottom sheet is dismissed * @property content content config */ -internal data class TokenDetailsDialogConfig( +data class TokenDetailsDialogConfig( val isShow: Boolean, val onDismissRequest: () -> Unit, val content: DialogContentConfig, @@ -28,7 +28,7 @@ internal data class TokenDetailsDialogConfig( data class ButtonConfig( val text: TextReference, val onClick: () -> Unit, - val warning: Boolean = false, + val hasWarning: Boolean = false, ) data class ConfirmHideConfig( @@ -51,7 +51,7 @@ internal data class TokenDetailsDialogConfig( override val confirmButtonConfig: ButtonConfig = ButtonConfig( text = TextReference.Res(R.string.token_details_hide_alert_hide), onClick = onConfirmClick, - warning = true, + hasWarning = true, ) } diff --git a/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 06cfd97a8a..b66a09112f 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt @@ -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 @@ -251,9 +252,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 } @@ -283,12 +284,14 @@ class TokenItemStateConverter( val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit + val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool val rateInfo = when (val stakingOptions = stakingAvailability.option) { - is StakingOption.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 } @@ -314,7 +317,7 @@ class TokenItemStateConverter( return StakingLocalInfo( rate = rateInfo?.rate, - isActive = stakeKitBalance != null, // todo add p2p check + isActive = stakeKitBalance != null || p2pEthPoolBalance != null, rewardType = rateInfo?.type, ) } @@ -469,7 +472,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/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index 9488b9c840..f8528755c7 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -1,5 +1,7 @@ package com.tangem.core.analytics.models +const val IS_NOT_HTTP_ERROR = "Is not http error" + sealed class AnalyticsParam { sealed class CardBalanceState(val value: String) { @@ -77,7 +79,9 @@ sealed class AnalyticsParam { data object Backup : ScreensSources("Backup") data object Onboarding : ScreensSources("Onboarding") data object LongTap : ScreensSources("Long Tap") + data object Market : ScreensSources("Market") data object Markets : ScreensSources("Markets") + data object MarketPulse : ScreensSources("Market Pulse") data object TangemPay : ScreensSources("Tangem Pay") data object WalletSettings : ScreensSources("Wallet Settings") data object Upgrade : ScreensSources("Upgrade") @@ -86,6 +90,9 @@ sealed class AnalyticsParam { data object CreateWalletIntro : ScreensSources("Create Wallet Intro") data object AddNewWallet : ScreensSources("Add New Wallet") data object CreateWallet : ScreensSources("Create Wallet") + data object NewsList : ScreensSources("News List") + data object NewsLink : ScreensSources("News Link") + data object NewsPage : ScreensSources("News Page") } sealed class TxSentFrom(val value: String) { diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/OneTimePerSessionEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/OneTimePerSessionEvent.kt new file mode 100644 index 0000000000..a94df878eb --- /dev/null +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/OneTimePerSessionEvent.kt @@ -0,0 +1,22 @@ +package com.tangem.core.analytics.models + +/** + * Marker interface for analytics events that should be sent only once per session. + * + * Events implementing this interface will be tracked by their [oneTimeEventId] to ensure + * they are not sent multiple times during the same application session. Once an event + * with a specific [oneTimeEventId] has been sent, subsequent attempts to send an event + * with the same ID will be ignored. + * + * @see Analytics.send + */ +interface OneTimePerSessionEvent { + /** + * Unique identifier for the one-time event. + * + * This ID is used to track whether the event has already been sent in the current session. + * Events with the same [oneTimeEventId] will only be sent once, even if they are + * different instances of the same event class. + */ + val oneTimeEventId: String +} \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt b/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt index 9703947954..c5630c4906 100644 --- a/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt +++ b/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt @@ -6,6 +6,7 @@ import com.tangem.common.extensions.toHexString import com.tangem.core.analytics.api.* import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.ExceptionAnalyticsEvent +import com.tangem.core.analytics.models.OneTimePerSessionEvent import com.tangem.utils.coroutines.FeatureCoroutineExceptionHandler import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex @@ -31,6 +32,7 @@ object Analytics : GlobalAnalyticsEventHandler { private val handlers = mutableMapOf() private val paramsInterceptors = ConcurrentHashMap() + private val oneEventsPerSession = ConcurrentHashMap() private val analyticsFilters = mutableSetOf() private val analyticsMutex = Mutex() @@ -85,6 +87,11 @@ object Analytics : GlobalAnalyticsEventHandler { override fun send(event: AnalyticsEvent) { analyticsScope.launch { + if (event is OneTimePerSessionEvent && + oneEventsPerSession.putIfAbsent(event.oneTimeEventId, true) != null + ) { + return@launch + } event.params = applyParamsInterceptors(event) val eventFilter = analyticsFilters.firstOrNull { it.canBeAppliedTo(event) } diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 94775b09a4..72e92884a0 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -55,16 +55,32 @@ "name": "YIELD_SUPPLY_FEATURE_ENABLED", "version": "5.30.0" }, + { + "name": "YIELD_SUPPLY_PENDING_TRANSACTIONS_ENABLED", + "version": "undefined" + }, { "name": "NEW_ONRAMP_MAIN_ENABLED", "version": "5.31.0" }, { "name": "ACCOUNTS_FEATURE_ENABLED", - "version": "undefined" + "version": "5.33.0" }, { "name": "FEED_ENABLED", + "version": "5.33.0" + }, + { + "name": "APP_REDESIGN_ENABLED", + "version": "undefined" + }, + { + "name": "GASLESS_TRANSACTIONS_ENABLED", + "version": "5.33.0" + }, + { + "name": "SWAP_MARKET_LIST_ENABLED", "version": "undefined" } ] diff --git a/core/datasource/detekt-baseline-debug.xml b/core/datasource/detekt-baseline-debug.xml index 7d14c576a1..08b13f32ec 100644 --- a/core/datasource/detekt-baseline-debug.xml +++ b/core/datasource/detekt-baseline-debug.xml @@ -12,6 +12,5 @@ NestedScopeFunctions:RetrofitApiBuilder.kt$RetrofitApiBuilder$let { withWriteTimeout(timeout = it.duration, unit = it.unit) } UnreachableCode:MockApiConfigsManager.kt$MockApiConfigsManager$apiConfigs + (apiConfig to environment) UnreachableCode:MockApiConfigsManager.kt$MockApiConfigsManager$val apiConfig = apiConfigs.keys.firstOrNull { it.id.name == id } ?: error("Api config with id [$id] not found. Check that ApiConfig with id [$id] was provided into DI") - UnusedImports:NetworkModule.kt$import com.tangem.datasource.api.common.config.MoonPay diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/AuthProvider.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/AuthProvider.kt index 002f8656ed..75cf5c12bd 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/AuthProvider.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/AuthProvider.kt @@ -18,6 +18,8 @@ interface AuthProvider { fun getApiKey(apiEnvironment: Provider): ProviderSuspend + fun getGaslessServiceApiKey(apiEnvironment: Provider): ProviderSuspend + /** * Returns map where keys(cardId) associated with cardPublicKey */ diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index 8e0a661871..515a93b2f8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -31,6 +31,7 @@ sealed class ApiConfig { YieldSupply, MoonPay, News, + GaslessTxService, } private fun initializeId(): ID { @@ -39,12 +40,13 @@ sealed class ApiConfig { is TangemTech -> ID.TangemTech is StakeKit -> ID.StakeKit is P2PEthPool -> ID.P2PEthPool - is TangemPay -> ID.TangemPay - is TangemPayAuth -> ID.TangemPayAuth + is TangemPay.Bff -> ID.TangemPay + is TangemPay.Auth -> ID.TangemPayAuth is BlockAid -> ID.BlockAid is YieldSupply -> ID.YieldSupply is MoonPay -> ID.MoonPay is News -> ID.News + is GaslessTxService -> ID.GaslessTxService } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt new file mode 100644 index 0000000000..35935f0c9d --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt @@ -0,0 +1,66 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.common.AuthProvider +import com.tangem.datasource.utils.RequestHeader +import com.tangem.utils.Provider +import com.tangem.utils.ProviderSuspend +import com.tangem.utils.info.AppInfoProvider +import com.tangem.utils.version.AppVersionProvider + +/** + * Gasless transactions [ApiConfig] + */ +internal class GaslessTxService( + private val authProvider: AuthProvider, + private val appVersionProvider: AppVersionProvider, + private val appInfoProvider: AppInfoProvider, +) : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() + + override val environmentConfigs: List = listOf( + createProdEnvironment(), + createDevEnvironment(), + ) + + private fun getInitialEnvironment(): ApiEnvironment { + return when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE, + DEBUG_BUILD_TYPE, + -> ApiEnvironment.DEV + INTERNAL_BUILD_TYPE, + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + } + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = PROD_BASE_URL, + headers = createHeaders(ApiEnvironment.PROD), + ) + + private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = DEV_BASE_URL, + headers = createHeaders(ApiEnvironment.DEV), + ) + + private fun createHeaders(environment: ApiEnvironment) = buildMap { + putAll(RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) + put( + key = "Authorization", + value = ProviderSuspend { + "Bearer ${authProvider.getGaslessServiceApiKey(Provider { environment }).invoke()}" + }, + ) + } + + private companion object { + private const val PROD_BASE_URL = "https://gasless.tangem.org/" + private const val DEV_BASE_URL = "[REDACTED_ENV_URL]" + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt index 23ff32ba22..fc57478ec5 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt @@ -4,12 +4,16 @@ import com.tangem.datasource.BuildConfig import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.utils.RequestHeader import com.tangem.utils.Provider +import com.tangem.utils.info.AppInfoProvider +import com.tangem.utils.version.AppVersionProvider /** * News [ApiConfig] [REDACTED_AUTHOR] */ internal class News( + private val appVersionProvider: AppVersionProvider, + private val appInfoProvider: AppInfoProvider, private val authProvider: AuthProvider, ) : ApiConfig() { @@ -52,6 +56,7 @@ internal class News( apiEnvironment = Provider { environment }, ).values, ) + putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) } private companion object { diff --git a/core/datasource/src/main/java/com/tangem/datasource/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/common/config/TangemPay.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt index c0a5c07775..b494cbc70c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt @@ -1,11 +1,13 @@ package com.tangem.datasource.api.common.config import com.tangem.datasource.BuildConfig +import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage import com.tangem.utils.ProviderSuspend import com.tangem.utils.version.AppVersionProvider -internal class TangemPay( +internal sealed class TangemPay( private val appVersionProvider: AppVersionProvider, + private val environmentConfigStorage: EnvironmentConfigStorage, ) : ApiConfig() { override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() @@ -16,6 +18,8 @@ internal class TangemPay( createProdEnvironment(), ) + protected abstract fun getBaseUrl(apiEnvironment: ApiEnvironment): String + private fun getInitialEnvironment(): ApiEnvironment { return when (BuildConfig.BUILD_TYPE) { MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK @@ -31,24 +35,75 @@ internal class TangemPay( private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( environment = ApiEnvironment.DEV, - baseUrl = "https://api.dev.us.paera.com/bff-v2/", - headers = createHeaders(), + baseUrl = getBaseUrl(ApiEnvironment.DEV), + headers = createHeaders(ApiEnvironment.DEV), ) private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( environment = ApiEnvironment.MOCK, - baseUrl = "[REDACTED_ENV_URL]", - headers = createHeaders(), + baseUrl = getBaseUrl(ApiEnvironment.MOCK), + headers = createHeaders(ApiEnvironment.MOCK), ) private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( environment = ApiEnvironment.PROD, - baseUrl = "https://api.us.paera.com/bff-v2/", - headers = createHeaders(), + baseUrl = getBaseUrl(ApiEnvironment.PROD), + headers = createHeaders(ApiEnvironment.PROD), ) - private fun createHeaders() = mapOf( + private fun createHeaders(apiEnvironment: ApiEnvironment) = mapOf( "version" to ProviderSuspend { appVersionProvider.versionName }, "platform" to ProviderSuspend { "Android" }, + "X-API-KEY" to ProviderSuspend { getBffStaticToken(apiEnvironment) }, ) + + private fun getBffStaticToken(apiEnvironment: ApiEnvironment): String { + return when (apiEnvironment) { + ApiEnvironment.MOCK, + ApiEnvironment.DEV, + -> environmentConfigStorage.getConfigSync().bffStaticTokenDev + ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().bffStaticToken + ApiEnvironment.STAGE, + ApiEnvironment.STAGE_2, + ApiEnvironment.DEV_2, + ApiEnvironment.DEV_3, + -> null + } ?: error("BffStaticToken is not provided for $apiEnvironment") + } + + class Bff( + appVersionProvider: AppVersionProvider, + environmentConfigStorage: EnvironmentConfigStorage, + ) : TangemPay(appVersionProvider, environmentConfigStorage) { + override fun getBaseUrl(apiEnvironment: ApiEnvironment): String { + return when (apiEnvironment) { + ApiEnvironment.DEV -> "https://api.dev.us.paera.com/bff-v2/" + ApiEnvironment.MOCK -> "[REDACTED_ENV_URL]" + ApiEnvironment.PROD -> "https://api.us.paera.com/bff-v2/" + ApiEnvironment.DEV_2, + ApiEnvironment.DEV_3, + ApiEnvironment.STAGE, + ApiEnvironment.STAGE_2, + -> error("Unknown environment: $apiEnvironment") + } + } + } + + class Auth( + appVersionProvider: AppVersionProvider, + environmentConfigStorage: EnvironmentConfigStorage, + ) : TangemPay(appVersionProvider, environmentConfigStorage) { + override fun getBaseUrl(apiEnvironment: ApiEnvironment): String { + return when (apiEnvironment) { + ApiEnvironment.DEV -> "https://api.dev.us.paera.com/" + ApiEnvironment.MOCK -> "[REDACTED_ENV_URL]" + ApiEnvironment.PROD -> "https://api.us.paera.com/" + ApiEnvironment.DEV_2, + ApiEnvironment.DEV_3, + ApiEnvironment.STAGE, + ApiEnvironment.STAGE_2, + -> error("Unknown environment: $apiEnvironment") + } + } + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPayAuth.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPayAuth.kt deleted file mode 100644 index cbbc29459d..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPayAuth.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.tangem.datasource.api.common.config - -import com.tangem.datasource.BuildConfig -import com.tangem.utils.ProviderSuspend -import com.tangem.utils.version.AppVersionProvider - -internal class TangemPayAuth( - private val appVersionProvider: AppVersionProvider, -) : ApiConfig() { - override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() - - override val environmentConfigs = listOf( - createDevEnvironment(), - createMockedEnvironment(), - createProdEnvironment(), - ) - - private fun getInitialEnvironment(): ApiEnvironment { - return when (BuildConfig.BUILD_TYPE) { - MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK - DEBUG_BUILD_TYPE, - INTERNAL_BUILD_TYPE, - -> ApiEnvironment.DEV - EXTERNAL_BUILD_TYPE, - RELEASE_BUILD_TYPE, - -> ApiEnvironment.PROD - else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") - } - } - - private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( - environment = ApiEnvironment.DEV, - baseUrl = "https://api.dev.us.paera.com/", - headers = createHeaders(), - ) - - private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( - environment = ApiEnvironment.MOCK, - baseUrl = "[REDACTED_ENV_URL]", - headers = createHeaders(), - ) - - private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( - environment = ApiEnvironment.PROD, - baseUrl = "https://api.us.paera.com/", - headers = createHeaders(), - ) - - private fun createHeaders() = mapOf( - "version" to ProviderSuspend { appVersionProvider.versionName }, - "platform" to ProviderSuspend { "Android" }, - ) -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt index cf3ab2f3e5..f76e0a7bfa 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt @@ -2,9 +2,7 @@ package com.tangem.datasource.api.ethpool import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest -import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolDepositRequest -import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolUnstakeRequest -import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolWithdrawRequest +import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolTransactionRequest import com.tangem.datasource.api.ethpool.models.response.* import retrofit2.http.* @@ -31,13 +29,13 @@ interface P2PEthPoolApi { * Create unsigned transaction for depositing ETH into a vault. * * @param network Ethereum pool network: "mainnet" or "hoodi" - * @param body Deposit parameters (delegator address, vault address, amount) + * @param body Transaction parameters (delegator address, vault address, amount) */ @POST("api/v1/staking/pool/{network}/staking/deposit") suspend fun createDepositTransaction( @Path("network") network: String, - @Body body: P2PEthPoolDepositRequest, - ): ApiResponse> + @Body body: P2PEthPoolTransactionRequest, + ): ApiResponse> /** * Prepare unstake transaction @@ -45,13 +43,13 @@ interface P2PEthPoolApi { * Create unsigned transaction to initiate unstaking process. * * @param network Ethereum pool network: "mainnet" or "hoodi" - * @param body Unstake parameters (staker public key, stake transaction hash) + * @param body Transaction parameters (delegator address, vault address, amount) */ @POST("api/v1/staking/pool/{network}/staking/unstake") suspend fun createUnstakeTransaction( @Path("network") network: String, - @Body body: P2PEthPoolUnstakeRequest, - ): ApiResponse> + @Body body: P2PEthPoolTransactionRequest, + ): ApiResponse> /** * Prepare withdrawal transaction @@ -59,13 +57,13 @@ interface P2PEthPoolApi { * Create unsigned transaction to withdraw available funds from exit queue. * * @param network Ethereum pool network: "mainnet" or "hoodi" - * @param body Withdrawal parameters (staker address) + * @param body Transaction parameters (delegator address, vault address, amount) */ @POST("api/v1/staking/pool/{network}/staking/withdraw") suspend fun createWithdrawTransaction( @Path("network") network: String, - @Body body: P2PEthPoolWithdrawRequest, - ): ApiResponse> + @Body body: P2PEthPoolTransactionRequest, + ): ApiResponse> /** * Broadcast signed transaction @@ -96,22 +94,4 @@ interface P2PEthPoolApi { @Path("delegatorAddress") delegatorAddress: String, @Path("vaultAddress") vaultAddress: String, ): ApiResponse> - - /** - * Get rewards history - * - * Retrieve historical rewards data for a specific account and vault. - * - * @param network Ethereum pool network: "mainnet" or "hoodi" - * @param delegatorAddress Account address that initiated staking - * @param vaultAddress Ethereum address of the vault - * @param period Optional period filter (30, 60, or 90 days) - */ - @GET("api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}/rewards") - suspend fun getRewards( - @Path("network") network: String, - @Path("delegatorAddress") delegatorAddress: String, - @Path("vaultAddress") vaultAddress: String, - @Query("period") period: Int? = null, - ): ApiResponse> } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolDepositRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolDepositRequest.kt deleted file mode 100644 index e4b3200add..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolDepositRequest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.tangem.datasource.api.ethpool.models.request - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -/** - * Request body for creating deposit transaction - * - * Used in: POST /api/v1/staking/pool/{network}/staking/deposit - */ -@JsonClass(generateAdapter = true) -data class P2PEthPoolDepositRequest( - @Json(name = "delegatorAddress") - val delegatorAddress: String, - @Json(name = "vaultAddress") - val vaultAddress: String, - @Json(name = "amount") - val amount: Double, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolTransactionRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolTransactionRequest.kt new file mode 100644 index 0000000000..500fc969be --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolTransactionRequest.kt @@ -0,0 +1,23 @@ +package com.tangem.datasource.api.ethpool.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigDecimal + +/** + * Unified request body for creating staking transactions (deposit, unstake, withdraw) + * + * Used in: + * - POST /api/v1/staking/pool/{network}/staking/deposit + * - POST /api/v1/staking/pool/{network}/staking/unstake + * - POST /api/v1/staking/pool/{network}/staking/withdraw + */ +@JsonClass(generateAdapter = true) +data class P2PEthPoolTransactionRequest( + @Json(name = "delegatorAddress") + val delegatorAddress: String, + @Json(name = "vaultAddress") + val vaultAddress: String, + @Json(name = "amount") + val amount: BigDecimal, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolUnstakeRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolUnstakeRequest.kt deleted file mode 100644 index 0e56cd5112..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolUnstakeRequest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.datasource.api.ethpool.models.request - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -/** - * Request body for creating unstake transaction - * - * Used in: POST /api/v1/staking/pool/{network}/staking/unstake - * - * Note: Documentation seems to contain Bitcoin-related fields (possibly copy-paste error). - * Using as-is per specification. - */ -@JsonClass(generateAdapter = true) -data class P2PEthPoolUnstakeRequest( - @Json(name = "stakerPublicKey") - val stakerPublicKey: String, - @Json(name = "stakeTransactionHash") - val stakeTransactionHash: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolWithdrawRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolWithdrawRequest.kt deleted file mode 100644 index d0a7aac5cf..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolWithdrawRequest.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.datasource.api.ethpool.models.request - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -/** - * Request body for creating withdrawal transaction - * - * Used in: POST /api/v1/staking/pool/{network}/staking/withdraw - */ -@JsonClass(generateAdapter = true) -data class P2PEthPoolWithdrawRequest( - @Json(name = "stakerAddress") - val stakerAddress: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt index 526c3c7d16..647750c927 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt @@ -34,7 +34,7 @@ data class P2PEthPoolStakeDTO( @JsonClass(generateAdapter = true) data class P2PEthPoolExitQueueDTO( @Json(name = "total") - val total: Double, + val total: BigDecimal, @Json(name = "requests") val requests: List, ) @@ -44,11 +44,11 @@ data class P2PEthPoolExitRequestDTO( @Json(name = "ticket") val ticket: String, @Json(name = "totalAssets") - val totalAssets: Double, + val totalAssets: BigDecimal, @Json(name = "timestamp") val timestamp: Long, @Json(name = "withdrawalTimestamp") - val withdrawalTimestamp: Long, + val withdrawalTimestamp: Long?, @Json(name = "isClaimable") val isClaimable: Boolean, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/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/P2PEthPoolDepositResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolDepositResponse.kt deleted file mode 100644 index ed922bf827..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolDepositResponse.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.datasource.api.ethpool.models.response - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import org.joda.time.DateTime - -/** - * Response for POST /api/v1/staking/pool/{network}/staking/deposit - */ -@JsonClass(generateAdapter = true) -data class P2PEthPoolDepositResponse( - @Json(name = "amount") - val amount: Double, - @Json(name = "vaultAddress") - val vaultAddress: String, - @Json(name = "delegatorAddress") - val delegatorAddress: String, - @Json(name = "unsignedTransaction") - val unsignedTransaction: P2PEthPoolUnsignedTxDTO, - @Json(name = "createdAt") - val createdAt: DateTime, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/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/P2PEthPoolRewardsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolRewardsResponse.kt deleted file mode 100644 index ef4dd03ff0..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolRewardsResponse.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.tangem.datasource.api.ethpool.models.response - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import org.joda.time.DateTime -import java.math.BigDecimal - -/** - * Response for GET /api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}/rewards - */ -@JsonClass(generateAdapter = true) -data class P2PEthPoolRewardsResponse( - @Json(name = "delegatorAddress") - val delegatorAddress: String, - @Json(name = "vaultAddress") - val vaultAddress: String, - @Json(name = "rewards") - val rewards: List, -) - -@JsonClass(generateAdapter = true) -data class P2PEthPoolRewardDTO( - @Json(name = "date") - val date: DateTime, - @Json(name = "apy") - val apy: Double, - @Json(name = "balance") - val balance: BigDecimal, - @Json(name = "rewards") - val rewards: BigDecimal, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolWithdrawResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolTransactionResponse.kt similarity index 57% rename from core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolWithdrawResponse.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolTransactionResponse.kt index 1d5ff5bb4a..9c44476b5c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolWithdrawResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolTransactionResponse.kt @@ -3,14 +3,20 @@ package com.tangem.datasource.api.ethpool.models.response import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import org.joda.time.DateTime +import java.math.BigDecimal /** - * Response for POST /api/v1/staking/pool/{network}/staking/withdraw + * Unified response for staking transactions (deposit, unstake, withdraw) + * + * Response for: + * - POST /api/v1/staking/pool/{network}/staking/deposit + * - POST /api/v1/staking/pool/{network}/staking/unstake + * - POST /api/v1/staking/pool/{network}/staking/withdraw */ @JsonClass(generateAdapter = true) -data class P2PEthPoolWithdrawResponse( +data class P2PEthPoolTransactionResponse( @Json(name = "amount") - val amount: Double, + val amount: BigDecimal, @Json(name = "vaultAddress") val vaultAddress: String, @Json(name = "delegatorAddress") @@ -20,5 +26,5 @@ data class P2PEthPoolWithdrawResponse( @Json(name = "createdAt") val createdAt: DateTime, @Json(name = "tickets") - val tickets: List, + val tickets: List? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnstakeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnstakeResponse.kt deleted file mode 100644 index a3e2391339..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnstakeResponse.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.datasource.api.ethpool.models.response - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -/** - * Response for POST /api/v1/staking/pool/{network}/staking/unstake - * - * Note: Contains Bitcoin-related fields (likely documentation error). - * Using as-is per specification. - */ -@JsonClass(generateAdapter = true) -data class P2PEthPoolUnstakeResponse( - @Json(name = "stakerPublicKey") - val stakerPublicKey: String, - @Json(name = "stakeTransactionHash") - val stakeTransactionHash: String, - @Json(name = "unstakeTransactionHex") - val unstakeTransactionHex: String, // unsigned - @Json(name = "unstakeFee") - val unstakeFee: Double, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/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/api/express/models/response/ExchangeProvider.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt index 2ef036c212..4712dadadc 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt @@ -32,6 +32,9 @@ data class ExchangeProvider( @Json(name = "slippage") val slippage: BigDecimal?, + + @Json(name = "exchangeOnlyWithinSingleAddress") + val isExchangeOnlyWithinSingleAddress: Boolean = false, ) @JsonClass(generateAdapter = false) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt new file mode 100644 index 0000000000..514dc9f20e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt @@ -0,0 +1,21 @@ +package com.tangem.datasource.api.gasless + +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.gasless.models.* +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +interface GaslessTxServiceApi { + + @GET("api/v1/tokens") + suspend fun getSupportedTokens(): ApiResponse> + + @POST("api/v1/transaction/sign") + suspend fun signGaslessTransaction( + @Body transaction: GaslessTransactionRequest, + ): ApiResponse> + + @GET("api/v1/config/fee-recipient") + suspend fun getFeeRecipient(): ApiResponse> +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessFeeRecipient.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessFeeRecipient.kt new file mode 100644 index 0000000000..f66c161faf --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessFeeRecipient.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaslessFeeRecipient( + @Json(name = "feeRecipientAddress") val address: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessServiceResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessServiceResponse.kt new file mode 100644 index 0000000000..a439b9e05a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessServiceResponse.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaslessServiceResponse( + @Json(name = "result") val result: T, + @Json(name = "success") val isSuccess: Boolean, + @Json(name = "timestamp") val timestamp: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResultDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResultDTO.kt new file mode 100644 index 0000000000..8184f49c1c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSignedTransactionResultDTO.kt @@ -0,0 +1,14 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Result of gasless transaction signing. + * Contains the signed transaction data and gas parameters. + */ +@JsonClass(generateAdapter = true) +data class GaslessSignedTransactionResultDTO( + @Json(name = "txHash") + val txHash: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSupportedTokens.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSupportedTokens.kt new file mode 100644 index 0000000000..21ef002c64 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSupportedTokens.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaslessSupportedTokens( + @Json(name = "tokens") val tokens: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTokenDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTokenDTO.kt new file mode 100644 index 0000000000..e107fdcc83 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTokenDTO.kt @@ -0,0 +1,14 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaslessTokenDTO( + @Json(name = "tokenAddress") val tokenAddress: String, + @Json(name = "tokenSymbol") val tokenSymbol: String, + @Json(name = "tokenName") val tokenName: String, + @Json(name = "decimals") val decimals: Int, + @Json(name = "chainId") val chainId: Int, + @Json(name = "chain") val chain: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt new file mode 100644 index 0000000000..948567af63 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt @@ -0,0 +1,93 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Request body for gasless transaction submission. + * Represents complete transaction with fee delegation metadata. + */ +@JsonClass(generateAdapter = true) +data class GaslessTransactionRequest( + @Json(name = "gaslessTransaction") + val gaslessTransaction: GaslessTransactionData, + + @Json(name = "signature") + val signature: String, + + @Json(name = "userAddress") + val userAddress: String, + + @Json(name = "chainId") + val chainId: Int, + + @Json(name = "eip7702auth") + val eip7702Auth: Eip7702AuthorizationDTO? = null, +) + +@JsonClass(generateAdapter = true) +data class GaslessTransactionData( + @Json(name = "transaction") + val transaction: TransactionData, + + @Json(name = "fee") + val fee: FeeData, + + @Json(name = "nonce") + val nonce: String, +) + +@JsonClass(generateAdapter = true) +data class TransactionData( + @Json(name = "to") + val to: String, + + @Json(name = "value") + val value: String, + + @Json(name = "data") + val data: String, +) + +@JsonClass(generateAdapter = true) +data class FeeData( + @Json(name = "feeToken") + val feeToken: String, + + @Json(name = "maxTokenFee") + val maxTokenFee: String, + + @Json(name = "coinPriceInToken") + val coinPriceInToken: String, + + @Json(name = "feeTransferGasLimit") + val feeTransferGasLimit: String, + + @Json(name = "baseGas") + val baseGas: String, +) + +/** + * EIP-7702 authorization for account abstraction. + * Optional field, used only when EOA delegation is required. + */ +@JsonClass(generateAdapter = true) +data class Eip7702AuthorizationDTO( + @Json(name = "chainId") + val chainId: Int, + + @Json(name = "address") + val address: String, + + @Json(name = "nonce") + val nonce: String, + + @Json(name = "yParity") + val yParity: Int, + + @Json(name = "r") + val r: String, + + @Json(name = "s") + val s: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt index 292b3d4559..ef9e77a8f0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt @@ -27,15 +27,4 @@ data class NewsRelatedTokenDto( @Json(name = "id") val id: String, @Json(name = "symbol") val symbol: String, @Json(name = "name") val name: String, -) - -@JsonClass(generateAdapter = true) -data class NewsOriginalArticleDto( - @Json(name = "id") val id: Int, - @Json(name = "title") val title: String, - @Json(name = "sourceName") val sourceName: String, - @Json(name = "language") val language: String, - @Json(name = "publishedAt") val publishedAt: String, - @Json(name = "url") val url: String, - @Json(name = "imageUrl") val imageUrl: String? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt index 230c3f42fb..c3f8338224 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt @@ -17,4 +17,21 @@ data class NewsDetailsResponse( @Json(name = "shortContent") val shortContent: String, @Json(name = "content") val content: String, @Json(name = "originalArticles") val originalArticles: List, +) + +@JsonClass(generateAdapter = true) +data class NewsOriginalArticleDto( + @Json(name = "id") val id: Int, + @Json(name = "title") val title: String, + @Json(name = "source") val source: Source, + @Json(name = "language") val language: String, + @Json(name = "publishedAt") val publishedAt: String, + @Json(name = "url") val url: String, + @Json(name = "imageUrl") val imageUrl: String? = null, +) + +@JsonClass(generateAdapter = true) +data class Source( + @Json(name = "id") val id: Int, + @Json(name = "name") val name: String, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 773dbb7b72..60484ac83b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -25,7 +25,6 @@ interface TangemPayApi { @GET("v1/customer/wallets/{customer_wallet_id}") suspend fun checkCustomerWalletId( - @Header("X-API-KEY") authHeader: String, @Path("customer_wallet_id") customerWalletId: String, ): ApiResponse diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index 7bf7ad5ce5..20d4c632d5 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -60,7 +60,15 @@ internal object ApiConfigsModule { @Provides @IntoSet - fun provideNewsConfig(authProvider: AuthProvider): ApiConfig = News(authProvider = authProvider) + fun provideNewsConfig( + appVersionProvider: AppVersionProvider, + authProvider: AuthProvider, + appInfoProvider: AppInfoProvider, + ): ApiConfig = News( + appVersionProvider = appVersionProvider, + appInfoProvider = appInfoProvider, + authProvider = authProvider, + ) @Provides @IntoSet @@ -78,13 +86,17 @@ internal object ApiConfigsModule { @Provides @IntoSet - fun provideTangemVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemPay(appVersionProvider) + fun provideTangemPayBffConfig( + appVersionProvider: AppVersionProvider, + environmentConfigStorage: EnvironmentConfigStorage, + ): ApiConfig = TangemPay.Bff(appVersionProvider, environmentConfigStorage) @Provides @IntoSet - fun provideTangemPayAuthConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemPayAuth( - appVersionProvider, - ) + fun provideTangemPayAuthConfig( + appVersionProvider: AppVersionProvider, + environmentConfigStorage: EnvironmentConfigStorage, + ): ApiConfig = TangemPay.Auth(appVersionProvider, environmentConfigStorage) @Provides @IntoSet @@ -97,4 +109,18 @@ internal object ApiConfigsModule { fun provideMoonPayConfig(): ApiConfig { return MoonPay() } + + @Provides + @IntoSet + fun provideGaslessServiceConfig( + appVersionProvider: AppVersionProvider, + authProvider: AuthProvider, + appInfoProvider: AppInfoProvider, + ): ApiConfig { + return GaslessTxService( + authProvider = authProvider, + appVersionProvider = appVersionProvider, + appInfoProvider = appInfoProvider, + ) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 511a2dab3d..25833cc27c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -15,6 +15,7 @@ import com.tangem.datasource.api.moonpay.MoonPayApi import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.api.onramp.OnrampApi import com.tangem.datasource.api.ethpool.P2PEthPoolApi +import com.tangem.datasource.api.gasless.GaslessTxServiceApi import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.TangemPayAuthApi import com.tangem.datasource.api.stakekit.StakeKitApi @@ -36,8 +37,11 @@ import javax.inject.Singleton internal object NetworkModule { private const val TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS = 60L + private const val TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS = 60L private const val STAKE_KIT_API_TIMEOUT_SECONDS = 60L + private const val P2P_ETH_POOL_API_TIMEOUT_SECONDS = 60L + @Provides @Singleton fun provideApiConfigManager( @@ -82,6 +86,12 @@ internal object NetworkModule { return retrofitApiBuilder.build( apiConfigId = ApiConfig.ID.P2PEthPool, applyTimeoutAnnotations = false, + timeouts = Timeouts( + callTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, + connectTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, + readTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, + writeTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, + ), ) } @@ -180,4 +190,19 @@ internal object NetworkModule { applyTimeoutAnnotations = false, ) } + + @Provides + @Singleton + fun provideGaslessTxServiceApi(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApi { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.GaslessTxService, + applyTimeoutAnnotations = false, + timeouts = Timeouts( + callTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, + connectTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, + readTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, + writeTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, + ), + ) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt index fa1dbd3792..22b046ee0c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt @@ -3,8 +3,12 @@ package com.tangem.datasource.di import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.news.details.DefaultNewsDetailsStore import com.tangem.datasource.local.news.details.NewsDetailsStore +import com.tangem.datasource.local.news.liked.DefaultNewsLikedStore +import com.tangem.datasource.local.news.liked.NewsLikedStore import com.tangem.datasource.local.news.trending.DefaultTrendingNewsStore import com.tangem.datasource.local.news.trending.TrendingNewsStore +import com.tangem.datasource.local.news.viewed.DefaultNewsViewedStore +import com.tangem.datasource.local.news.viewed.NewsViewedStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -26,4 +30,16 @@ internal object NewsStoreModule { fun provideTrendingNewsStore(): TrendingNewsStore { return DefaultTrendingNewsStore(store = RuntimeSharedStore()) } + + @Provides + @Singleton + fun provideNewsViewedStore(): NewsViewedStore { + return DefaultNewsViewedStore(store = RuntimeSharedStore()) + } + + @Provides + @Singleton + fun provideNewsLikedStore(): NewsLikedStore { + return DefaultNewsLikedStore(store = RuntimeSharedStore()) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/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/config/environment/EnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt index cce8979a7c..22545b5329 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt @@ -25,4 +25,7 @@ data class EnvironmentConfig( val yieldModuleApiKey: String? = null, val yieldModuleApiKeyDev: String? = null, val bffStaticToken: String? = null, + val bffStaticTokenDev: String? = null, + val gaslessTxApiKeyDev: String? = null, + val gaslessTxApiKey: String? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt index d8ec470eff..6d7733109a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt @@ -33,6 +33,9 @@ internal object EnvironmentConfigConverter : Converter + +internal class DefaultNewsLikedStore( + private val store: RuntimeSharedStore, +) : NewsLikedStore { + + override fun getAll(): Flow> { + return store.get().onStart { emit(emptyMap()) } + } + + override suspend fun getSync(): Map { + return store.getSyncOrNull().orEmpty() + } + + override suspend fun updateLiked(articleIds: Collection, liked: Boolean) { + if (articleIds.isEmpty()) return + + store.update(emptyMap()) { current -> + val updated = current.toMutableMap() + articleIds.forEach { id -> + updated[id] = liked + } + updated + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/liked/NewsLikedStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/liked/NewsLikedStore.kt new file mode 100644 index 0000000000..6decede719 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/liked/NewsLikedStore.kt @@ -0,0 +1,24 @@ +package com.tangem.datasource.local.news.liked + +import kotlinx.coroutines.flow.Flow + +/** + * Store for news liked flags (runtime only). + */ +interface NewsLikedStore { + + /** + * Observes all liked flags. + */ + fun getAll(): Flow> + + /** + * Gets liked flags synchronously (returns empty map if no data). + */ + suspend fun getSync(): Map + + /** + * Updates liked flags for provided article ids. + */ + suspend fun updateLiked(articleIds: Collection, liked: Boolean) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/DefaultNewsViewedStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/DefaultNewsViewedStore.kt new file mode 100644 index 0000000000..f365d8a09e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/DefaultNewsViewedStore.kt @@ -0,0 +1,32 @@ +package com.tangem.datasource.local.news.viewed + +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onStart + +private typealias NewsViewedCache = Map + +internal class DefaultNewsViewedStore( + private val store: RuntimeSharedStore, +) : NewsViewedStore { + + override fun getAll(): Flow> { + return store.get().onStart { emit(emptyMap()) } + } + + override suspend fun getSync(): Map { + return store.getSyncOrNull().orEmpty() + } + + override suspend fun updateViewed(articleIds: Collection, viewed: Boolean) { + if (articleIds.isEmpty()) return + + store.update(emptyMap()) { current -> + val updated = current.toMutableMap() + articleIds.forEach { id -> + updated[id] = viewed + } + updated + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/NewsViewedStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/NewsViewedStore.kt new file mode 100644 index 0000000000..24a2fdc39b --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/NewsViewedStore.kt @@ -0,0 +1,24 @@ +package com.tangem.datasource.local.news.viewed + +import kotlinx.coroutines.flow.Flow + +/** + * Store for news viewed flags (runtime only). + */ +interface NewsViewedStore { + + /** + * Observes all viewed flags. + */ + fun getAll(): Flow> + + /** + * Gets viewed flags synchronously (returns empty map if no data). + */ + suspend fun getSync(): Map + + /** + * Updates viewed flags for provided article ids. + */ + suspend fun updateViewed(articleIds: Collection, viewed: Boolean) +} \ No newline at end of file diff --git a/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/ApiConfigTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt index d92d9ff26e..2e0ccb145b 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt @@ -69,12 +69,27 @@ class ApiConfigTest { ) } ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = mockk()) - ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = mockk()) + ApiConfig.ID.TangemPay -> TangemPay.Bff( + appVersionProvider = mockk(), + environmentConfigStorage = mockk() + ) + ApiConfig.ID.TangemPayAuth -> TangemPay.Auth( + appVersionProvider = mockk(), + environmentConfigStorage = mockk() + ) ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk()) ApiConfig.ID.MoonPay -> MoonPay() ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk()) - ApiConfig.ID.News -> News(authProvider = appAuthProvider) - ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = mockk()) + ApiConfig.ID.News -> News( + appVersionProvider = mockk(), + authProvider = appAuthProvider, + appInfoProvider = mockk(), + ) + ApiConfig.ID.GaslessTxService -> GaslessTxService( + authProvider = appAuthProvider, + appVersionProvider = mockk(), + appInfoProvider = mockk(), + ) } } } diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt index 18c51f8e49..530d4de06e 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt @@ -18,6 +18,8 @@ internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage { blockAidApiKey = BLOCK_AID_API_KEY, tangemApiKey = TANGEM_API_KEY, tangemApiKeyDev = TANGEM_API_KEY_DEV, + bffStaticToken = TANGEM_PAY_BFF_KEY, + bffStaticTokenDev = TANGEM_PAY_BFF_KEY_DEV, tangemApiKeyStage = TANGEM_API_KEY_STAGE, yieldModuleApiKey = YIELD_MODULE_KEY, yieldModuleApiKeyDev = YIELD_MODULE_KEY_DEV, @@ -33,6 +35,9 @@ internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage { const val BLOCK_AID_API_KEY = "block_aid_api_key" const val TANGEM_API_KEY = "tangem_api_key" const val TANGEM_API_KEY_DEV = "tangem_api_key_dev" + const val TANGEM_PAY_BFF_KEY = "tangem_pay_bff_key" + const val TANGEM_PAY_BFF_KEY_DEV = "tangem_pay_bff_key_dev" + const val TANGEM_GASLESS_API_KEY = "tangem_gasless_api_key" const val TANGEM_API_KEY_STAGE = "tangem_api_key_stage" const val YIELD_MODULE_KEY = "yield_module_api_key" const val YIELD_MODULE_KEY_DEV = "yield_module_api_key_dev" diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index e176d0b768..76572d804f 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -12,7 +12,9 @@ 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.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_GASLESS_API_KEY +import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_PAY_BFF_KEY_DEV +import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.lib.auth.P2PEthPoolAuthProvider import com.tangem.lib.auth.StakeKitAuthProvider @@ -45,6 +47,7 @@ internal class ProdApiConfigsManagerTest { private val appAuthProvider = mockk() private val appInfoProvider = mockk() private val tangemApiKeyProvider = mockk>() + private val tangemGaslessApiKeyProvider = mockk>() private lateinit var manager: ProdApiConfigsManager @@ -63,6 +66,8 @@ internal class ProdApiConfigsManagerTest { every { stakeKitAuthProvider.getApiKey() } returns STAKE_KIT_API_KEY every { p2pEthPoolAuthProvider.getApiKey() } returns P2P_API_KEY every { appAuthProvider.getApiKey(any()) } returns tangemApiKeyProvider + every { appAuthProvider.getGaslessServiceApiKey(any()) } returns tangemGaslessApiKeyProvider + coEvery { tangemGaslessApiKeyProvider.invoke() } returns TANGEM_GASLESS_API_KEY coEvery { tangemApiKeyProvider.invoke() } returns TANGEM_API_KEY coEvery { appAuthProvider.getCardId() } returns APP_CARD_ID coEvery { appAuthProvider.getCardPublicKey() } returns APP_CARD_PUBLIC_KEY @@ -111,12 +116,27 @@ internal class ProdApiConfigsManagerTest { ) } ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = stakeKitAuthProvider) - ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = appVersionProvider) + ApiConfig.ID.TangemPay -> TangemPay.Bff( + appVersionProvider = appVersionProvider, + environmentConfigStorage = environmentConfigStorage, + ) + ApiConfig.ID.TangemPayAuth -> TangemPay.Auth( + appVersionProvider = appVersionProvider, + environmentConfigStorage = environmentConfigStorage, + ) ApiConfig.ID.BlockAid -> BlockAid(configStorage = environmentConfigStorage) ApiConfig.ID.MoonPay -> MoonPay() ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider) - ApiConfig.ID.News -> News(authProvider = appAuthProvider) - ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = appVersionProvider) + ApiConfig.ID.News -> News( + appVersionProvider = appVersionProvider, + authProvider = appAuthProvider, + appInfoProvider = appInfoProvider, + ) + ApiConfig.ID.GaslessTxService -> GaslessTxService( + authProvider = appAuthProvider, + appVersionProvider = appVersionProvider, + appInfoProvider = appInfoProvider, + ) } } } @@ -128,11 +148,12 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.TangemTech -> createTangemTechModel() ApiConfig.ID.StakeKit -> createStakeKitModel() ApiConfig.ID.TangemPay -> createTangemPayModel() + ApiConfig.ID.TangemPayAuth -> createTangemPayAuthModel() ApiConfig.ID.BlockAid -> createBlockAidSdkModel() ApiConfig.ID.MoonPay -> createMoonPayModel() ApiConfig.ID.P2PEthPool -> createP2PModel() ApiConfig.ID.News -> createNewsModel() - ApiConfig.ID.TangemPayAuth -> createTangemPayAuthModel() + ApiConfig.ID.GaslessTxService -> createGaslessTxServiceModel() } } @@ -255,6 +276,7 @@ internal class ProdApiConfigsManagerTest { headers = mapOf( "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "Android" }, + "X-API-KEY" to ProviderSuspend { TANGEM_PAY_BFF_KEY_DEV }, ), ), ) @@ -269,6 +291,38 @@ internal class ProdApiConfigsManagerTest { headers = mapOf( "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "Android" }, + "X-API-KEY" to ProviderSuspend { TANGEM_PAY_BFF_KEY_DEV }, + ), + ), + ) + } + + private fun createGaslessTxServiceModel(): TestModel { + val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE, + DEBUG_BUILD_TYPE, + -> ApiEnvironment.DEV to "[REDACTED_ENV_URL]" + INTERNAL_BUILD_TYPE, + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD to "https://gasless.tangem.org/" + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + return TestModel( + id = ApiConfig.ID.GaslessTxService, + expected = ApiEnvironmentConfig( + environment = environment, + baseUrl = baseUrl, + headers = mapOf( + "Authorization" to ProviderSuspend { "Bearer $TANGEM_GASLESS_API_KEY" }, + "version" to ProviderSuspend { VERSION_NAME }, + "platform" to ProviderSuspend { "android" }, + "system_version" to ProviderSuspend { "Android 16" }, + "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "timezone" to ProviderSuspend { + TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() + }, + "device" to ProviderSuspend { "${Build.MANUFACTURER} ${Build.MODEL}".checkHeaderValueOrEmpty() }, ), ), ) @@ -300,7 +354,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/" @@ -338,6 +392,14 @@ internal class ProdApiConfigsManagerTest { baseUrl = baseUrl, headers = mapOf( "api-key" to ProviderSuspend { TANGEM_API_KEY }, + "version" to ProviderSuspend { VERSION_NAME }, + "platform" to ProviderSuspend { "android" }, + "system_version" to ProviderSuspend { "Android 16" }, + "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "timezone" to ProviderSuspend { + TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() + }, + "device" to ProviderSuspend { "${Build.MANUFACTURER} ${Build.MODEL}".checkHeaderValueOrEmpty() }, ), ), ) diff --git a/core/decompose/src/main/kotlin/com/tangem/core/decompose/utils/Extensions.kt b/core/decompose/src/main/kotlin/com/tangem/core/decompose/utils/Extensions.kt new file mode 100644 index 0000000000..f21a549bec --- /dev/null +++ b/core/decompose/src/main/kotlin/com/tangem/core/decompose/utils/Extensions.kt @@ -0,0 +1,18 @@ +package com.tangem.core.decompose.utils + +import com.arkivanov.decompose.router.stack.StackNavigation +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * Retrieves the current stack of items from the [com.arkivanov.decompose.router.stack.ChildStack] + * @return A list representing the current stack of items + */ +suspend fun StackNavigation.stack(): List { + return suspendCancellableCoroutine { continuation -> + this.navigate( + transformer = { stack -> stack }, + onComplete = { newStack, oldStack -> continuation.resume(newStack) }, + ) + } +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt index f7b78b226f..eb7a80bd00 100644 --- a/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt @@ -14,11 +14,13 @@ sealed class BatchFetchResult { * @param data fetched data. * @param empty indicates that data is empty and [BatchListSource] shouldn't create new batch for this result * @param last indicates if this is the last batch for the request. + * @param total total number of items available on the server. */ data class Success( val data: TData, val empty: Boolean, val last: Boolean, + val total: Int? = null, ) : BatchFetchResult() /** diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt index dab020caa2..0e70379d40 100644 --- a/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt @@ -69,7 +69,7 @@ private class DefaultBatchListSource private val updateFetcher: BatchUpdateFetcher? = null, ) : BatchListSource { - override val state = MutableStateFlow(BatchListState(emptyList(), PaginationStatus.None)) + override val state = MutableStateFlow(BatchListState(emptyList(), PaginationStatus.None, null)) override val updateResults = MutableSharedFlow>>( extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST, @@ -254,6 +254,7 @@ private class DefaultBatchListSource } else { PaginationStatus.Paginating(res) }, + totalCount = res.total, ) } is BatchFetchResult.Error -> { @@ -262,6 +263,7 @@ private class DefaultBatchListSource status = PaginationStatus.InitialLoadingError( throwable = res.throwable, ), + totalCount = null, ) } } diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchListState.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchListState.kt index b53ba23dc0..e752da816b 100644 --- a/core/pagination/src/main/java/com/tangem/pagination/BatchListState.kt +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchListState.kt @@ -8,10 +8,12 @@ package com.tangem.pagination * * @property data list of loaded batches. * @property status current status of the pagination. + * @property totalCount total number of items available on the server. * * @see BatchListSource */ data class BatchListState( val data: List>, val status: PaginationStatus, + val totalCount: Int? = null, ) \ No newline at end of file diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 33e81eb02f..db9bc30c60 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -61,6 +61,16 @@ dependencies { api(deps.jodatime) implementation(deps.timber) implementation(deps.markdown) + implementation(deps.haze) { + exclude(module = "activity-compose") + exclude(module = "activity") + exclude(module = "activity-ktx") + } + implementation(deps.haze.materials) { + exclude(module = "activity-compose") + exclude(module = "activity") + exclude(module = "activity-ktx") + } /** Tests */ testImplementation(deps.test.junit) diff --git a/core/ui/detekt-baseline-debug.xml b/core/ui/detekt-baseline-debug.xml index 662bc3318f..9cd3507ca1 100644 --- a/core/ui/detekt-baseline-debug.xml +++ b/core/ui/detekt-baseline-debug.xml @@ -5,12 +5,6 @@ BooleanPropertyNaming:BigDecimalPercentFormat.kt$BigDecimalPercentFormat$val withPercentSign: Boolean = true BooleanPropertyNaming:FullScreen.kt$FullScreenLayout$private val focusable: Boolean BooleanPropertyNaming:TokenItemState.kt$TokenItemState.TitleState.Content$val earnApyIsActive: Boolean = false - CanBeNonNullable:FooterContainer.kt$footer: TextReference? = null - CanBeNonNullable:InputRowImageBase.kt$iconRes: Int? - CanBeNonNullable:InputRowImageInfo.kt$subtitleEndIconRes: Int? - CanBeNonNullable:SearchBar.kt$keyboardController: SoftwareKeyboardController? - CanBeNonNullable:TextFields.kt$caption: String? = null - CanBeNonNullable:TransactionList.kt$txHistoryItems: LazyPagingItems<TxHistoryState.TxHistoryItemState>? CastNullableToNonNullableType:DialogFullScreen.kt$as CastNullableToNonNullableType:FullScreen.kt$FullScreenLayout$as MultilineLambdaItParameter:Actions.kt${ ActionButtonContent( config = config, text = { textColor -> Text(text = config.text, textColor = textColor) }, modifier = it.padding( start = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing24, ), ) } @@ -27,6 +21,5 @@ ReusedModifierInstance:TangemRadioButton.kt$AnimatedVisibility( visible = isSelected, label = "Radio button animation", modifier = modifier .size(TangemTheme.dimens.size24), ) { Icon( painter = painterResource(id = R.drawable.ic_check_circle_24), contentDescription = null, tint = TangemTheme.colors.control.checked, ) } ReusedModifierInstance:TokenPrice.kt$Icon( modifier = modifier, painter = painterResource( id = when (animatedType) { PriceChangeType.UP -> R.drawable.ic_arrow_up_8 PriceChangeType.DOWN -> R.drawable.ic_arrow_down_8 PriceChangeType.NEUTRAL -> R.drawable.ic_elipse_8 }, ), tint = when (animatedType) { PriceChangeType.UP -> TangemTheme.colors.icon.accent PriceChangeType.DOWN -> TangemTheme.colors.icon.warning PriceChangeType.NEUTRAL -> TangemTheme.colors.icon.inactive }, contentDescription = null, ) UnnecessaryEventHandlerParameter:PinTextField.kt$onValueChange: (String) -> Unit - UnnecessaryEventHandlerParameter:ResizableText.kt$onFontSizeChange: (Float) -> Unit diff --git a/core/ui/src/main/java/com/tangem/core/ui/DesignFeatureToggles.kt b/core/ui/src/main/java/com/tangem/core/ui/DesignFeatureToggles.kt new file mode 100644 index 0000000000..fc8cf40b5c --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/DesignFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui + +interface DesignFeatureToggles { + val isRedesignEnabled: Boolean +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt b/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt index 0db95df8d1..10c50f5742 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt @@ -16,4 +16,6 @@ interface UiDependencies { val globalSnackbarHostState: SnackbarHostState val eventMessageHandler: EventMessageHandler + + val designFeatureToggles: DesignFeatureToggles } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt index 00b71108b4..8c5d8daa49 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt @@ -170,6 +170,8 @@ fun PrimaryButtonIconStart( modifier: Modifier = Modifier, showProgress: Boolean = false, enabled: Boolean = true, + size: TangemButtonSize = TangemButtonSize.Default, + shape: Shape = size.toShape(), ) { TangemButton( modifier = modifier, @@ -180,6 +182,8 @@ fun PrimaryButtonIconStart( enabled = enabled, showProgress = showProgress, textStyle = TangemTheme.typography.subtitle1, + size = size, + shape = shape, ) } // endregion PrimaryButton diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/CurrencyPlaceholderIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/CurrencyPlaceholderIcon.kt deleted file mode 100644 index 0cfef1c3d7..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/CurrencyPlaceholderIcon.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.tangem.core.ui.components - -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.res.TangemTheme - -/** - * Currency placeholder icon - * - * @param id currency id - * @param modifier modifier - */ -@Composable -fun CurrencyPlaceholderIcon(id: String, modifier: Modifier = Modifier) { - Box( - contentAlignment = Alignment.Center, - modifier = modifier.background(color = TangemTheme.colors.icon.secondary, shape = CircleShape), - ) { - Text( - text = id.firstOrNull()?.titlecase().orEmpty(), - modifier = Modifier.padding(TangemTheme.dimens.spacing4), - color = TangemTheme.colors.text.primary2, - textAlign = TextAlign.Center, - // FIXME("Incorrect typography. Replace with typography from design system") - style = TangemTheme.typography.h3.copy(fontWeight = FontWeight.Bold), - ) - } -} - -// region preview - -@Preview(showBackground = true, heightDp = 40, widthDp = 40) -@Preview(showBackground = true, heightDp = 40, widthDp = 40, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun Preview_CurrencyPlaceholderIcon() { - TangemThemePreview { - CurrencyPlaceholderIcon(id = "DAI") - } -} -// endregion preview \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt index faeb20ff2f..bf788f49a2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Dialogs.kt @@ -70,17 +70,6 @@ fun BasicDialog( ) } -@Composable -fun SimpleOkDialog(message: String, onDismissDialog: () -> Unit) { - TangemDialog( - type = DialogType.Message(message), - confirmButton = DialogButtonUM(onClick = onDismissDialog), - onDismissDialog = onDismissDialog, - title = null, - dismissButton = null, - ) -} - /** * Dialog with text field * @@ -124,34 +113,6 @@ fun TextInputDialog( ) } -@Composable -fun TextInputDialog( - fieldValue: String, - confirmButton: DialogButtonUM, - onDismissDialog: () -> Unit, - onValueChange: (String) -> Unit, - textFieldParams: AdditionalTextInputDialogUM = remember { AdditionalTextInputDialogUM() }, - title: String? = null, - dismissButton: DialogButtonUM? = null, - isDismissable: Boolean = true, -) { - TangemDialog( - type = DialogType.SimpleTextInput( - value = fieldValue, - onValueChange = onValueChange, - params = textFieldParams, - ), - confirmButton = confirmButton, - onDismissDialog = onDismissDialog, - title = title, - dismissButton = dismissButton, - properties = DialogProperties( - dismissOnBackPress = isDismissable, - dismissOnClickOutside = isDismissable, - ), - ) -} - @Composable fun SelectorDialog( selectedItemIndex: Int, @@ -441,14 +402,6 @@ private sealed class DialogType { // endregion Defaults // region Preview -@Composable -private fun SimpleOkDialogPreview() { - SimpleOkDialog( - message = "All protected passwords will be deleted from the " + - "secure storage, you must enter the wallet password to work with the app.", - ) {} -} - @Composable private fun BasicDialogPreview() { BasicDialog( @@ -461,15 +414,6 @@ private fun BasicDialogPreview() { ) } -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun Preview_SimpleOkDialog() { - TangemThemePreview { - SimpleOkDialogPreview() - } -} - @Preview(showBackground = true) @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt b/core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt deleted file mode 100644 index 892ef05493..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt +++ /dev/null @@ -1,142 +0,0 @@ -package com.tangem.core.ui.components - -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.SubcomposeLayout -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.TextUnit -import com.tangem.core.ui.components.atoms.text.BoundCounter - -/** - * https://stackoverflow.com/questions/69083061/how-to-make-middle-ellipsis-in-text-with-jetpack-compose - */ -@Deprecated("Use EllipsisText with TextEllipsis.Middle ellipsis instead") -@Suppress("LongMethod") -@Composable -fun MiddleEllipsisText( - text: String, - modifier: Modifier = Modifier, - color: Color = Color.Unspecified, - fontSize: TextUnit = TextUnit.Unspecified, - fontStyle: FontStyle? = null, - fontWeight: FontWeight? = null, - fontFamily: FontFamily? = null, - letterSpacing: TextUnit = TextUnit.Unspecified, - textDecoration: TextDecoration? = null, - textAlign: TextAlign? = null, - lineHeight: TextUnit = TextUnit.Unspecified, - softWrap: Boolean = true, - onTextLayout: (TextLayoutResult) -> Unit = {}, - style: TextStyle = LocalTextStyle.current, -) { - // some letters, like "r", will have less width when placed right before "." - // adding a space to prevent such case - val layoutText = remember(text) { "$text $ellipsisText" } - val textLayoutResultState = remember(layoutText) { - mutableStateOf(null) - } - SubcomposeLayout(modifier) { constraints -> - // result is ignored - we only need to fill our textLayoutResult - subcompose("measure") { - Text( - text = layoutText, - color = color, - fontSize = fontSize, - fontStyle = fontStyle, - fontWeight = fontWeight, - fontFamily = fontFamily, - letterSpacing = letterSpacing, - textDecoration = textDecoration, - textAlign = textAlign, - lineHeight = lineHeight, - softWrap = softWrap, - maxLines = 1, - onTextLayout = { textLayoutResultState.value = it }, - style = style, - ) - }.first().measure(Constraints()) - // to allow smart cast - val textLayoutResult = textLayoutResultState.value - ?: // shouldn't happen - onTextLayout is called before subcompose finishes - return@SubcomposeLayout layout(0, 0) {} - val placeable = subcompose("visible") { - val finalText = remember(text, textLayoutResult, constraints.maxWidth) { - if ( - text.isEmpty() || - textLayoutResult.getBoundingBox(text.indices.last).right <= constraints.maxWidth - ) { - // text not including ellipsis fits on the first line. - return@remember text - } - - val ellipsisWidth = layoutText.indices.toList() - .takeLast(ELLIPSIS_CHARACTERS_COUNT) - .let widthLet@{ indices -> - // fix this bug: https://issuetracker.google.com/issues/197146630 - // in this case width is invalid - for (i in indices) { - val width = textLayoutResult.getBoundingBox(i).width - if (width > 0) { - return@widthLet width * ELLIPSIS_CHARACTERS_COUNT - } - } - // this should not happen, because - // this error occurs only for the last character in the string - error("all ellipsis chars have invalid width") - } - val availableWidth = constraints.maxWidth - ellipsisWidth - val startCounter = BoundCounter(text, textLayoutResult) { it } - val endCounter = BoundCounter(text, textLayoutResult) { text.indices.last - it } - - while (availableWidth - startCounter.width - endCounter.width > 0) { - val possibleEndWidth = endCounter.widthWithNextChar() - if ( - startCounter.width >= possibleEndWidth && - availableWidth - startCounter.width - possibleEndWidth >= 0 - ) { - endCounter.addNextChar() - } else if (availableWidth - startCounter.widthWithNextChar() - endCounter.width >= 0) { - startCounter.addNextChar() - } else { - break - } - } - startCounter.string.trimEnd() + ellipsisText + endCounter.string.reversed().trimStart() - } - Text( - text = finalText, - color = color, - fontSize = fontSize, - fontStyle = fontStyle, - fontWeight = fontWeight, - fontFamily = fontFamily, - letterSpacing = letterSpacing, - textDecoration = textDecoration, - textAlign = textAlign, - lineHeight = lineHeight, - softWrap = softWrap, - onTextLayout = onTextLayout, - style = style, - ) - }[0].measure(constraints) - layout(placeable.width, placeable.height) { - placeable.place(0, 0) - } - } -} - -private const val ELLIPSIS_CHARACTERS_COUNT = 3 -private const val ELLIPSIS_CHARACTER = '.' -private val ellipsisText = List(ELLIPSIS_CHARACTERS_COUNT) { ELLIPSIS_CHARACTER }.joinToString(separator = "") \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/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/audits/AuditLabel.kt b/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabel.kt index e5293e07ab..290eaf58ac 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabel.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabel.kt @@ -62,6 +62,7 @@ private fun getTextColorByType(type: AuditLabelUM.Type): Color { AuditLabelUM.Type.Warning -> TangemTheme.colors.text.attention AuditLabelUM.Type.Permit -> TangemTheme.colors.text.accent AuditLabelUM.Type.Info -> TangemTheme.colors.text.primary2 + AuditLabelUM.Type.General -> TangemTheme.colors.text.secondary } } @@ -73,6 +74,7 @@ private fun getBackgroundColorByType(type: AuditLabelUM.Type): Color { AuditLabelUM.Type.Warning -> TangemTheme.colors.text.attention.copy(alpha = 0.1f) AuditLabelUM.Type.Permit -> TangemTheme.colors.text.accent.copy(alpha = 0.1f) AuditLabelUM.Type.Info -> TangemTheme.colors.text.accent + AuditLabelUM.Type.General -> TangemTheme.colors.control.default } } @@ -83,6 +85,7 @@ private fun getPaddingByType(type: AuditLabelUM.Type): PaddingValues { AuditLabelUM.Type.Prohibition, AuditLabelUM.Type.Warning, AuditLabelUM.Type.Permit, + AuditLabelUM.Type.General, -> PaddingValues(horizontal = 4.dp) AuditLabelUM.Type.Info -> PaddingValues(horizontal = 6.dp, vertical = 1.dp) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabelUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabelUM.kt index 4b1ed19772..a3be3ff135 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabelUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/audits/AuditLabelUM.kt @@ -25,5 +25,8 @@ data class AuditLabelUM(val text: TextReference, val type: Type) { /** Blue with white text. Like, "best rate" */ Info, + + /** Gray. Like, "info" */ + General, } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt index 95ce4090db..8b8f7d3fd4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt @@ -1,32 +1,25 @@ package com.tangem.core.ui.components.bottomsheets.internal import androidx.activity.compose.BackHandler -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.key.* import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun ModalBottomSheetWithBackHandling( onDismissRequest: () -> Unit, - onBack: (() -> Unit), modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, sheetState: SheetState = rememberModalBottomSheetState(), sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, shape: Shape = BottomSheetDefaults.ExpandedShape, @@ -38,28 +31,9 @@ fun ModalBottomSheetWithBackHandling( contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets }, content: @Composable ColumnScope.() -> Unit, ) { - BackHandler(enabled = sheetState.targetValue != SheetValue.Hidden) { - onBack() - } - - val requester = remember { FocusRequester() } - val backPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current - ModalBottomSheet( onDismissRequest = onDismissRequest, - modifier = modifier - .focusRequester(requester) - .focusable() - .onPreviewKeyEvent { keyEvent -> - if (keyEvent.key == Key.Back && - keyEvent.type == KeyEventType.KeyUp && - !keyEvent.nativeKeyEvent.isCanceled - ) { - backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed() - return@onPreviewKeyEvent true - } - return@onPreviewKeyEvent false - }, + modifier = modifier, sheetState = sheetState, sheetMaxWidth = sheetMaxWidth, shape = shape, @@ -69,18 +43,17 @@ fun ModalBottomSheetWithBackHandling( scrimColor = scrimColor, dragHandle = dragHandle, contentWindowInsets = contentWindowInsets, - properties = ModalBottomSheetDefaults.properties( - // Set false otherwise the onPreviewKeyEvent doesn't work at all. - // The functionality of shouldDismissOnBackPress is achieved by the BackHandler. - shouldDismissOnBackPress = false, + properties = ModalBottomSheetProperties( + shouldDismissOnBackPress = onBack == null, ), - content = content, - ) + content = { + content() - LaunchedEffect(Unit) { - delay(timeMillis = 200) - requester.requestFocus() - } + BackHandler(enabled = onBack != null && sheetState.targetValue != SheetValue.Hidden) { + onBack?.invoke() + } + }, + ) } @OptIn(ExperimentalMaterial3Api::class) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt index 7b3ab2dd0b..75ed9271df 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt @@ -27,6 +27,7 @@ import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults import com.tangem.core.ui.components.icons.HighlightedIcon import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -109,7 +110,7 @@ private fun ContentContainer(state: MessageBottomSheetUMV2.InfoBlock, modifier: modifier = Modifier .fillMaxWidth() .padding(top = TangemTheme.dimens.spacing8), - text = body.resolveReference(), + text = body.resolveAnnotatedReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.secondary, textAlign = TextAlign.Center, @@ -124,7 +125,6 @@ private fun ContentContainer(state: MessageBottomSheetUMV2.InfoBlock, modifier: } } -@Suppress("CanBeNonNullable") @Composable private fun BottomSheetIconContainer( icon: MessageBottomSheetUMV2.Icon?, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt index 2d29c50afc..8731e72e07 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt @@ -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 = { @@ -207,32 +207,18 @@ inline fun BasicModalBottomSheet( noinline onBack: (() -> Unit)? = null, noinline bsContent: @Composable ColumnScope.() -> Unit, ) { - if (onBack != null) { - ModalBottomSheetWithBackHandling( - modifier = modifier, - onDismissRequest = config.onDismissRequest, - sheetState = sheetState, - containerColor = Color.Transparent, - shape = TangemTheme.shapes.roundedCornersLarge, - contentWindowInsets = { WindowInsetsZero }, - onBack = onBack, - dragHandle = null, - content = bsContent, - scrimColor = TangemTheme.colors.overlay.secondary, - ) - } else { - ModalBottomSheet( - modifier = modifier, - onDismissRequest = config.onDismissRequest, - sheetState = sheetState, - containerColor = Color.Transparent, - shape = TangemTheme.shapes.roundedCornersLarge, - contentWindowInsets = { WindowInsetsZero }, - dragHandle = null, - content = bsContent, - scrimColor = TangemTheme.colors.overlay.secondary, - ) - } + ModalBottomSheetWithBackHandling( + modifier = modifier, + onDismissRequest = config.onDismissRequest, + sheetState = sheetState, + containerColor = Color.Transparent, + shape = TangemTheme.shapes.roundedCornersLarge, + contentWindowInsets = { WindowInsetsZero }, + onBack = onBack, + dragHandle = null, + content = bsContent, + scrimColor = TangemTheme.colors.overlay.secondary, + ) } // region Preview diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt index 00646f4f2e..4f05ab4074 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt @@ -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, @@ -247,32 +247,18 @@ inline fun BasicModalBottomSheetWit } } - if (onBack != null) { - ModalBottomSheetWithBackHandling( - modifier = modifier, - onDismissRequest = config.onDismissRequest, - sheetState = sheetState, - containerColor = Color.Transparent, - shape = TangemTheme.shapes.roundedCornersLarge, - contentWindowInsets = { WindowInsetsZero }, - onBack = onBack, - dragHandle = null, - content = bsContent, - scrimColor = TangemTheme.colors.overlay.secondary, - ) - } else { - ModalBottomSheet( - modifier = modifier, - onDismissRequest = config.onDismissRequest, - sheetState = sheetState, - containerColor = Color.Transparent, - shape = TangemTheme.shapes.roundedCornersLarge, - contentWindowInsets = { WindowInsetsZero }, - dragHandle = null, - content = bsContent, - scrimColor = TangemTheme.colors.overlay.secondary, - ) - } + ModalBottomSheetWithBackHandling( + modifier = modifier, + onDismissRequest = config.onDismissRequest, + sheetState = sheetState, + containerColor = Color.Transparent, + shape = TangemTheme.shapes.roundedCornersLarge, + contentWindowInsets = { WindowInsetsZero }, + onBack = onBack, + dragHandle = null, + content = bsContent, + scrimColor = TangemTheme.colors.overlay.secondary, + ) } // region Preview diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt index 0a9f35bb67..d609afe29a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt @@ -2,7 +2,6 @@ package com.tangem.core.ui.components.bottomsheets.sheet import androidx.compose.foundation.layout.* import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue.Expanded import androidx.compose.material3.rememberModalBottomSheetState @@ -134,7 +133,8 @@ inline fun PreviewBottomSheet( sheetState = SheetState( skipPartiallyExpanded = skipPartiallyExpanded, initialValue = Expanded, - density = LocalDensity.current, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ), onBack = null, containerColor = containerColor, @@ -181,30 +181,16 @@ inline fun BasicBottomSheet( } } - if (onBack != null) { - ModalBottomSheetWithBackHandling( - modifier = modifier.statusBarsPadding(), - onDismissRequest = config.onDismissRequest, - sheetState = sheetState, - containerColor = containerColor, - shape = TangemTheme.shapes.bottomSheetLarge, - contentWindowInsets = { WindowInsetsZero }, - dragHandle = { TangemBottomSheetDraggableHeader(color = containerColor) }, - onBack = onBack, - content = bsContent, - scrimColor = TangemTheme.colors.overlay.secondary, - ) - } else { - ModalBottomSheet( - modifier = modifier.statusBarsPadding(), - onDismissRequest = config.onDismissRequest, - sheetState = sheetState, - containerColor = containerColor, - shape = TangemTheme.shapes.bottomSheetLarge, - contentWindowInsets = { WindowInsetsZero }, - dragHandle = { TangemBottomSheetDraggableHeader(color = containerColor) }, - content = bsContent, - scrimColor = TangemTheme.colors.overlay.secondary, - ) - } + ModalBottomSheetWithBackHandling( + modifier = modifier.statusBarsPadding(), + onDismissRequest = config.onDismissRequest, + sheetState = sheetState, + containerColor = containerColor, + shape = TangemTheme.shapes.bottomSheetLarge, + contentWindowInsets = { WindowInsetsZero }, + dragHandle = { TangemBottomSheetDraggableHeader(color = containerColor) }, + onBack = onBack, + content = bsContent, + scrimColor = TangemTheme.colors.overlay.secondary, + ) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/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/chip/Chip.kt b/core/ui/src/main/java/com/tangem/core/ui/components/chip/Chip.kt new file mode 100644 index 0000000000..fb14a4a72e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/chip/Chip.kt @@ -0,0 +1,122 @@ +package com.tangem.core.ui.components.chip + +import android.content.res.Configuration +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +fun Chip(state: ChipUM, modifier: Modifier = Modifier) { + val backgroundColor by animateColorAsState( + targetValue = if (state.isSelected) { + TangemTheme.colors.button.primary + } else { + TangemTheme.colors.button.secondary + }, + ) + + val textColor by animateColorAsState( + targetValue = if (state.isSelected) { + TangemTheme.colors.text.primary2 + } else { + TangemTheme.colors.text.primary1 + }, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(color = backgroundColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = state.onClick, + ) + .padding(PaddingValues(horizontal = 24.dp, vertical = 8.dp)), + ) { + Text( + text = state.text.resolveReference(), + style = TangemTheme.typography.button, + color = textColor, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ChipPreview() { + TangemThemePreview { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .background(TangemTheme.colors.background.primary) + .padding(16.dp), + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Chip( + state = ChipUM( + id = 0, + text = TextReference.Str("All News"), + isSelected = true, + onClick = {}, + ), + ) + Chip( + state = ChipUM( + id = 1, + text = TextReference.Str("Regulation"), + isSelected = false, + onClick = {}, + ), + ) + Chip( + state = ChipUM( + id = 2, + text = TextReference.Str("ETFs"), + isSelected = false, + onClick = {}, + ), + ) + Chip( + state = ChipUM( + id = 3, + text = TextReference.Str("Institutions"), + isSelected = false, + onClick = {}, + ), + ) + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/chip/entity/ChipUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/chip/entity/ChipUM.kt new file mode 100644 index 0000000000..a2785cb42b --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/chip/entity/ChipUM.kt @@ -0,0 +1,10 @@ +package com.tangem.core.ui.components.chip.entity + +import com.tangem.core.ui.extensions.TextReference + +data class ChipUM( + val id: Int, + val text: TextReference, + val isSelected: Boolean = false, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/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/haze/HazeExt.kt b/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt new file mode 100644 index 0000000000..779a7a0b55 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt @@ -0,0 +1,57 @@ +package com.tangem.core.ui.components.haze + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.tangem.core.ui.res.LocalHazeState +import com.tangem.core.ui.res.LocalPowerSavingState +import com.tangem.core.ui.res.LocalRootBackgroundColor +import dev.chrisbanes.haze.* +import dev.chrisbanes.haze.materials.CupertinoMaterials +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +internal fun ProvideHaze(content: @Composable () -> Unit) { + val hazeState = rememberHazeState() + CompositionLocalProvider( + LocalHazeState provides hazeState, + LocalHazeStyle provides CupertinoMaterials.ultraThin(), + ) { + content() + } +} + +/** + * Applies a haze effect to the [Modifier] with consideration of global haze settings and power saving mode. + * + * @param configure A lambda to configure the [HazeEffectScope]. + * @return A [Modifier] with the configured haze effect applied. + */ +@Composable +fun Modifier.hazeEffectTangem( + style: HazeStyle = HazeStyle.Unspecified, + configure: HazeEffectScope.() -> Unit = {}, +): Modifier { + val powerSavingEnabled = LocalPowerSavingState.current.isPowerSavingModeEnabled.collectAsState() + val hazeState = LocalHazeState.current + val isGlobalBlurEnabled = hazeState.blurEnabled && !powerSavingEnabled.value + val rootBackground by LocalRootBackgroundColor.current + + return hazeEffect(hazeState, style) { + fallbackTint = HazeTint(rootBackground) + if (isGlobalBlurEnabled) { + configure() + } + blurEnabled = isGlobalBlurEnabled + } +} + +/** + * Applies a haze source to the [Modifier] using the current global haze state. + */ +@Composable +fun Modifier.hazeSourceTangem(zIndex: Float = 0f, key: Any? = null) = + this.hazeSource(LocalHazeState.current, zIndex, key) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/icons/IconTint.kt b/core/ui/src/main/java/com/tangem/core/ui/components/icons/IconTint.kt index db6a607f67..a41cdb95ef 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/icons/IconTint.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/icons/IconTint.kt @@ -7,4 +7,5 @@ enum class IconTint { Accent, Warning, Inactive, + Informative, } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/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/label/Label.kt b/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt index 0f6b07e0d1..025bbcab5e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/label/Label.kt @@ -50,7 +50,7 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) { val backgroundColor by animateColorAsState( targetValue = when (state.style) { LabelStyle.ACCENT -> TangemTheme.colors.text.accent.copy(alpha = 0.1f) - LabelStyle.REGULAR -> TangemTheme.colors.control.unchecked + LabelStyle.REGULAR -> TangemTheme.colors.control.default LabelStyle.WARNING -> TangemTheme.colors.text.warning.copy(alpha = 0.1f) }, ) @@ -168,7 +168,9 @@ private fun LabelPreview() { TangemThemePreview { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(16.dp), + modifier = Modifier + .background(TangemTheme.colors.background.primary) + .padding(16.dp), ) { FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt index 131469bd96..1a6f10a087 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/label/entity/LabelUM.kt @@ -12,6 +12,7 @@ data class LabelUM( @DrawableRes val icon: Int? = null, val onIconClick: (() -> Unit)? = null, val onClick: (() -> Unit)? = null, + val maxLines: Int = Int.MAX_VALUE, ) @Immutable diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/pager/PagerIndicator.kt b/core/ui/src/main/java/com/tangem/core/ui/components/pager/PagerIndicator.kt index 8dc43f2d53..418cc7344d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/pager/PagerIndicator.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/pager/PagerIndicator.kt @@ -1,5 +1,6 @@ package com.tangem.core.ui.components.pager +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope @@ -8,18 +9,24 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import kotlin.math.abs +import kotlin.math.min + +// six - cause the central indicator has width multiplied twice +private const val TOTAL_MAX_INDICATORS = 6 +private const val SPACER_COUNT_BETWEEN_INDICATORS = 4 /** * Horizontal pager indicator @@ -29,82 +36,163 @@ import com.tangem.core.ui.res.TangemThemePreview */ @Composable fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier, indicatorCount: Int = 5) { + if (pagerState.pageCount == 0) return + val listState = rememberLazyListState() val indicatorColor = TangemTheme.colors.control.key val overlayColor = TangemTheme.colors.overlay.secondary - val indicatorSize = 8.dp + + val inactiveIndicatorColor = remember(indicatorColor) { + indicatorColor.copy(alpha = 0.5f) + } + + val baseIndicatorSize = 8.dp val spacing = 4.dp - val totalWidth: Dp = indicatorSize * indicatorCount + spacing * (indicatorCount - 1) - val widthInPx = LocalDensity.current.run { indicatorSize.toPx() } - - val currentItem by remember { + val indicatorState by remember(pagerState, indicatorCount) { derivedStateOf { - pagerState.currentPage + val count = pagerState.pageCount + val current = pagerState.currentPage + + val winSize = min(indicatorCount, count) + val centerPosition = winSize / 2 + + val start = when { + count <= winSize -> 0 + current <= centerPosition -> 0 + current >= count - centerPosition - 1 -> count - winSize + else -> current - centerPosition + } + Triple(count, winSize, start) } } - val itemCount = pagerState.pageCount + val (itemCount, windowSize, windowStart) = indicatorState + val currentItem by remember { derivedStateOf { pagerState.currentPage } } - LaunchedEffect(key1 = currentItem) { - val viewportSize = listState.layoutInfo.viewportSize - listState.animateScrollToItem( - currentItem, - (widthInPx / 2 - viewportSize.width / 2).toInt(), - ) + LaunchedEffect(currentItem, windowStart) { + if (itemCount > windowSize) { + listState.animateScrollToItem(windowStart.coerceIn(0, itemCount - 1)) + } + } + + val maxContainerWidth = remember(baseIndicatorSize, spacing) { + baseIndicatorSize * TOTAL_MAX_INDICATORS + spacing * SPACER_COUNT_BETWEEN_INDICATORS } Box( modifier = modifier .height(32.dp) + .width(maxContainerWidth + 32.dp) .background( color = overlayColor, shape = CircleShape, ) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 12.dp) + .clip(CircleShape), contentAlignment = Alignment.Center, ) { LazyRow( - modifier = Modifier - .width(totalWidth), + modifier = Modifier.wrapContentWidth(), state = listState, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(spacing), userScrollEnabled = false, ) { indicatorItems( itemCount = itemCount, currentItem = currentItem, - indicatorShape = CircleShape, activeColor = indicatorColor, - inActiveColor = indicatorColor.copy(alpha = 0.5f), - indicatorSize = indicatorSize, + inActiveColor = inactiveIndicatorColor, + baseSize = baseIndicatorSize, + windowSize = windowSize, + windowStart = windowStart, ) } } } +@Suppress("MagicNumber", "CyclomaticComplexMethod") +private fun calculateIndicatorHeight(position: Int, currentPosition: Int, baseSize: Dp, windowSize: Int): Dp { + val distance = abs(position - currentPosition) + val mediumSize = 6.dp + val smallSize = 4.dp + + if (windowSize < 5) { + return when { + distance <= 1 -> baseSize + distance == 2 -> mediumSize + else -> smallSize + } + } + + val isEdgeFocus = currentPosition == 0 || currentPosition == windowSize - 1 + val isNearEdgeFocus = currentPosition == 1 || currentPosition == windowSize - 2 + return when { + isEdgeFocus -> when { + distance <= 2 -> baseSize + distance == 3 -> mediumSize + else -> smallSize + } + isNearEdgeFocus -> when { + distance <= 1 -> baseSize + distance == 2 -> mediumSize + else -> smallSize + } + else -> when { + distance <= 1 -> baseSize + else -> mediumSize + } + } +} + @Suppress("LongParameterList") private fun LazyListScope.indicatorItems( itemCount: Int, currentItem: Int, - indicatorShape: Shape, activeColor: Color, inActiveColor: Color, - indicatorSize: Dp, + baseSize: Dp, + windowSize: Int, + windowStart: Int, ) { - items(itemCount) { index -> + val safeWindowSize = min(windowSize, itemCount) + if (safeWindowSize <= 0) return - val isSelected = index == currentItem + val windowEnd = windowStart + safeWindowSize + val currentPosInWindow = (currentItem - windowStart).coerceIn(0, safeWindowSize - 1) + + items(itemCount) { pageIndex -> + val isInWindow = pageIndex in windowStart until windowEnd + val positionInWindow = (pageIndex - windowStart).coerceIn(0, safeWindowSize - 1) + + val isSelected = pageIndex == currentItem + + val refinedHeight = if (isInWindow) { + calculateIndicatorHeight( + position = positionInWindow, + currentPosition = currentPosInWindow, + baseSize = baseSize, + windowSize = safeWindowSize, + ) + } else { + 0.dp + } + val targetWidth = if (isSelected) refinedHeight * 2 else refinedHeight + val targetShape = if (isSelected) RoundedCornerShape(16.dp) else CircleShape + val animatedWidth by animateDpAsState(targetValue = targetWidth, label = "width") + val animatedHeight by animateDpAsState(targetValue = refinedHeight, label = "height") Box( modifier = Modifier - .clip(indicatorShape) - .size(indicatorSize) + .padding(vertical = (baseSize - animatedHeight) / 2) + .clip(targetShape) + .width(animatedWidth) + .height(animatedHeight) .background( if (isSelected) activeColor else inActiveColor, - indicatorShape, + targetShape, ), ) } @@ -112,19 +200,31 @@ private fun LazyListScope.indicatorItems( @Preview(showBackground = true) @Composable -private fun PagerIndicatorPreviewFirstPage() { +private fun PagerIndicatorPreview() { TangemThemePreview { - Box( + Column( modifier = Modifier .background(TangemTheme.colors.background.primary) - .padding(), - contentAlignment = Alignment.Center, + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { val pagerState = rememberPagerState( - initialPage = 0, + initialPage = 2, pageCount = { 10 }, ) PagerIndicator(pagerState = pagerState) + + val pagerState1 = rememberPagerState( + initialPage = 0, + pageCount = { 3 }, + ) + PagerIndicator(pagerState = pagerState1) + + val pagerState2 = rememberPagerState( + initialPage = 0, + pageCount = { 1 }, + ) + PagerIndicator(pagerState = pagerState2) } } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/powersaving/PowerSavingState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/powersaving/PowerSavingState.kt new file mode 100644 index 0000000000..54a3a42e9b --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/powersaving/PowerSavingState.kt @@ -0,0 +1,66 @@ +package com.tangem.core.ui.components.powersaving + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.PowerManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class PowerSavingState( + appContext: Context, + lifecycle: androidx.lifecycle.Lifecycle, +) { + + private val powerManager = + appContext.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: error("PowerManager not available") + + val isPowerSavingModeEnabled: StateFlow + field = MutableStateFlow(powerManager.isPowerSaveMode) + + init { + val intentFilter = IntentFilter().apply { + addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) + } + + val receiver = PowerSavingModeReceiver() + + ContextCompat.registerReceiver( + appContext, + receiver, + intentFilter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + appContext.unregisterReceiver(receiver) + } + }) + } + + inner class PowerSavingModeReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) { + isPowerSavingModeEnabled.value = powerManager.isPowerSaveMode + } + } + } +} + +@Composable +internal fun rememberPowerSavingState(): PowerSavingState { + val appContext = LocalContext.current.applicationContext + val lifecycle = LocalLifecycleOwner.current.lifecycle + return remember(appContext, lifecycle) { + PowerSavingState(appContext = appContext, lifecycle = lifecycle) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/ActionRow.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/ActionRow.kt deleted file mode 100644 index 781bc8b851..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/rows/ActionRow.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.tangem.core.ui.components.rows - -import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerH28 -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview - -/** - * Simple clickable action row, without input and icon - * - * https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-807&mode=design&t=Ygv5sohTTHYAQcBS-4 - */ -@Composable -fun SimpleActionRow(title: String, description: String, modifier: Modifier = Modifier, isClickable: Boolean = true) { - Box( - modifier = modifier - .background(color = TangemTheme.colors.background.action) - .height(TangemTheme.dimens.size44) - .fillMaxWidth(), - ) { - Column( - modifier = Modifier - .padding(end = TangemTheme.dimens.spacing48) - .align(Alignment.CenterStart), - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), - ) { - AnimatedContent(targetState = title, label = "") { title -> - Text( - text = title, - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.secondary, - ) - } - AnimatedContent(targetState = description, label = "") { description -> - Text( - text = description, - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.primary1, - ) - } - } - - if (isClickable) { - Icon( - painter = painterResource(id = R.drawable.ic_chevron_right_24), - contentDescription = null, - modifier = Modifier - .align(alignment = Alignment.CenterEnd) - .padding(end = TangemTheme.dimens.spacing12), - tint = TangemTheme.colors.icon.informative, - ) - } - } -} - -@Preview -@Composable -private fun SimpleActionRowPreview() { - Column { - TangemThemePreview(isDark = false) { - SimpleActionRow( - title = "Title", - description = "Description", - ) - } - - SpacerH28() - - TangemThemePreview(isDark = false) { - SimpleActionRow( - title = "Title", - description = "Description", - ) - } - } -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/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/states/SelectableItemsState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/states/SelectableItemsState.kt deleted file mode 100644 index 0d7b55596b..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/states/SelectableItemsState.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.core.ui.components.states - -import com.tangem.core.ui.extensions.TextReference -import kotlinx.collections.immutable.ImmutableList - -data class SelectableItemsState( - val selectedItem: Item, - val items: ImmutableList>, -) - -data class Item( - val id: Int, - val startText: TextReference, - val endText: TextReference, - val isSelected: Boolean, - val data: T, -) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt index 6b01ffbff7..0651618f73 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt @@ -2,6 +2,7 @@ package com.tangem.core.ui.components.token.internal import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -43,16 +44,33 @@ internal fun TokenFiatAmount(state: TokenFiatAmountState?, isBalanceHidden: Bool isFlickering = state.isFlickering, ) } + is TokenFiatAmountState.Icon -> { + IconAmount(modifier = modifier, state = state) + } is TokenFiatAmountState.Loading -> { RectangleShimmer(modifier = modifier.placeholderSize(), radius = 4.dp) } is TokenFiatAmountState.Locked -> { LockedRectangle(modifier = modifier.placeholderSize()) } + is TokenFiatAmountState.Empty -> { + // Empty box for proper measurements + Box(modifier) + } null -> Unit } } +@Composable +private fun IconAmount(state: TokenFiatAmountState.Icon, modifier: Modifier = Modifier) { + Icon( + modifier = modifier, + painter = rememberVectorPainter(image = ImageVector.vectorResource(state.iconRes)), + tint = state.tint.color(), + contentDescription = null, + ) +} + @Composable private fun ContentFiatAmount( text: String, @@ -76,11 +94,7 @@ private fun ContentFiatAmount( Icon( modifier = Modifier.size(12.dp), painter = rememberVectorPainter(image = ImageVector.vectorResource(icon.iconRes)), - tint = when (icon.tint) { - IconTint.Accent -> TangemTheme.colors.icon.accent - IconTint.Warning -> TangemTheme.colors.icon.attention - IconTint.Inactive -> TangemTheme.colors.icon.inactive - }, + tint = icon.tint.color(), contentDescription = null, ) } @@ -91,6 +105,14 @@ private fun ContentFiatAmount( } } +@Composable +private fun IconTint.color() = when (this) { + IconTint.Accent -> TangemTheme.colors.icon.accent + IconTint.Warning -> TangemTheme.colors.icon.attention + IconTint.Inactive -> TangemTheme.colors.icon.inactive + IconTint.Informative -> TangemTheme.colors.icon.informative +} + @Composable private fun FiatAmountText( text: String, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenTitle.kt index ed61f2011d..a60f268fcb 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenTitle.kt @@ -90,7 +90,7 @@ private fun YieldSupplyApyLabel(apy: TextReference?, isActive: Boolean, modifier color = if (isActive) { TangemTheme.colors.text.accent.copy(alpha = 0.1f) } else { - TangemTheme.colors.control.unchecked + TangemTheme.colors.control.default }, shape = TangemTheme.shapes.roundedCornersSmall2, ), diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/YieldSupplyPromoBanner.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/YieldSupplyPromoBanner.kt index 71ccba070a..8d60a22526 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/YieldSupplyPromoBanner.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/YieldSupplyPromoBanner.kt @@ -43,7 +43,7 @@ internal fun YieldSupplyPromoBanner(state: PromoBannerState.Content, modifier: M LaunchedEffect(state) { state.onPromoShown() } - val bgColor = TangemTheme.colors.control.unchecked + val bgColor = TangemTheme.colors.control.default Column(modifier = modifier) { Row( modifier = Modifier diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt index 7193eed339..d4e8f3e33e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt @@ -235,9 +235,16 @@ sealed class TokenItemState { val isFlickering: Boolean = false, ) : FiatAmountState() + data class Icon( + val iconRes: Int, + val tint: IconTint = IconTint.Inactive, + ) : FiatAmountState() + data object Loading : FiatAmountState() data object Locked : FiatAmountState() + + data object Empty : FiatAmountState() } @Immutable 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/components/transactions/TransactionList.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt index 5ff2f3c7bc..b5c5335aba 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt @@ -29,6 +29,7 @@ import kotlinx.collections.immutable.ImmutableList * @param txHistoryItems transactions * @param modifier modifier */ +@Suppress("CanBeNonNullable") fun LazyListScope.txHistoryItems( state: TxHistoryState, txHistoryItems: LazyPagingItems?, diff --git a/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/ds/badge/TangemBadge.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt new file mode 100644 index 0000000000..f41322e237 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt @@ -0,0 +1,344 @@ +package com.tangem.core.ui.ds.badge + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.badge.TangemBadgeSize.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * Tangem badge component to display a small piece of information with optional icon. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8441-83535&m=dev) + * + * @param badgeUM [TangemBadgeUM] containing all the badge parameters. + * @param modifier Modifier to be applied to the badge. + * +[REDACTED_AUTHOR] + */ +@Composable +fun TangemBadge(badgeUM: TangemBadgeUM, modifier: Modifier = Modifier) { + TangemBadge( + text = badgeUM.text, + iconRes = badgeUM.iconRes, + size = badgeUM.size, + shape = badgeUM.shape, + color = badgeUM.color, + type = badgeUM.type, + iconPosition = badgeUM.iconPosition, + modifier = modifier, + ) +} + +/** + * Tangem badge component to display a small piece of information with optional icon. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8441-83535&m=dev) + * + * @param text TextReference for the badge label. + * @param modifier Modifier to be applied to the badge. + * @param iconRes Drawable resource ID for the icon to be displayed in the badge. + * @param size [TangemBadgeSize] defining the size of the badge. + * @param shape [TangemBadgeShape] defining the shape of the badge. + * @param color [TangemBadgeColor] defining the color scheme of the badge. + * @param type [TangemBadgeType] defining the style of the badge. + * @param iconPosition [TangemBadgeIconPosition] defining icon position of the badge. + * +[REDACTED_AUTHOR] + */ +@Composable +fun TangemBadge( + text: TextReference, + modifier: Modifier = Modifier, + @DrawableRes iconRes: Int? = null, + size: TangemBadgeSize = X9, + shape: TangemBadgeShape = TangemBadgeShape.Default, + color: TangemBadgeColor = TangemBadgeColor.Gray, + type: TangemBadgeType = TangemBadgeType.Solid, + iconPosition: TangemBadgeIconPosition = TangemBadgeIconPosition.Start, +) { + val iconColor = getIconColor(type = type, color = color) + Row( + horizontalArrangement = Arrangement.spacedBy(size.toContentPadding()), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .heightIn(min = size.toHeightDp()) + .clip(shape.toShape(size)) + .getBackgroundColor(type = type, color = color, shape = shape.toShape(size)) + .padding(size.toPaddingDp(position = iconPosition)), + ) { + AnimatedVisibility( + visible = iconRes != null && iconPosition == TangemBadgeIconPosition.Start, + modifier = Modifier.size(size = size.toContentSize()), + label = "Start Icon Visibility", + ) { + val wrappedIconRes = remember(this) { requireNotNull(iconRes) } + Icon( + painter = painterResource(id = wrappedIconRes), + contentDescription = null, + tint = iconColor, + ) + } + Text( + text = text.resolveReference(), + style = size.toTextStyle(), + maxLines = 1, + color = getTextColor(type = type, color = color), + ) + + AnimatedVisibility( + visible = iconRes != null && iconPosition == TangemBadgeIconPosition.End, + modifier = Modifier.size(size = size.toContentSize()), + label = "End Icon Visibility", + ) { + val wrappedIconRes = remember(this) { requireNotNull(iconRes) } + Icon( + painter = painterResource(id = wrappedIconRes), + contentDescription = null, + tint = iconColor, + ) + } + } +} + +/** + * Tangem badge shape options. + */ +enum class TangemBadgeShape { + Default, + Rounded, + ; + + @ReadOnlyComposable + @Composable + internal fun toShape(size: TangemBadgeSize) = RoundedCornerShape( + when (this) { + Rounded -> when (size) { + X4, + X6, + -> TangemTheme.dimens2.x4 + X9 -> TangemTheme.dimens2.x25 + } + Default -> when (size) { + X4 -> TangemTheme.dimens2.x1 + X6, + X9, + -> 6.dp + } + }, + ) +} + +/** + * Tangem badge size options. + */ +enum class TangemBadgeSize { + X4, + X6, + X9, + ; + + @ReadOnlyComposable + @Composable + internal fun toHeightDp() = when (this) { + X4 -> TangemTheme.dimens2.x4 + X6 -> TangemTheme.dimens2.x6 + X9 -> TangemTheme.dimens2.x9 + } + + @ReadOnlyComposable + @Composable + internal fun toPaddingDp(position: TangemBadgeIconPosition) = when (this) { + X4 -> when (position) { + TangemBadgeIconPosition.Start -> PaddingValues(start = 4.dp, end = 6.dp) + TangemBadgeIconPosition.End -> PaddingValues(start = 6.dp, end = 4.dp) + } + X6 -> when (position) { + TangemBadgeIconPosition.Start -> PaddingValues(start = 8.dp, end = 12.dp) + TangemBadgeIconPosition.End -> PaddingValues(start = 12.dp, end = 8.dp) + } + X9 -> when (position) { + TangemBadgeIconPosition.Start -> PaddingValues(start = 12.dp, end = 16.dp) + TangemBadgeIconPosition.End -> PaddingValues(start = 16.dp, end = 12.dp) + } + } + + @ReadOnlyComposable + @Composable + internal fun toContentSize() = when (this) { + X4 -> TangemTheme.dimens2.x3 + X6, + X9, + -> TangemTheme.dimens2.x4 + } + + @ReadOnlyComposable + @Composable + internal fun toContentPadding() = when (this) { + X4 -> TangemTheme.dimens2.x0_5 + X6, + X9, + -> TangemTheme.dimens2.x1 + } + + @ReadOnlyComposable + @Composable + internal fun toTextStyle() = when (this) { + X4 -> TangemTheme.typography2.captionSemibold11 + X6 -> TangemTheme.typography2.captionSemibold12 + X9 -> TangemTheme.typography2.bodySemibold16 + } +} + +/** + * Position of the icon in the Tangem badge. + */ +enum class TangemBadgeIconPosition { + Start, + End, +} + +/** + * Tangem badge type options. + */ +enum class TangemBadgeType { + Solid, + Tinted, + Outline, +} + +/** + * Tangem badge color options. + */ +enum class TangemBadgeColor { + Blue, + Red, + Gray, +} + +@ReadOnlyComposable +@Composable +private fun getIconColor(type: TangemBadgeType, color: TangemBadgeColor) = when (color) { + TangemBadgeColor.Gray -> TangemTheme.colors2.markers.iconGray + TangemBadgeColor.Blue -> when (type) { + TangemBadgeType.Outline, + TangemBadgeType.Tinted, + -> TangemTheme.colors2.markers.iconBlue + TangemBadgeType.Solid -> TangemTheme.colors2.graphic.neutral.primaryInvertedConstant + } + TangemBadgeColor.Red -> when (type) { + TangemBadgeType.Outline, + TangemBadgeType.Tinted, + -> TangemTheme.colors2.markers.iconRed + TangemBadgeType.Solid -> TangemTheme.colors2.graphic.neutral.primaryInvertedConstant + } +} + +@ReadOnlyComposable +@Composable +private fun getTextColor(type: TangemBadgeType, color: TangemBadgeColor) = when (color) { + TangemBadgeColor.Gray -> TangemTheme.colors2.markers.textGray + TangemBadgeColor.Blue -> when (type) { + TangemBadgeType.Outline, + TangemBadgeType.Tinted, + -> TangemTheme.colors2.markers.textBlue + TangemBadgeType.Solid -> TangemTheme.colors2.text.neutral.primaryInvertedConstant + } + TangemBadgeColor.Red -> when (type) { + TangemBadgeType.Outline, + TangemBadgeType.Tinted, + -> TangemTheme.colors2.markers.textRed + TangemBadgeType.Solid -> TangemTheme.colors2.text.neutral.primaryInvertedConstant + } +} + +@ReadOnlyComposable +@Composable +private fun Modifier.getBackgroundColor(type: TangemBadgeType, color: TangemBadgeColor, shape: Shape) = when (type) { + TangemBadgeType.Solid -> background( + when (color) { + TangemBadgeColor.Gray -> TangemTheme.colors2.markers.backgroundSolidGray + TangemBadgeColor.Blue -> TangemTheme.colors2.markers.backgroundSolidBlue + TangemBadgeColor.Red -> TangemTheme.colors2.markers.backgroundSolidRed + }, + ) + TangemBadgeType.Tinted -> background( + when (color) { + TangemBadgeColor.Gray -> TangemTheme.colors2.markers.backgroundTintedGray + TangemBadgeColor.Blue -> TangemTheme.colors2.markers.backgroundTintedBlue + TangemBadgeColor.Red -> TangemTheme.colors2.markers.backgroundTintedRed + }, + ) + TangemBadgeType.Outline -> { + border( + color = when (color) { + TangemBadgeColor.Gray -> TangemTheme.colors2.markers.borderGray + TangemBadgeColor.Blue -> TangemTheme.colors2.markers.borderTintedBlue + TangemBadgeColor.Red -> TangemTheme.colors2.markers.borderTintedRed + }, + shape = shape, + width = 1.dp, + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemBadge_Preview(@PreviewParameter(TangemBadgePreviewProvider::class) params: TangemBadgeColor) { + TangemThemePreviewRedesign { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + repeat(2) { yIndex -> + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + repeat(TangemBadgeType.entries.size) { index -> + TangemBadge( + text = stringReference("Title"), + iconRes = R.drawable.ic_information_24, + type = TangemBadgeType.entries[index], + color = params, + shape = TangemBadgeShape.entries[yIndex % 2], + iconPosition = TangemBadgeIconPosition.entries[yIndex % 2], + ) + } + } + } + } + } +} + +private class TangemBadgePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemBadgeColor.Gray, + TangemBadgeColor.Blue, + TangemBadgeColor.Red, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt new file mode 100644 index 0000000000..e28cbdd6ba --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt @@ -0,0 +1,27 @@ +package com.tangem.core.ui.ds.badge + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.ds.badge.TangemBadgeSize.X9 +import com.tangem.core.ui.extensions.TextReference + +/** + * UI model for [TangemBadge] component + * + * @param text TextReference for the badge label. + * @param modifier Modifier to be applied to the badge. + * @param iconRes Drawable resource ID for the icon to be displayed in the badge. + * @param size [TangemBadgeSize] defining the size of the badge. + * @param shape [TangemBadgeShape] defining the shape of the badge. + * @param color [TangemBadgeColor] defining the color scheme of the badge. + * @param type [TangemBadgeType] defining the style of the badge. + * @param iconPosition [TangemBadgeIconPosition] defining icon position of the badge. + */ +class TangemBadgeUM( + val text: TextReference, + @DrawableRes val iconRes: Int? = null, + val size: TangemBadgeSize = X9, + val shape: TangemBadgeShape = TangemBadgeShape.Default, + val color: TangemBadgeColor = TangemBadgeColor.Gray, + val type: TangemBadgeType = TangemBadgeType.Solid, + val iconPosition: TangemBadgeIconPosition = TangemBadgeIconPosition.Start, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/AccentTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/AccentTangemButton.kt new file mode 100644 index 0000000000..da0e951bfa --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/AccentTangemButton.kt @@ -0,0 +1,125 @@ +package com.tangem.core.ui.ds.button + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * [Accent Tangem button](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8004-26798) + * + * @param onClick Lambda to be invoked when the button is clicked. + * @param modifier Modifier to be applied to the button. + * @param text TextReference for the button label. + * @param iconRes Drawable resource ID for the icon to be displayed in the button. + * @param iconPosition Position of the icon (Start or End). + * @param enabled Boolean indicating whether the button is enabled. + * @param size TangemButtonSize defining the size of the button. + * @param state TangemButtonState defining the current state of the button. + * @param shape TangemButtonShape defining the shape of the button. + * +[REDACTED_AUTHOR] + */ +@Composable +fun AccentTangemButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: TextReference? = null, + @DrawableRes iconRes: Int? = null, + iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, + enabled: Boolean = true, + size: TangemButtonSize = TangemButtonSize.X15, + state: TangemButtonState = TangemButtonState.Default, + shape: TangemButtonShape = TangemButtonShape.Default, +) { + TangemButtonInternal( + onClick = onClick, + modifier = modifier + .clip(shape.toShape(size)) + .then( + when (state) { + TangemButtonState.Disabled, + TangemButtonState.Default, + -> Modifier.background(TangemTheme.colors2.button.backgroundPositive) + TangemButtonState.Loading, + TangemButtonState.Pressed, + -> Modifier + .background(TangemTheme.colors2.button.backgroundPositive) + .background(TangemTheme.colors2.overlay.overlaySecondary) + }, + ), + text = text, + contentColor = TangemTheme.colors2.text.neutral.primaryInvertedConstant, + iconRes = iconRes, + enabled = enabled, + size = size, + state = state, + iconPosition = iconPosition, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 480) +@Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun AccentTangemButton_Preview( + @PreviewParameter(AccentTangemButtonPreviewProvider::class) params: TangemButtonState, +) { + TangemThemePreviewRedesign { + Row( + horizontalArrangement = Arrangement.spacedBy(21.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + repeat(4) { yIndex -> + val shape = if (yIndex < 2) TangemButtonShape.Default else TangemButtonShape.Rounded + val text = if (yIndex % 2 == 1) null else stringReference("Button") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(2) { xIndex -> + val iconPosition = if (xIndex == 1) { + TangemButtonIconPosition.Start + } else { + TangemButtonIconPosition.End + } + AccentTangemButton( + onClick = {}, + text = text, + size = TangemButtonSize.X15, + shape = shape, + iconPosition = iconPosition, + iconRes = R.drawable.ic_tangem_24, + state = params, + ) + } + } + } + } + } +} + +private class AccentTangemButtonPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemButtonState.Default, + TangemButtonState.Pressed, + TangemButtonState.Loading, + TangemButtonState.Disabled, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt new file mode 100644 index 0000000000..16b1f51876 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/GhostTangemButton.kt @@ -0,0 +1,111 @@ +package com.tangem.core.ui.ds.button + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * [Ghost Tangem button](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=5854-4804) + * + * @param onClick Lambda to be invoked when the button is clicked. + * @param modifier Modifier to be applied to the button. + * @param text TextReference for the button label. + * @param iconRes Drawable resource ID for the icon to be displayed in the button. + * @param iconPosition Position of the icon (Start or End). + * @param enabled Boolean indicating whether the button is enabled. + * @param size TangemButtonSize defining the size of the button. + * @param state TangemButtonState defining the current state of the button. + * +[REDACTED_AUTHOR] + */ +@Composable +fun GhostTangemButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: TextReference? = null, + @DrawableRes iconRes: Int? = null, + enabled: Boolean = true, + size: TangemButtonSize = TangemButtonSize.X15, + state: TangemButtonState = TangemButtonState.Default, + iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, +) { + val contentColor = when (state) { + TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled + else -> TangemTheme.colors2.text.neutral.primary + } + TangemButtonInternal( + onClick = onClick, + modifier = modifier, + text = text, + contentColor = contentColor, + enabled = enabled, + size = size, + state = state, + iconPosition = iconPosition, + iconRes = iconRes, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 480) +@Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun GhostTangemButton_Preview( + @PreviewParameter(GhostTangemButtonPreviewProvider::class) params: TangemButtonState, +) { + TangemThemePreviewRedesign { + Row( + horizontalArrangement = Arrangement.spacedBy(21.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + repeat(4) { yIndex -> + val text = if (yIndex % 2 == 1) null else stringReference("Button") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(2) { xIndex -> + val iconPosition = if (xIndex == 1) { + TangemButtonIconPosition.Start + } else { + TangemButtonIconPosition.End + } + GhostTangemButton( + onClick = {}, + text = text, + size = TangemButtonSize.X15, + iconPosition = iconPosition, + iconRes = R.drawable.ic_tangem_24, + state = params, + ) + } + } + } + } + } +} + +private class GhostTangemButtonPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemButtonState.Default, + TangemButtonState.Pressed, + TangemButtonState.Loading, + TangemButtonState.Disabled, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/OutlineTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/OutlineTangemButton.kt new file mode 100644 index 0000000000..e86db3a8b8 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/OutlineTangemButton.kt @@ -0,0 +1,132 @@ +package com.tangem.core.ui.ds.button + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * [Outline Tangem button](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=5854-4800) + * + * @param onClick Lambda to be invoked when the button is clicked. + * @param modifier Modifier to be applied to the button. + * @param text TextReference for the button label. + * @param iconRes Drawable resource ID for the icon to be displayed in the button. + * @param iconPosition Position of the icon (Start or End). + * @param enabled Boolean indicating whether the button is enabled. + * @param size TangemButtonSize defining the size of the button. + * @param state TangemButtonState defining the current state of the button. + * @param shape TangemButtonShape defining the shape of the button. + * +[REDACTED_AUTHOR] + */ +@Composable +fun OutlineTangemButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: TextReference? = null, + @DrawableRes iconRes: Int? = null, + iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, + enabled: Boolean = true, + size: TangemButtonSize = TangemButtonSize.X15, + state: TangemButtonState = TangemButtonState.Default, + shape: TangemButtonShape = TangemButtonShape.Default, +) { + val backgroundModifier = when (state) { + TangemButtonState.Loading, + TangemButtonState.Pressed, + TangemButtonState.Disabled, + TangemButtonState.Default, + -> Modifier + .background(TangemTheme.colors2.surface.level1) + .border( + width = 1.dp, + color = TangemTheme.colors2.border.neutral.primary, + shape = shape.toShape(size), + ) + } + val contentColor = when (state) { + TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled + else -> TangemTheme.colors2.text.neutral.primary + } + TangemButtonInternal( + onClick = onClick, + modifier = modifier + .clip(shape.toShape(size)) + .then(backgroundModifier), + text = text, + contentColor = contentColor, + iconRes = iconRes, + enabled = enabled, + size = size, + state = state, + iconPosition = iconPosition, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 480) +@Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun OutlineTangemButton_Preview( + @PreviewParameter(OutlineTangemButtonPreviewProvider::class) params: TangemButtonState, +) { + TangemThemePreviewRedesign { + Row( + horizontalArrangement = Arrangement.spacedBy(21.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + repeat(4) { yIndex -> + val shape = if (yIndex < 2) TangemButtonShape.Default else TangemButtonShape.Rounded + val text = if (yIndex % 2 == 1) null else stringReference("Button") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(2) { xIndex -> + val iconPosition = if (xIndex == 0) { + TangemButtonIconPosition.Start + } else { + TangemButtonIconPosition.End + } + OutlineTangemButton( + onClick = {}, + text = text, + size = TangemButtonSize.X15, + shape = shape, + iconPosition = iconPosition, + iconRes = R.drawable.ic_tangem_24, + state = params, + ) + } + } + } + } + } +} + +private class OutlineTangemButtonPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemButtonState.Default, + TangemButtonState.Pressed, + TangemButtonState.Loading, + TangemButtonState.Disabled, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryInverseTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryInverseTangemButton.kt new file mode 100644 index 0000000000..8fc4f8644e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryInverseTangemButton.kt @@ -0,0 +1,127 @@ +package com.tangem.core.ui.ds.button + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * [Primary Inverse Tangem button](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=7545-78314) + * + * @param onClick Lambda to be invoked when the button is clicked. + * @param modifier Modifier to be applied to the button. + * @param text TextReference for the button label. + * @param iconRes Drawable resource ID for the icon to be displayed in the button. + * @param iconPosition Position of the icon (Start or End). + * @param enabled Boolean indicating whether the button is enabled. + * @param size TangemButtonSize defining the size of the button. + * @param state TangemButtonState defining the current state of the button. + * @param shape TangemButtonShape defining the shape of the button. + * +[REDACTED_AUTHOR] + */ +@Composable +fun PrimaryInverseTangemButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: TextReference? = null, + @DrawableRes iconRes: Int? = null, + iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, + enabled: Boolean = true, + size: TangemButtonSize = TangemButtonSize.X15, + state: TangemButtonState = TangemButtonState.Default, + shape: TangemButtonShape = TangemButtonShape.Default, +) { + val backgroundModifier = when (state) { + TangemButtonState.Default -> Modifier.background(TangemTheme.colors2.button.backgroundPrimaryInverse) + TangemButtonState.Disabled -> Modifier.background(TangemTheme.colors2.button.backgroundDisabled) + TangemButtonState.Loading, + TangemButtonState.Pressed, + -> Modifier + .background(TangemTheme.colors2.button.backgroundPrimaryInverse) + .background(TangemTheme.colors2.overlay.overlayPrimary) + } + val contentColor = when (state) { + TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled + else -> TangemTheme.colors2.text.neutral.primary + } + TangemButtonInternal( + onClick = onClick, + modifier = modifier + .clip(shape.toShape(size)) + .then(backgroundModifier), + text = text, + contentColor = contentColor, + enabled = enabled, + size = size, + state = state, + iconPosition = iconPosition, + iconRes = iconRes, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 480) +@Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PrimaryInverseTangemButton_Preview( + @PreviewParameter(PrimaryInverseTangemButtonPreviewProvider::class) params: TangemButtonState, +) { + TangemThemePreviewRedesign { + Row( + horizontalArrangement = Arrangement.spacedBy(21.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level2) + .padding(8.dp), + ) { + repeat(4) { yIndex -> + val shape = if (yIndex < 2) TangemButtonShape.Default else TangemButtonShape.Rounded + val text = if (yIndex % 2 == 1) null else stringReference("Button") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(2) { xIndex -> + val iconPosition = if (xIndex == 0) { + TangemButtonIconPosition.Start + } else { + TangemButtonIconPosition.End + } + PrimaryInverseTangemButton( + onClick = {}, + text = text, + size = TangemButtonSize.X15, + shape = shape, + iconPosition = iconPosition, + iconRes = R.drawable.ic_tangem_24, + state = params, + ) + } + } + } + } + } +} + +private class PrimaryInverseTangemButtonPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemButtonState.Default, + TangemButtonState.Pressed, + TangemButtonState.Loading, + TangemButtonState.Disabled, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryTangemButton.kt new file mode 100644 index 0000000000..74487c813b --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/PrimaryTangemButton.kt @@ -0,0 +1,122 @@ +package com.tangem.core.ui.ds.button + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * [Primary Tangem button](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=5854-4732&t=euYo1qCxPlQl3Fa6-4) + * + * @param onClick Lambda to be invoked when the button is clicked. + * @param modifier Modifier to be applied to the button. + * @param text TextReference for the button label. + * @param iconRes Drawable resource ID for the icon to be displayed in the button. + * @param iconPosition Position of the icon (Start or End). + * @param enabled Boolean indicating whether the button is enabled. + * @param size TangemButtonSize defining the size of the button. + * @param state TangemButtonState defining the current state of the button. + * @param shape TangemButtonShape defining the shape of the button. + * +[REDACTED_AUTHOR] + */ +@Composable +fun PrimaryTangemButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: TextReference? = null, + @DrawableRes iconRes: Int? = null, + iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, + enabled: Boolean = true, + size: TangemButtonSize = TangemButtonSize.X15, + state: TangemButtonState = TangemButtonState.Default, + shape: TangemButtonShape = TangemButtonShape.Default, +) { + val backgroundModifier = when (state) { + TangemButtonState.Loading, + TangemButtonState.Default, + -> Modifier.background(TangemTheme.colors2.button.backgroundPrimary) + TangemButtonState.Disabled -> Modifier.background(TangemTheme.colors2.button.backgroundDisabled) + TangemButtonState.Pressed -> Modifier + .background(TangemTheme.colors2.button.backgroundPrimary) + .background(TangemTheme.colors2.overlay.overlaySecondary) + } + val contentColor = when (state) { + TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled + else -> TangemTheme.colors2.text.neutral.primaryInverted + } + TangemButtonInternal( + onClick = onClick, + modifier = modifier + .clip(shape.toShape(size)) + .then(backgroundModifier), + text = text, + contentColor = contentColor, + enabled = enabled, + size = size, + state = state, + iconPosition = iconPosition, + iconRes = iconRes, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 480) +@Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PrimaryTangemButton_Preview( + @PreviewParameter(PrimaryTangemButtonPreviewProvider::class) params: TangemButtonState, +) { + TangemThemePreviewRedesign { + Row( + horizontalArrangement = Arrangement.spacedBy(21.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + repeat(4) { yIndex -> + val shape = if (yIndex < 2) TangemButtonShape.Default else TangemButtonShape.Rounded + val text = if (yIndex % 2 == 1) null else stringReference("Button") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(TangemButtonIconPosition.entries.size) { xIndex -> + PrimaryTangemButton( + onClick = {}, + text = text, + size = TangemButtonSize.X15, + shape = shape, + iconPosition = TangemButtonIconPosition.entries[xIndex], + iconRes = R.drawable.ic_tangem_24, + state = params, + ) + } + } + } + } + } +} + +private class PrimaryTangemButtonPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemButtonState.Default, + TangemButtonState.Pressed, + TangemButtonState.Loading, + TangemButtonState.Disabled, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt new file mode 100644 index 0000000000..8c43685df8 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/SecondaryTangemButton.kt @@ -0,0 +1,125 @@ +package com.tangem.core.ui.ds.button + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * [Secondary Tangem button](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=5854-4796) + * + * @param onClick Lambda to be invoked when the button is clicked. + * @param modifier Modifier to be applied to the button. + * @param text TextReference for the button label. + * @param iconRes Drawable resource ID for the icon to be displayed in the button. + * @param iconPosition Position of the icon (Start or End). + * @param enabled Boolean indicating whether the button is enabled. + * @param size TangemButtonSize defining the size of the button. + * @param state TangemButtonState defining the current state of the button. + * @param shape TangemButtonShape defining the shape of the button. + * +[REDACTED_AUTHOR] + */ +@Composable +fun SecondaryTangemButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: TextReference? = null, + @DrawableRes iconRes: Int? = null, + iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, + enabled: Boolean = true, + size: TangemButtonSize = TangemButtonSize.X15, + state: TangemButtonState = TangemButtonState.Default, + shape: TangemButtonShape = TangemButtonShape.Default, +) { + val backgroundModifier = when (state) { + TangemButtonState.Loading, + TangemButtonState.Default, + -> Modifier.background(TangemTheme.colors2.button.backgroundSecondary) + TangemButtonState.Disabled -> Modifier.background(TangemTheme.colors2.button.backgroundDisabled) + TangemButtonState.Pressed -> Modifier.background(TangemTheme.colors2.overlay.overlayPrimary) + } + val contentColor = when (state) { + TangemButtonState.Disabled -> TangemTheme.colors2.text.status.disabled + else -> TangemTheme.colors2.text.neutral.primary + } + TangemButtonInternal( + onClick = onClick, + modifier = modifier + .clip(shape.toShape(size)) + .then(backgroundModifier), + text = text, + contentColor = contentColor, + iconRes = iconRes, + enabled = enabled, + size = size, + state = state, + iconPosition = iconPosition, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 480) +@Preview(showBackground = true, widthDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun SecondaryTangemButton_Preview( + @PreviewParameter(SecondaryTangemButtonPreviewProvider::class) params: TangemButtonState, +) { + TangemThemePreviewRedesign { + Row( + horizontalArrangement = Arrangement.spacedBy(21.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + repeat(4) { yIndex -> + val shape = if (yIndex < 2) TangemButtonShape.Default else TangemButtonShape.Rounded + val text = if (yIndex % 2 == 1) null else stringReference("Button") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(2) { xIndex -> + val iconPosition = if (xIndex == 0) { + TangemButtonIconPosition.Start + } else { + TangemButtonIconPosition.End + } + SecondaryTangemButton( + onClick = {}, + text = text, + size = TangemButtonSize.X15, + shape = shape, + iconPosition = iconPosition, + iconRes = R.drawable.ic_tangem_24, + state = params, + ) + } + } + } + } + } +} + +private class SecondaryTangemButtonPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemButtonState.Default, + TangemButtonState.Pressed, + TangemButtonState.Loading, + TangemButtonState.Disabled, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt new file mode 100644 index 0000000000..abc8dcf003 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButtonInternal.kt @@ -0,0 +1,256 @@ +package com.tangem.core.ui.ds.button + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.conditionalCompose +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.BaseButtonTestTags + +/** + * A customizable button component that supports text, icons, and different states. + * + * @param onClick Lambda to be invoked when the button is clicked. + * @param modifier Modifier to be applied to the button. + * @param text TextReference for the button label. + * @param iconRes Drawable resource ID for the icon to be displayed in the button. + * @param iconPosition Position of the icon (Start or End). + * @param enabled Boolean indicating whether the button is enabled. + * @param contentColor Color of the button content (text and icon). + * @param size TangemButtonSize defining the size of the button. + * @param state TangemButtonState defining the current state of the button. + * +[REDACTED_AUTHOR] + */ +@Composable +internal fun TangemButtonInternal( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: TextReference? = null, + @DrawableRes iconRes: Int? = null, + iconPosition: TangemButtonIconPosition = TangemButtonIconPosition.Start, + enabled: Boolean = true, + contentColor: Color = TangemTheme.colors2.text.neutral.primary, + size: TangemButtonSize = TangemButtonSize.X15, + state: TangemButtonState = TangemButtonState.Default, +) { + Row( + modifier = modifier + .testTag(BaseButtonTestTags.BUTTON) + .height(size.toHeightDp()) + .conditionalCompose(text == null) { + width(size.toHeightDp()) + } + .clickableSingle(enabled = enabled, onClick = onClick, role = Role.Button) + .conditionalCompose(text != null) { + padding(horizontal = size.toPaddingDp()) + } + .animateContentSize(), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedVisibility( + visible = iconRes != null && iconPosition == TangemButtonIconPosition.Start, + modifier = Modifier.size(size = size.toContentSize()), + ) { + val wrappedIconRes = remember(this) { requireNotNull(iconRes) } + TangemButtonIcon(iconRes = wrappedIconRes, state = state, iconColor = contentColor, size = size) + } + + AnimatedVisibility(text != null && state != TangemButtonState.Loading) { + val wrappedText = remember(this) { requireNotNull(text) } + val textStyle = size.toTextStyle() + Text( + text = wrappedText.resolveReference(), + style = textStyle, + color = contentColor, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = 12.sp, + maxFontSize = textStyle.fontSize, + ), + modifier = Modifier.testTag(BaseButtonTestTags.TEXT), + ) + } + + AnimatedVisibility( + visible = iconRes != null && iconPosition == TangemButtonIconPosition.End, + modifier = Modifier.size(size = size.toContentSize()), + ) { + val wrappedIconRes = remember(this) { requireNotNull(iconRes) } + TangemButtonIcon(iconRes = wrappedIconRes, state = state, iconColor = contentColor, size = size) + } + } +} + +@Composable +private fun TangemButtonIcon( + @DrawableRes iconRes: Int, + iconColor: Color, + state: TangemButtonState, + size: TangemButtonSize, +) { + AnimatedContent(state) { targetState -> + when (targetState) { + TangemButtonState.Loading -> CircularProgressIndicator( + color = iconColor, + strokeWidth = 2.dp, + strokeCap = StrokeCap.Round, + modifier = Modifier.padding( + when (size) { + TangemButtonSize.X7, + TangemButtonSize.X8, + TangemButtonSize.X9, + TangemButtonSize.X10, + -> 0.5.dp + TangemButtonSize.X12, + TangemButtonSize.X15, + -> 4.5.dp + }, + ), + ) + else -> Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = iconColor, + ) + } + } +} + +/** + * Defines the shape of the Tangem button. + */ +enum class TangemButtonShape { + Default, + Rounded, + ; + + @ReadOnlyComposable + @Composable + internal fun toShape(size: TangemButtonSize) = RoundedCornerShape( + when (this) { + Default -> size.toShapeRadius() + Rounded -> 100.dp + }, + ) +} + +/** + * Defines the size of the Tangem button. + */ +enum class TangemButtonSize { + X7, + X8, + X9, + X10, + X12, + X15, + ; + + @ReadOnlyComposable + @Composable + internal fun toHeightDp() = when (this) { + X7 -> TangemTheme.dimens2.x7 + X8 -> TangemTheme.dimens2.x8 + X9 -> TangemTheme.dimens2.x9 + X10 -> TangemTheme.dimens2.x10 + X12 -> TangemTheme.dimens2.x12 + X15 -> TangemTheme.dimens2.x15 + } + + @ReadOnlyComposable + @Composable + internal fun toPaddingDp() = when (this) { + X7 -> TangemTheme.dimens2.x2 + X8, + X9, + X10, + -> TangemTheme.dimens2.x3 + X12, + X15, + -> TangemTheme.dimens2.x6 + } + + @ReadOnlyComposable + @Composable + internal fun toContentSize() = when (this) { + X7, + X8, + X9, + X10, + -> TangemTheme.dimens2.x5 + X12, + X15, + -> TangemTheme.dimens2.x7 + } + + @ReadOnlyComposable + @Composable + internal fun toShapeRadius() = when (this) { + X7, + X8, + X9, + X10, + -> TangemTheme.dimens2.x2 + X12 -> TangemTheme.dimens2.x3 + X15 -> TangemTheme.dimens2.x4 + } + + @ReadOnlyComposable + @Composable + internal fun toTextStyle(): TextStyle = when (this) { + X7 -> TangemTheme.typography2.bodyRegular14 + X8, + X9, + X10, + X12, + X15, + -> TangemTheme.typography2.bodySemibold16 + } +} + +/** + * Defines the state of the Tangem button. + */ +enum class TangemButtonState { + Default, + Disabled, + Pressed, + Loading, +} + +/** + * Defines the position of the icon in the Tangem button. + */ +enum class TangemButtonIconPosition { + Start, + End, +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/checkbox/TangemCheckbox.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/checkbox/TangemCheckbox.kt new file mode 100644 index 0000000000..9abdda6653 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/checkbox/TangemCheckbox.kt @@ -0,0 +1,127 @@ +package com.tangem.core.ui.ds.checkbox + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.R +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * Tangem checkbox component. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8437-82586&m=dev) + * + * @param isChecked Boolean value representing the current state of the checkbox. + * @param onCheckedChange Lambda function to be invoked when the checkbox state changes. + * @param modifier Modifier to be applied to the badge. + * @param isRounded Boolean value to determine if the checkbox has rounded corners or is circular. + * @param isEnabled Boolean value to determine if the checkbox is enabled or disabled. + */ +@Composable +fun TangemCheckbox( + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + isRounded: Boolean = true, + isEnabled: Boolean = true, +) { + val shape = if (isRounded) { + RoundedCornerShape(TangemTheme.dimens2.x1) + } else { + CircleShape + } + Box( + modifier = modifier + .size(TangemTheme.dimens2.x5) + .then( + if (isChecked) { + Modifier.background( + color = TangemTheme.colors2.border.status.accent, + shape = shape, + ) + } else { + Modifier.border( + color = TangemTheme.colors2.border.neutral.secondary, + shape = shape, + width = 1.dp, + ) + }, + ) + .clip(shape = shape) + .toggleable( + value = isChecked, + onValueChange = onCheckedChange, + enabled = isEnabled, + role = Role.Checkbox, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + ), + ) { + if (isChecked) { + Icon( + painter = rememberVectorPainter(ImageVector.vectorResource(R.drawable.ic_check_default_24)), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + modifier = Modifier + .align(Alignment.Center) + .size(TangemTheme.dimens2.x4), + ) + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemCheckbox_Preview() { + val previewData = mutableListOf( + TangemCheckboxPreviewData(isChecked = false, isRounded = true), + TangemCheckboxPreviewData(isChecked = false, isRounded = false), + TangemCheckboxPreviewData(isChecked = true, isRounded = true), + TangemCheckboxPreviewData(isChecked = true, isRounded = false), + ) + TangemThemePreviewRedesign { + Row( + modifier = Modifier.background(TangemTheme.colors2.surface.level1), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x6), + ) { + previewData.fastForEach { data -> + TangemCheckbox( + isChecked = data.isChecked, + isRounded = data.isRounded, + onCheckedChange = { }, + modifier = Modifier, + ) + } + } + } +} + +private data class TangemCheckboxPreviewData( + val isChecked: Boolean, + val isRounded: Boolean, +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt new file mode 100644 index 0000000000..b1a24c2067 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/TangemRowContainer.kt @@ -0,0 +1,189 @@ +package com.tangem.core.ui.ds.row + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Constraints +import com.tangem.core.ui.res.TangemTheme +import kotlin.math.max + +/** + * A custom layout composable that arranges its children in a row with specific layout IDs. + */ +internal enum class TangemRowLayoutId { + HEAD, START_TOP, END_TOP, START_BOTTOM, END_BOTTOM, TAIL, EXTRA_TOP +} + +/** + * A custom layout composable that arranges its children in a row with specific layout IDs. + * + * @param modifier Modifier to be applied to the layout. + * @param contentPadding Padding values to be applied around the content. + * @param content Composable content to be laid out within the TangemRow. + */ +@Suppress("LongMethod") +@Composable +internal fun TangemRowContainer( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(TangemTheme.dimens2.x3), + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + val localDirection = LocalLayoutDirection.current + val verticalPadding = with(density) { TangemTheme.dimens2.x1.roundToPx() } + val contentTopPadding = with(density) { contentPadding.calculateTopPadding().roundToPx() } + val contentBottomPadding = with(density) { contentPadding.calculateBottomPadding().roundToPx() } + val contentStartPadding = with(density) { contentPadding.calculateLeftPadding(localDirection).roundToPx() } + val contentEndPadding = with(density) { contentPadding.calculateRightPadding(localDirection).roundToPx() } + Layout( + content = content, + modifier = modifier, + ) { measurables, constraints -> + val layoutWidth = constraints.maxWidth - contentStartPadding - contentEndPadding + + val startTopMinWidth = (layoutWidth * TITLE_MIN_WIDTH_COEFFICIENT).toInt() + val startBottomMinWidth = (layoutWidth * PRICE_MIN_WIDTH_COEFFICIENT).toInt() + + // Head composable measurement + val headPlaceable = measurables.measure( + layoutId = TangemRowLayoutId.HEAD, + constraints = constraints, + ) + + // Tail composable measurement + val tailPlaceable = measurables.measure( + layoutId = TangemRowLayoutId.TAIL, + constraints = constraints, + ) + + val availableWidthForBody = layoutWidth - headPlaceable.widthOrZero() - tailPlaceable.widthOrZero() + + // End top composable width must take the whole free space but is not greater the Start top min size + val endTopPlaceable = measurables.measure( + layoutId = TangemRowLayoutId.END_TOP, + constraints = constraints.copy( + minWidth = 0, + maxWidth = availableWidthForBody - startTopMinWidth, + ), + ) + + // End bottom composable width must take the whole free space but is not greater the Start bottom min size + val endBottomPlaceable = measurables.measure( + layoutId = TangemRowLayoutId.END_BOTTOM, + constraints = constraints.copy( + minWidth = 0, + maxWidth = availableWidthForBody - startBottomMinWidth, + ), + ) + + /* Start top composable will take take the whole REMAINING width space width but no less than minimum */ + val startTopPlaceable = measurables.measure( + layoutId = TangemRowLayoutId.START_TOP, + constraints = constraints.copy( + minWidth = startTopMinWidth, + maxWidth = max( + a = startTopMinWidth, + b = availableWidthForBody - endTopPlaceable.widthOrZero(), + ), + ), + ) + + /* Start bottom composable will take take the whole REMAINING width space width but no less than minimum */ + val startBottomPlaceable = measurables.measure( + layoutId = TangemRowLayoutId.START_BOTTOM, + constraints = constraints.copy( + minWidth = startBottomMinWidth, + maxWidth = max( + a = startBottomMinWidth, + b = availableWidthForBody - endBottomPlaceable.widthOrZero(), + ), + ), + ) + + val extraTopPlaceable = measurables.measure( + layoutId = TangemRowLayoutId.EXTRA_TOP, + constraints = constraints, + ) + + val mainLayoutHeight = maxOf( + headPlaceable.heightOrZero(), + tailPlaceable.heightOrZero(), + startTopPlaceable.heightOrZero() + startBottomPlaceable.heightOrZero() + verticalPadding, + endTopPlaceable.heightOrZero() + endBottomPlaceable.heightOrZero() + verticalPadding, + ) + + val mainContentTopPadding = if (extraTopPlaceable != null) { + extraTopPlaceable.heightOrZero() + } else { + contentTopPadding + } + + val layoutHeight = mainLayoutHeight + mainContentTopPadding + contentBottomPadding + + layout(width = constraints.maxWidth, height = layoutHeight) { + extraTopPlaceable?.placeRelative(x = 0, y = 0) + + headPlaceable?.placeRelative( + x = contentStartPadding, + y = mainContentTopPadding + (mainLayoutHeight - headPlaceable.height).div(other = 2), + ) + + startTopPlaceable?.placeRelative( + x = contentStartPadding + headPlaceable.widthOrZero(), + y = mainContentTopPadding + if (startBottomPlaceable == null) { + (mainLayoutHeight - startTopPlaceable.height).div(2) + } else { + 0 + }, + ) + + startBottomPlaceable?.placeRelative( + x = contentStartPadding + headPlaceable.widthOrZero(), + y = mainContentTopPadding + if (startTopPlaceable == null) { + (mainLayoutHeight - startBottomPlaceable.height).div(2) + } else { + startTopPlaceable.heightOrZero() + verticalPadding + }, + ) + + endTopPlaceable?.placeRelative( + x = layoutWidth - endTopPlaceable.widthOrZero() - tailPlaceable.widthOrZero() + contentEndPadding, + y = mainContentTopPadding + if (endBottomPlaceable == null) { + (mainLayoutHeight - endTopPlaceable.height).div(2) + } else { + 0 + }, + ) + + endBottomPlaceable?.placeRelative( + x = layoutWidth - endBottomPlaceable.widthOrZero() - tailPlaceable.widthOrZero() + contentEndPadding, + y = mainContentTopPadding + if (endTopPlaceable == null) { + (mainLayoutHeight - endBottomPlaceable.height).div(2) + } else { + endTopPlaceable.heightOrZero() + verticalPadding + }, + ) + + tailPlaceable?.placeRelative( + x = layoutWidth - tailPlaceable.width + contentEndPadding, + y = mainContentTopPadding + (mainLayoutHeight - tailPlaceable.height).div(other = 2), + ) + } + } +} + +private const val TITLE_MIN_WIDTH_COEFFICIENT = 0.3 +private const val PRICE_MIN_WIDTH_COEFFICIENT = 0.32 + +private fun List.measure(layoutId: TangemRowLayoutId, constraints: Constraints): Placeable? { + return firstOrNull { it.layoutId == layoutId }?.measure(constraints) +} + +private fun Placeable?.widthOrZero(): Int = this?.width ?: 0 +private fun Placeable?.heightOrZero(): Int = this?.height ?: 0 \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRow.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRow.kt new file mode 100644 index 0000000000..8f9c251ea1 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRow.kt @@ -0,0 +1,167 @@ +package com.tangem.core.ui.ds.row.token + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.ds.row.token.internal.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.test.TokenElementsTestTags +import org.burnoutcrew.reorderable.ReorderableLazyListState + +/** + * Composable function that represents a Tangem token row in a list. + * + * [Token Row](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8207-17583&t=k8dyaykorsNocGVq-4) + * + * @param tokenRowUM The user model containing the data for the token row. + * @param isBalanceHidden A boolean indicating whether the balance should be hidden. + * @param reorderableTokenListState The state of the reorderable lazy list, if applicable. + * @param modifier The modifier to be applied to the row. + */ +@Composable +fun TangemTokenRow( + tokenRowUM: TangemTokenRowUM, + isBalanceHidden: Boolean, + reorderableTokenListState: ReorderableLazyListState?, + modifier: Modifier = Modifier, +) { + TangemRowContainer( + content = { + CurrencyIcon( + state = tokenRowUM.iconState, + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x2) + .testTag(TokenElementsTestTags.TOKEN_ICON), + ) + + TokenRowPromoBanner( + promoBannerUM = tokenRowUM.promoBannerUM, + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.EXTRA_TOP) + .testTag(TokenElementsTestTags.TOKEN_YIELD_PROMO_BANNER) + .padding(horizontal = TangemTheme.dimens2.x3) + .fillMaxWidth(), + ) + + TokenRowTitle( + titleUM = tokenRowUM.titleUM, + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.START_TOP) + .padding(end = TangemTheme.dimens2.x2) + .testTag(tag = TokenElementsTestTags.TOKEN_TITLE), + ) + + TokenRowSubtitle( + subtitleUM = tokenRowUM.subtitleUM, + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.START_BOTTOM) + .padding(end = TangemTheme.dimens2.x2) + .testTag(tag = TokenElementsTestTags.TOKEN_PRICE), + ) + + TokenRowEndTopContent( + endContentUM = tokenRowUM.topEndContentUM, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.END_TOP) + .testTag(tag = TokenElementsTestTags.TOKEN_FIAT_AMOUNT), + ) + + TokenRowEndBottomContent( + endContentUM = tokenRowUM.bottomEndContentUM, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.END_BOTTOM) + .testTag(tag = TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT), + ) + + TokenRowTail( + tailUM = tokenRowUM.tailUM, + reorderableTokenListState = reorderableTokenListState, + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.TAIL) + .testTag(TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK), + ) + }, + modifier = modifier.tokenClickable(tokenRowUM = tokenRowUM), + ) +} + +@OptIn(ExperimentalFoundationApi::class) +private fun Modifier.tokenClickable(tokenRowUM: TangemTokenRowUM): Modifier = composed { + val hapticFeedback = LocalHapticFeedback.current + + val onClick = tokenRowUM.onItemClick + val onLongClick = tokenRowUM.onItemLongClick + val onHapticLongClick = if (onLongClick != null) { + { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClick(tokenRowUM) + } + } else { + null + } + + when { + onClick == null && onLongClick == null -> this + onClick == null && onLongClick != null -> combinedClickable(onClick = {}, onLongClick = onHapticLongClick) + onClick != null && onLongClick == null -> combinedClickable(onClick = { onClick(tokenRowUM) }) + onClick != null && onLongClick != null -> { + combinedClickable(onClick = { onClick(tokenRowUM) }, onLongClick = onHapticLongClick) + } + else -> this + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemTokenRow_Preview( + @PreviewParameter(TangemTokenRowPreviewProvider::class) tokenRowUM: TangemTokenRowUM, +) { + TangemThemePreviewRedesign { + TangemTokenRow( + tokenRowUM = tokenRowUM, + isBalanceHidden = false, + reorderableTokenListState = null, + modifier = Modifier.background(TangemTheme.colors2.surface.level1), + ) + } +} + +private class TangemTokenRowPreviewProvider : CollectionPreviewParameterProvider( + collection = listOf( + TangemTokenRowPreviewData.defaultState, + TangemTokenRowPreviewData.defaultEllipsisState, + TangemTokenRowPreviewData.tokenState, + TangemTokenRowPreviewData.customTokenState, + TangemTokenRowPreviewData.draggableState, + TangemTokenRowPreviewData.draggableStateV2, + TangemTokenRowPreviewData.loadingState, + TangemTokenRowPreviewData.unreachableState, + TangemTokenRowPreviewData.accountState, + TangemTokenRowPreviewData.accountLetterState, + TangemTokenRowPreviewData.accountEllipsisState, + TangemTokenRowPreviewData.promoBannerState, + ), +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRowUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRowUM.kt new file mode 100644 index 0000000000..8ef54a567b --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/TangemTokenRowUM.kt @@ -0,0 +1,172 @@ +package com.tangem.core.ui.ds.row.token + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.ds.badge.TangemBadgeUM +import com.tangem.core.ui.extensions.ColorReference2 +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.TangemTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +sealed class TangemTokenRowUM { + + /** Unique id */ + abstract val id: String + + /** Token icon state */ + abstract val iconState: CurrencyIconState + + /** Token title UM (in one row with [topEndContentUM]) */ + abstract val titleUM: TitleUM + + /** Token subtitle UM (in one row with [bottomEndContentUM]) */ + abstract val subtitleUM: SubtitleUM + + /** Top end content UM (e.i. fiat amount in one row with [titleUM]) */ + abstract val topEndContentUM: EndContentUM + + /** Bottom end content UM (e.i. crypto amount in one row with [subtitleUM]) */ + abstract val bottomEndContentUM: EndContentUM + + /** Token row tail UM */ + abstract val tailUM: TailUM + + /** Promo banner UM */ + abstract val promoBannerUM: PromoBannerUM + + /** Callback which will be called when an item is clicked */ + abstract val onItemClick: ((TangemTokenRowUM) -> Unit)? + + /** Callback which will be called when an item is long clicked */ + abstract val onItemLongClick: ((TangemTokenRowUM) -> Unit)? + + /** + * Content state of [TangemTokenRowUM] + */ + data class Content( + override val id: String, + override val iconState: CurrencyIconState, + override val titleUM: TitleUM, + override val subtitleUM: SubtitleUM, + override val topEndContentUM: EndContentUM, + override val bottomEndContentUM: EndContentUM, + override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty, + override val tailUM: TailUM = TailUM.Empty, + override val onItemClick: ((TangemTokenRowUM) -> Unit)?, + override val onItemLongClick: ((TangemTokenRowUM) -> Unit)?, + ) : TangemTokenRowUM() + + /** + * Loading state of [TangemTokenRowUM] + */ + data class Loading( + override val id: String, + override val iconState: CurrencyIconState, + override val titleUM: TitleUM = TitleUM.Loading, + override val subtitleUM: SubtitleUM = SubtitleUM.Loading, + ) : TangemTokenRowUM() { + override val topEndContentUM: EndContentUM = EndContentUM.Loading + override val bottomEndContentUM: EndContentUM = EndContentUM.Loading + override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty + override val tailUM: TailUM = TailUM.Empty + override val onItemClick: ((TangemTokenRowUM) -> Unit)? = null + override val onItemLongClick: ((TangemTokenRowUM) -> Unit)? = null + } + + /** + * Actionable state of [TangemTokenRowUM] + */ + data class Actionable( + override val id: String, + override val iconState: CurrencyIconState, + override val titleUM: TitleUM, + override val subtitleUM: SubtitleUM, + override val tailUM: TailUM, + override val onItemClick: ((TangemTokenRowUM) -> Unit)?, + override val onItemLongClick: ((TangemTokenRowUM) -> Unit)?, + override val topEndContentUM: EndContentUM = EndContentUM.Empty, + override val bottomEndContentUM: EndContentUM = EndContentUM.Empty, + ) : TangemTokenRowUM() { + override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty + } + + @Immutable + sealed class TitleUM { + + data class Content( + val text: TextReference, + val hasPending: Boolean = false, + val isAvailable: Boolean = true, + val badge: TangemBadgeUM? = null, + val onBadgeClick: (() -> Unit)? = null, + ) : TitleUM() + + data object Loading : TitleUM() + + data object Empty : TitleUM() + } + + @Immutable + sealed class SubtitleUM { + + data class Content( + val text: TextReference, + val isAvailable: Boolean = true, + val isFlickering: Boolean = false, + val icons: ImmutableList = persistentListOf(), + val priceChangeUM: PriceChangeState = PriceChangeState.Unknown, + val badge: TangemBadgeUM? = null, + ) : SubtitleUM() + + data object Loading : SubtitleUM() + + data object Empty : SubtitleUM() + } + + @Immutable + sealed class EndContentUM { + + data class Content( + val text: TextReference, + val isAvailable: Boolean = true, + val isFlickering: Boolean = false, + val icons: ImmutableList = persistentListOf(), + val priceChangeUM: PriceChangeState = PriceChangeState.Unknown, + ) : EndContentUM() + + data object Loading : EndContentUM() + + data object Empty : EndContentUM() + } + + @Immutable + sealed class PromoBannerUM { + data class Content( + val title: TextReference, + val onPromoBannerClick: () -> Unit, + val onCloseClick: () -> Unit, + val onPromoShown: () -> Unit = {}, + ) : PromoBannerUM() + + data object Empty : PromoBannerUM() + } + + @Immutable + sealed class TailUM { + data class Text( + val text: TextReference, + ) : TailUM() + + data object Draggable : TailUM() + + data object Empty : TailUM() + } + + data class IconUM( + val iconRes: Int, + val tintReference: ColorReference2 = ColorReference2 { TangemTheme.colors2.graphic.neutral.primary }, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt new file mode 100644 index 0000000000..aee2125611 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TangemTokenRowPreviewData.kt @@ -0,0 +1,266 @@ +package com.tangem.core.ui.ds.row.token.internal + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import com.tangem.core.ui.R +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.styledResourceReference +import com.tangem.core.ui.extensions.styledStringReference +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.utils.StringsSigns +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +internal object TangemTokenRowPreviewData { + + private val priceChangeState: PriceChangeState.Content + get() = PriceChangeState.Content( + type = PriceChangeType.UP, + valueInPercent = "2.45%", + ) + + val promoBannerUM: TangemTokenRowUM.PromoBannerUM.Content + get() = TangemTokenRowUM.PromoBannerUM.Content( + title = stringReference("Earn yield by supplying your crypto assets"), + onPromoBannerClick = {}, + onPromoShown = {}, + onCloseClick = {}, + ) + + val titleUM: TangemTokenRowUM.TitleUM.Content + get() = TangemTokenRowUM.TitleUM.Content( + text = stringReference(value = "Polygon"), + hasPending = true, + ) + + val subtitleUM: TangemTokenRowUM.SubtitleUM.Content + get() = TangemTokenRowUM.SubtitleUM.Content( + text = stringReference(value = "$ 0.6631"), + priceChangeUM = priceChangeState, + ) + + val topEndContentUM: TangemTokenRowUM.EndContentUM.Content + get() = TangemTokenRowUM.EndContentUM.Content( + text = combinedReference( + stringReference("$ 500"), + styledStringReference(".17", { + SpanStyle( + color = TangemTheme.colors2.text.neutral.secondary, + fontWeight = TangemTheme.typography2.bodyRegular16.fontWeight, + ) + }), + ), + ) + + val bottomEndContentUM: TangemTokenRowUM.EndContentUM.Content + get() = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("Title"), + ) + + private val accountResIcon: CurrencyIconState.CryptoPortfolio.Icon + get() = CurrencyIconState.CryptoPortfolio.Icon( + resId = R.drawable.ic_rounded_star_24, + color = Color.Blue, + isGrayscale = false, + ) + private val accountLetterIcon: CurrencyIconState.CryptoPortfolio.Letter + get() = CurrencyIconState.CryptoPortfolio.Letter( + char = stringReference("A"), + color = Color.Blue, + isGrayscale = false, + ) + + private val coinIconState + get() = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_polygon_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ) + + private val tokenIconState + get() = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = R.drawable.img_polygon_22, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ) + + private val customTokenIconState + get() = CurrencyIconState.CustomTokenIcon( + tint = TangemColorPalette.Black, + background = TangemColorPalette.Meadow, + topBadgeIconResId = R.drawable.img_polygon_22, + isGrayscale = false, + ) + + val defaultState: TangemTokenRowUM.Content + get() = TangemTokenRowUM.Content( + id = UUID.randomUUID().toString(), + iconState = coinIconState, + titleUM = titleUM, + subtitleUM = subtitleUM, + topEndContentUM = topEndContentUM, + bottomEndContentUM = bottomEndContentUM, + promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty, + tailUM = TangemTokenRowUM.TailUM.Empty, + onItemClick = {}, + onItemLongClick = {}, + ) + + val defaultEllipsisState: TangemTokenRowUM.Content + get() = defaultState.copy( + titleUM = titleUM.copy(text = stringReference("Polygon Polygon Polygon Polygon")), + subtitleUM = subtitleUM, + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = combinedReference( + stringReference("$ 500"), + styledStringReference(".11232131237", { + SpanStyle( + color = TangemTheme.colors2.text.neutral.secondary, + fontWeight = TangemTheme.typography2.bodyRegular16.fontWeight, + ) + }), + ), + icons = persistentListOf( + TangemTokenRowUM.IconUM(R.drawable.ic_staking_mini_10), + TangemTokenRowUM.IconUM(R.drawable.ic_attention_12), + TangemTokenRowUM.IconUM(R.drawable.ic_error_sync_24), + ), + ), + bottomEndContentUM = bottomEndContentUM, + promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty, + tailUM = TangemTokenRowUM.TailUM.Empty, + onItemClick = {}, + onItemLongClick = {}, + ) + + val tokenState: TangemTokenRowUM.Content + get() = TangemTokenRowUM.Content( + id = UUID.randomUUID().toString(), + iconState = tokenIconState, + titleUM = titleUM, + subtitleUM = subtitleUM, + topEndContentUM = topEndContentUM, + bottomEndContentUM = bottomEndContentUM, + promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty, + tailUM = TangemTokenRowUM.TailUM.Empty, + onItemClick = {}, + onItemLongClick = {}, + ) + + val customTokenState: TangemTokenRowUM.Content + get() = TangemTokenRowUM.Content( + id = UUID.randomUUID().toString(), + iconState = customTokenIconState, + titleUM = titleUM, + subtitleUM = subtitleUM, + topEndContentUM = topEndContentUM, + bottomEndContentUM = bottomEndContentUM, + promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty, + tailUM = TangemTokenRowUM.TailUM.Empty, + onItemClick = {}, + onItemLongClick = {}, + ) + + val draggableState: TangemTokenRowUM.Actionable + get() = TangemTokenRowUM.Actionable( + id = UUID.randomUUID().toString(), + iconState = coinIconState, + titleUM = titleUM, + subtitleUM = subtitleUM, + tailUM = TangemTokenRowUM.TailUM.Draggable, + onItemClick = {}, + onItemLongClick = {}, + ) + + val draggableStateV2: TangemTokenRowUM.Actionable + get() = TangemTokenRowUM.Actionable( + id = UUID.randomUUID().toString(), + iconState = coinIconState, + titleUM = titleUM, + subtitleUM = subtitleUM, + topEndContentUM = topEndContentUM, + bottomEndContentUM = bottomEndContentUM, + tailUM = TangemTokenRowUM.TailUM.Draggable, + onItemClick = {}, + onItemLongClick = {}, + ) + + val loadingState: TangemTokenRowUM.Loading + get() = TangemTokenRowUM.Loading( + id = UUID.randomUUID().toString(), + iconState = coinIconState, + titleUM = TangemTokenRowUM.TitleUM.Loading, + subtitleUM = TangemTokenRowUM.SubtitleUM.Loading, + ) + + val unreachableState: TangemTokenRowUM.Content + get() = defaultState.copy( + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference(StringsSigns.DASH_SIGN), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = styledResourceReference( + id = R.string.common_unreachable, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.status.attention) }, + ), + ), + ) + + val accountState: TangemTokenRowUM.Content + get() = TangemTokenRowUM.Content( + id = UUID.randomUUID().toString(), + iconState = accountResIcon, + titleUM = TangemTokenRowUM.TitleUM.Content( + text = stringReference(value = "Portfolio"), + ), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = stringReference("24 tokens"), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("22,129.65 $"), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("+ $ 1,245.32"), + priceChangeUM = priceChangeState, + ), + onItemClick = {}, + onItemLongClick = {}, + ) + + val accountLetterState: TangemTokenRowUM.Content + get() = accountState.copy( + iconState = accountLetterIcon, + ) + + val accountEllipsisState: TangemTokenRowUM.Content + get() = accountState.copy( + titleUM = TangemTokenRowUM.TitleUM.Content( + text = stringReference(value = "Portfolio Portfolio Portfolio Portfolio"), + ), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = stringReference("24 tokens 24 tokens 24 tokens 24 tokens"), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("22,129.6129387147653025 $"), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("+ $ 1,245.31093284302752"), + priceChangeUM = priceChangeState, + ), + ) + + val promoBannerState: TangemTokenRowUM.Content + get() = defaultState.copy( + promoBannerUM = promoBannerUM, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndBottomContent.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndBottomContent.kt new file mode 100644 index 0000000000..a4bdecca54 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndBottomContent.kt @@ -0,0 +1,100 @@ +package com.tangem.core.ui.ds.row.token.internal + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun TokenRowEndBottomContent( + endContentUM: TangemTokenRowUM.EndContentUM, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + when (endContentUM) { + is TangemTokenRowUM.EndContentUM.Content -> Content( + modifier = modifier, + endContentUM = endContentUM, + isBalanceHidden = isBalanceHidden, + ) + TangemTokenRowUM.EndContentUM.Empty -> Unit + TangemTokenRowUM.EndContentUM.Loading -> TextShimmer( + style = TangemTheme.typography2.captionSemibold12, + modifier = modifier.width(TangemTheme.dimens2.x10), + radius = TangemTheme.dimens2.x25, + ) + } +} + +@Composable +private fun Content( + endContentUM: TangemTokenRowUM.EndContentUM.Content, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = endContentUM.text.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography2.captionSemibold12.applyBladeBrush( + isEnabled = endContentUM.isFlickering, + textColor = if (endContentUM.isAvailable) { + TangemTheme.colors2.text.neutral.secondary + } else { + TangemTheme.colors2.text.status.disabled + }, + ), + ) + + when (endContentUM.priceChangeUM) { + is PriceChangeState.Content -> TokenRowPriceChangeContent( + priceChangeState = endContentUM.priceChangeUM, + isFlickering = endContentUM.isFlickering, + isAvailable = endContentUM.isAvailable, + ) + PriceChangeState.Unknown -> Unit + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenRowEndBottomContent_Preview( + @PreviewParameter(TokenRowEndBottomContentPreviewProvider::class) params: TangemTokenRowUM.EndContentUM, +) { + TangemThemePreviewRedesign { + TokenRowEndBottomContent( + endContentUM = params, + isBalanceHidden = false, + ) + } +} + +private class TokenRowEndBottomContentPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemTokenRowPreviewData.bottomEndContentUM, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndTopContent.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndTopContent.kt new file mode 100644 index 0000000000..131497424f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowEndTopContent.kt @@ -0,0 +1,115 @@ +package com.tangem.core.ui.ds.row.token.internal + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun TokenRowEndTopContent( + endContentUM: TangemTokenRowUM.EndContentUM, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + when (endContentUM) { + is TangemTokenRowUM.EndContentUM.Content -> Content( + modifier = modifier, + endContentUM = endContentUM, + isBalanceHidden = isBalanceHidden, + ) + TangemTokenRowUM.EndContentUM.Empty -> Unit + TangemTokenRowUM.EndContentUM.Loading -> TextShimmer( + style = TangemTheme.typography2.bodySemibold16, + modifier = modifier.width(TangemTheme.dimens2.x18), + radius = TangemTheme.dimens2.x25, + ) + } +} + +@Composable +private fun Content( + endContentUM: TangemTokenRowUM.EndContentUM.Content, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedVisibility( + visible = endContentUM.icons.isNotEmpty(), + ) { + Row( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x1), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + endContentUM.icons.fastForEach { icon -> + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x3), + painter = rememberVectorPainter(image = ImageVector.vectorResource(icon.iconRes)), + tint = icon.tintReference(), + contentDescription = null, + ) + } + } + } + + Text( + modifier = Modifier, + text = endContentUM.text.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography2.bodySemibold16.applyBladeBrush( + isEnabled = endContentUM.isFlickering, + textColor = if (endContentUM.isAvailable) { + TangemTheme.colors2.text.neutral.primary + } else { + TangemTheme.colors2.text.status.disabled + }, + ), + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenRowEndTopContent_Preview( + @PreviewParameter(TokenRowEndContentPreviewProvider::class) params: TangemTokenRowUM.EndContentUM, +) { + TangemThemePreviewRedesign { + TokenRowEndTopContent( + endContentUM = params, + isBalanceHidden = false, + ) + } +} + +private class TokenRowEndContentPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemTokenRowPreviewData.topEndContentUM, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt new file mode 100644 index 0000000000..ef635a0a8e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt @@ -0,0 +1,71 @@ +package com.tangem.core.ui.ds.row.token.internal + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import com.tangem.core.ui.R +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun RowScope.TokenRowPriceChangeContent( + priceChangeState: PriceChangeState.Content, + isFlickering: Boolean, + isAvailable: Boolean = true, +) { + AnimatedContent( + targetState = priceChangeState.type, + label = "Update the price change's arrow", + modifier = Modifier.padding(start = TangemTheme.dimens2.x1), + ) { animatedType -> + Icon( + painter = rememberVectorPainter( + ImageVector.vectorResource( + when (animatedType) { + PriceChangeType.UP -> R.drawable.ic_up_dynamic_24 + PriceChangeType.DOWN -> R.drawable.ic_down_dynamic_24 + PriceChangeType.NEUTRAL -> R.drawable.ic_static_dynamic_24 + }, + ), + ), + tint = when (animatedType) { + PriceChangeType.UP -> TangemTheme.colors2.graphic.status.accent + PriceChangeType.DOWN -> TangemTheme.colors2.graphic.status.warning + PriceChangeType.NEUTRAL -> TangemTheme.colors2.graphic.neutral.secondary + }, + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens2.x3), + ) + } + + AnimatedContent( + targetState = priceChangeState.valueInPercent, + label = "Update the price text", + modifier = Modifier.padding(start = TangemTheme.dimens2.x0_5), + ) { animatedText -> + Text( + text = animatedText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography2.captionSemibold12.applyBladeBrush( + isEnabled = isFlickering, + textColor = if (isAvailable) { + TangemTheme.colors2.text.neutral.secondary + } else { + TangemTheme.colors2.text.status.disabled + }, + ), + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPromoBanner.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPromoBanner.kt new file mode 100644 index 0000000000..93845dea18 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPromoBanner.kt @@ -0,0 +1,107 @@ +package com.tangem.core.ui.ds.row.token.internal + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun TokenRowPromoBanner(promoBannerUM: TangemTokenRowUM.PromoBannerUM, modifier: Modifier = Modifier) { + when (promoBannerUM) { + is TangemTokenRowUM.PromoBannerUM.Content -> TokenRowPromoBanner( + promoBannerUM = promoBannerUM, + modifier = modifier, + ) + TangemTokenRowUM.PromoBannerUM.Empty -> Unit + } +} + +@Composable +internal fun TokenRowPromoBanner(promoBannerUM: TangemTokenRowUM.PromoBannerUM.Content, modifier: Modifier = Modifier) { + LaunchedEffect(promoBannerUM) { + promoBannerUM.onPromoShown() + } + val bgColor = TangemTheme.colors.control.default + Column(modifier = modifier) { + Row( + modifier = Modifier + .background(color = bgColor, shape = RoundedCornerShape(TangemTheme.dimens2.x4)) + .clickable(onClick = promoBannerUM.onPromoBannerClick) + .padding(horizontal = TangemTheme.dimens2.x3, vertical = TangemTheme.dimens2.x2) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_analytics_up_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + modifier = Modifier + .padding(end = TangemTheme.dimens2.x2) + .size(TangemTheme.dimens2.x4), + ) + Text( + text = promoBannerUM.title.resolveReference(), + style = TangemTheme.typography2.captionSemibold12, + color = TangemTheme.colors2.text.neutral.secondary, + modifier = Modifier + .weight(1f) + .padding(end = TangemTheme.dimens2.x2), + ) + Icon( + painter = painterResource(id = R.drawable.ic_close_24), + contentDescription = null, + tint = TangemTheme.colors2.text.neutral.secondary, + modifier = Modifier + .size(TangemTheme.dimens2.x4) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false), + onClick = { promoBannerUM.onCloseClick() }, + ), + ) + } + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_rectangle_bottom), + contentDescription = null, + tint = bgColor, + modifier = Modifier + .size(width = TangemTheme.dimens2.x3, height = TangemTheme.dimens2.x2), + ) + } + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_TokenRowPromoBanner() { + TangemThemePreviewRedesign { + TokenRowPromoBanner( + promoBannerUM = TangemTokenRowPreviewData.promoBannerUM, + modifier = Modifier.fillMaxWidth(), + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowSubtitle.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowSubtitle.kt new file mode 100644 index 0000000000..c0d32baca0 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowSubtitle.kt @@ -0,0 +1,100 @@ +package com.tangem.core.ui.ds.row.token.internal + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.ds.badge.TangemBadge +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun TokenRowSubtitle(subtitleUM: TangemTokenRowUM.SubtitleUM, modifier: Modifier = Modifier) { + when (subtitleUM) { + is TangemTokenRowUM.SubtitleUM.Content -> SubtitleContent( + subtitleUM = subtitleUM, + modifier = modifier, + ) + TangemTokenRowUM.SubtitleUM.Loading -> TextShimmer( + style = TangemTheme.typography2.captionSemibold12, + modifier = modifier.width(TangemTheme.dimens2.x8), + radius = TangemTheme.dimens2.x25, + ) + TangemTokenRowUM.SubtitleUM.Empty -> Unit + } +} + +@Composable +private fun SubtitleContent(subtitleUM: TangemTokenRowUM.SubtitleUM.Content, modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + AnimatedVisibility( + visible = subtitleUM.badge != null, + ) { + val wrappedBadge = remember(this) { requireNotNull(subtitleUM.badge) } + TangemBadge(wrappedBadge) + } + + Text( + text = subtitleUM.text.resolveAnnotatedReference(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography2.captionSemibold12.applyBladeBrush( + isEnabled = subtitleUM.isFlickering, + textColor = if (subtitleUM.isAvailable) { + TangemTheme.colors2.text.neutral.secondary + } else { + TangemTheme.colors2.text.status.disabled + }, + ), + ) + + when (subtitleUM.priceChangeUM) { + is PriceChangeState.Content -> TokenRowPriceChangeContent( + priceChangeState = subtitleUM.priceChangeUM, + isFlickering = subtitleUM.isFlickering, + isAvailable = subtitleUM.isAvailable, + ) + PriceChangeState.Unknown -> Unit + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenRowSubtitle_Preview( + @PreviewParameter(TokenRowSubtitlePreviewProvider::class) params: TangemTokenRowUM.SubtitleUM, +) { + TangemThemePreviewRedesign { + TokenRowSubtitle( + subtitleUM = params, + ) + } +} + +private class TokenRowSubtitlePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemTokenRowPreviewData.subtitleUM, + TangemTokenRowUM.SubtitleUM.Loading, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowTail.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowTail.kt new file mode 100644 index 0000000000..f3416f8a29 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowTail.kt @@ -0,0 +1,81 @@ +package com.tangem.core.ui.ds.row.token.internal + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.OrganizeTokensScreenTestTags +import org.burnoutcrew.reorderable.ReorderableLazyListState +import org.burnoutcrew.reorderable.detectReorder + +@Composable +internal fun TokenRowTail( + tailUM: TangemTokenRowUM.TailUM, + reorderableTokenListState: ReorderableLazyListState?, + modifier: Modifier = Modifier, +) { + AnimatedContent( + targetState = tailUM, + label = "Update non content fiat block", + modifier = modifier, + contentKey = { it::class.java }, + ) { animatedState -> + val innerModifier = Modifier.padding(start = TangemTheme.dimens2.x2) + when (animatedState) { + TangemTokenRowUM.TailUM.Empty -> Unit + is TangemTokenRowUM.TailUM.Draggable -> DraggableImage( + reorderableTokenListState = reorderableTokenListState, + modifier = innerModifier, + ) + is TangemTokenRowUM.TailUM.Text -> ContentText(text = animatedState.text, modifier = innerModifier) + } + } +} + +@Composable +private fun DraggableImage(reorderableTokenListState: ReorderableLazyListState?, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(size = TangemTheme.dimens2.x6) + .then( + other = if (reorderableTokenListState != null) { + Modifier.detectReorder(reorderableTokenListState) + } else { + Modifier + }, + ) + .testTag(OrganizeTokensScreenTestTags.DRAGGABLE_IMAGE), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_drag_24), + tint = TangemTheme.colors2.graphic.neutral.tertiaryConstant, + contentDescription = null, + ) + } +} + +@Composable +private fun ContentText(text: TextReference, modifier: Modifier = Modifier) { + Text( + text = text.resolveAnnotatedReference(), + color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography2.captionRegular12, + modifier = modifier, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowTitle.kt new file mode 100644 index 0000000000..ad8368ab4d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowTitle.kt @@ -0,0 +1,101 @@ +package com.tangem.core.ui.ds.row.token.internal + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.ds.badge.TangemBadge +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.conditional +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun TokenRowTitle(titleUM: TangemTokenRowUM.TitleUM, modifier: Modifier = Modifier) { + when (titleUM) { + is TangemTokenRowUM.TitleUM.Content -> ContentTitle(titleUM = titleUM, modifier = modifier) + TangemTokenRowUM.TitleUM.Loading -> TextShimmer( + style = TangemTheme.typography2.bodySemibold16, + modifier = modifier.width(TangemTheme.dimens2.x18), + radius = TangemTheme.dimens2.x25, + ) + TangemTokenRowUM.TitleUM.Empty -> Unit + } +} + +@Composable +private fun ContentTitle(titleUM: TangemTokenRowUM.TitleUM.Content, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens2.x4), + verticalAlignment = Alignment.CenterVertically, + ) { + /* + * If currency name has a long width, then it will completely displace the image. + * So we need to use [weight] to avoid displacement. + */ + Text( + text = titleUM.text.resolveReference(), + modifier = Modifier.weight(weight = 1f, fill = false), + color = if (titleUM.isAvailable) { + TangemTheme.colors2.text.neutral.primary + } else { + TangemTheme.colors2.text.status.disabled + }, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = TangemTheme.typography2.bodySemibold16, + ) + + AnimatedVisibility( + visible = titleUM.hasPending, + modifier = Modifier.align(alignment = Alignment.CenterVertically), + ) { + Image( + painter = painterResource(id = R.drawable.img_loader_15), + contentDescription = null, + ) + } + + AnimatedVisibility( + visible = titleUM.badge != null, + modifier = Modifier.conditional( + condition = titleUM.onBadgeClick != null, + modifier = { clickable(onClick = requireNotNull(titleUM.onBadgeClick)) }, + ), + ) { + val wrappedBadge = remember(this) { requireNotNull(titleUM.badge) } + TangemBadge(wrappedBadge) + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenRowTitle_Preview() { + TangemThemePreviewRedesign { + TokenRowTitle( + titleUM = TangemTokenRowPreviewData.titleUM, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt new file mode 100644 index 0000000000..fc0a59340b --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt @@ -0,0 +1,271 @@ +package com.tangem.core.ui.ds.tabs + +import android.content.res.Configuration +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.coerceAtLeast +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.conditional +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.utils.extensions.indexOfFirstOrNull +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +/** + * Tangem segmented picker component. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8452-16487&m=dev) + * + * @param tangemSegmentedPickerUM Model representing the segmented picker. + * @param modifier Modifier to be applied to the segmented picker. + * @param onClick Lambda function to be invoked when a segment is clicked. + */ +@Composable +fun TangemSegmentedPicker( + tangemSegmentedPickerUM: TangemSegmentedPickerUM, + modifier: Modifier = Modifier, + onClick: (TangemSegmentUM) -> Unit, +) { + TangemSegmentedPicker( + items = tangemSegmentedPickerUM.items, + modifier = modifier, + initialSelectedItem = tangemSegmentedPickerUM.initialSelectedItem, + hasSeparator = tangemSegmentedPickerUM.hasSeparator, + isFixed = tangemSegmentedPickerUM.isFixed, + isAltSurface = tangemSegmentedPickerUM.isAltSurface, + onClick = onClick, + ) +} + +/** + * Tangem segmented picker component. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8452-16487&m=dev) + * + * @param items List of TangemSegmentUM representing the segments in the picker. + * @param modifier Modifier to be applied to the segmented picker. + * @param initialSelectedItem Optional TangemSegmentUM representing the initially selected segment. + * @param hasSeparator Boolean indicating whether there is a separator between segments. + * @param isFixed Boolean indicating whether the picker has a fixed width. + * @param isAltSurface Boolean indicating whether to use an alternative surface style. + * @param onClick Lambda function to be invoked when a segment is clicked. + */ +@Composable +fun TangemSegmentedPicker( + items: ImmutableList, + modifier: Modifier = Modifier, + initialSelectedItem: TangemSegmentUM? = null, + hasSeparator: Boolean = false, + isFixed: Boolean = false, + isAltSurface: Boolean = false, + onClick: (TangemSegmentUM) -> Unit, +) { + if (items.isEmpty() || items.size == 1) return + + val density = LocalDensity.current + + val itemsWidths = remember { mutableStateListOf(*Array(items.size) { 0.dp }) } + val selectedIndex = remember { mutableStateOf(items.indexOfFirstOrNull { it == initialSelectedItem } ?: 0) } + val segmentHeight = remember { mutableStateOf(0.dp) } + + val shape = RoundedCornerShape(TangemTheme.dimens2.x25) + val spacing = if (hasSeparator) { + TangemTheme.dimens2.x4 + } else { + TangemTheme.dimens2.x1 + } + + Box( + modifier = modifier + .clip(shape) + .background( + color = if (isAltSurface) { + TangemTheme.colors2.tabs.backgroundQuaternary + } else { + TangemTheme.colors2.tabs.backgroundSecondary + }, + ) + .padding(TangemTheme.dimens2.x0_5), + ) { + SegmentSelection( + itemsWidths = itemsWidths, + selectedIndex = selectedIndex.value, + segmentHeight = segmentHeight.value, + spacing = spacing, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing), + ) { + items.fastForEachIndexed { index, item -> + Segment( + item = item, + index = index, + isFixed = isFixed, + selectedIndex = selectedIndex, + onClick = { onClick(item) }, + modifier = Modifier + .onGloballyPositioned { + with(density) { + itemsWidths[index] = it.size.width.toDp() + segmentHeight.value = it.size.height.toDp().coerceAtLeast(segmentHeight.value) + } + }, + ) + } + } + } +} + +@Composable +private fun SegmentSelection(itemsWidths: SnapshotStateList, selectedIndex: Int, segmentHeight: Dp, spacing: Dp) { + val indicatorOffset by animateDpAsState( + targetValue = itemsWidths.take(selectedIndex).fold(0.dp, Dp::plus) + spacing * selectedIndex, + animationSpec = tween(durationMillis = 300), + label = "indicatorOffset", + ) + + val indicatorWidth by animateDpAsState( + targetValue = itemsWidths[selectedIndex], + animationSpec = tween(durationMillis = 300), + label = "indicatorWidth", + ) + + Box( + modifier = Modifier + .offset { + IntOffset(x = indicatorOffset.roundToPx(), y = 0) + } + .size(width = indicatorWidth, height = segmentHeight) + .shadow( + elevation = TangemTheme.dimens2.x1, + shape = RoundedCornerShape(TangemTheme.dimens2.x25), + ) + .background( + color = TangemTheme.colors2.tabs.backgroundTertiary, + ), + ) +} + +@Composable +private fun RowScope.Segment( + item: TangemSegmentUM, + index: Int, + isFixed: Boolean, + selectedIndex: MutableState, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .conditional(isFixed) { + weight(1f) + } + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + selectedIndex.value = index + onClick() + }, + ) { + Text( + text = item.title.resolveReference(), + style = TangemTheme.typography2.bodySemibold15, + color = if (selectedIndex.value == index) { + TangemTheme.colors2.tabs.textTertiary + } else { + TangemTheme.colors2.tabs.textSecondary + }, + maxLines = 1, + modifier = Modifier + .align(Alignment.Center) + .padding( + horizontal = TangemTheme.dimens2.x3, + vertical = TangemTheme.dimens2.x2, + ), + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemSegmentedPicker_Preview(@PreviewParameter(PreviewProvider::class) params: TangemSegmentedPickerUM) { + var selectedItem by remember { mutableStateOf(params.items.first()) } + TangemThemePreviewRedesign { + Box( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level1) + .padding(TangemTheme.dimens2.x4), + ) { + TangemSegmentedPicker( + isFixed = params.isFixed, + hasSeparator = params.hasSeparator, + isAltSurface = params.isAltSurface, + items = params.items, + initialSelectedItem = params.items.last(), + onClick = { selectedItem = it }, + ) + } + } +} + +private class PreviewProvider : PreviewParameterProvider { + private val items = persistentListOf( + TangemSegmentUM(id = "1", title = TextReference.Str("Segment")), + TangemSegmentUM(id = "2", title = TextReference.Str("Segment Two")), + TangemSegmentUM(id = "3", title = TextReference.Str("Seg 3")), + ) + override val values: Sequence + get() = sequenceOf( + TangemSegmentedPickerUM( + items = items, + hasSeparator = false, + isFixed = false, + isAltSurface = false, + ), + TangemSegmentedPickerUM( + items = items, + hasSeparator = true, + isFixed = false, + isAltSurface = true, + ), + TangemSegmentedPickerUM( + items = items, + hasSeparator = false, + isFixed = true, + isAltSurface = false, + ), + TangemSegmentedPickerUM( + items = items, + hasSeparator = true, + isFixed = true, + isAltSurface = true, + ), + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPickerUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPickerUM.kt new file mode 100644 index 0000000000..4bc47ab4df --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPickerUM.kt @@ -0,0 +1,32 @@ +package com.tangem.core.ui.ds.tabs + +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +/** + * Model for Tangem segmented picker component. + * + * @param items List of TangemSegmentUM representing the segments in the picker. + * @param initialSelectedItem Optional TangemSegmentUM representing the initially selected segment. + * @param hasSeparator Boolean indicating whether there is a separator between segments. + * @param isFixed Boolean indicating whether the picker has a fixed width. + * @param isAltSurface Boolean indicating whether to use an alternative surface style. + */ +data class TangemSegmentedPickerUM( + val items: ImmutableList, + val initialSelectedItem: TangemSegmentUM? = null, + val hasSeparator: Boolean = false, + val isFixed: Boolean = false, + val isAltSurface: Boolean = false, +) + +/** + * Model for a single segment in the Tangem segmented picker. + * + * @param id String representing the unique identifier of the segment. + * @param title TextReference representing the title of the segment. + */ +data class TangemSegmentUM( + val id: String, + val title: TextReference, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt new file mode 100644 index 0000000000..67e1989fae --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt @@ -0,0 +1,105 @@ +package com.tangem.core.ui.ds.tabs + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * Tangem tab component. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8448-74386&m=dev) + * + * @param text TextReference representing the text to be displayed on the tab. + * @param isChecked Boolean value representing the current state of the tab. + * @param onCheckedChange Lambda function to be invoked when the tab state changes. + * @param modifier Modifier to be applied to the tab. + * @param isEnabled Boolean value to determine if the tab is enabled or disabled. + */ +@Composable +fun TangemTab( + text: TextReference, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + val shape = RoundedCornerShape(TangemTheme.dimens2.x25) + val backgroundColor = if (isChecked) { + TangemTheme.colors2.tabs.backgroundPrimary + } else { + TangemTheme.colors2.tabs.textPrimary + } + val textColor = if (isChecked) { + TangemTheme.colors2.tabs.textPrimary + } else { + TangemTheme.colors2.tabs.textSecondary + } + Box( + modifier = modifier + .background( + color = backgroundColor, + shape = shape, + ) + .clip(shape = shape) + .padding(all = TangemTheme.dimens2.x3) + .toggleable( + value = isChecked, + onValueChange = onCheckedChange, + enabled = isEnabled, + role = Role.Checkbox, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + ), + ) { + Text( + text = text.resolveReference(), + style = TangemTheme.typography2.bodySemibold16, + color = textColor, + maxLines = 1, + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemTab_Preview() { + TangemThemePreviewRedesign { + Column( + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x6), + modifier = Modifier.background(TangemTheme.colors2.surface.level1), + ) { + TangemTab( + text = stringReference("Tab Title"), + isChecked = true, + onCheckedChange = {}, + ) + TangemTab( + text = stringReference("Tab Title"), + isChecked = false, + onCheckedChange = {}, + ) + } + } +} + +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt new file mode 100644 index 0000000000..b96dd8d788 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBar.kt @@ -0,0 +1,239 @@ +package com.tangem.core.ui.ds.topbar + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * A top bar composable that displays a title and optional start and end icons. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8435-74860&m=dev) + * + * @param title The title text to be displayed in the center of the top bar. + * @param modifier Modifier to be applied to the top bar. + * @param subtitle Optional subtitle text to be displayed below the title. + * @param startIconRes Optional drawable resource ID for the start icon. + * @param onStartContentClick Optional click action for the start icon. + * @param endIconRes Optional drawable resource ID for the end icon. + * @param onEndContentClick Optional click action for the end icon. + * @param isGhostButtons Flag to determine if ghost button styling should be applied. + * +[REDACTED_AUTHOR] + */ +@Composable +fun TangemTopBar( + modifier: Modifier = Modifier, + title: TextReference? = null, + subtitle: TextReference? = null, + @DrawableRes startIconRes: Int? = null, + onStartContentClick: (() -> Unit)? = null, + @DrawableRes endIconRes: Int? = null, + onEndContentClick: (() -> Unit)? = null, + @DrawableRes titleIconRes: Int? = null, + titleStyle: TextStyle = TangemTheme.typography2.headingSemibold17, + isGhostButtons: Boolean = false, +) { + TangemTopBarInner( + modifier = modifier, + content = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5), + ) { + TangemTopBarTitle(title = title, titleIconRes = titleIconRes, titleStyle = titleStyle) + AnimatedVisibility( + visible = subtitle != null, + label = "Subtitle Visibility", + ) { + val wrappedSubtitle = remember(this) { requireNotNull(subtitle) } + Text( + text = wrappedSubtitle.resolveAnnotatedReference(), + color = TangemTheme.colors2.text.neutral.secondary, + style = TangemTheme.typography2.bodyRegular15, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } + } + }, + startContent = if (startIconRes != null) { + { TangemTopBarIcon(iconRes = startIconRes) } + } else { + null + }, + onStartContentClick = onStartContentClick, + endContent = if (endIconRes != null) { + { TangemTopBarIcon(iconRes = endIconRes) } + } else { + null + }, + onEndContentClick = onEndContentClick, + isGhostButtons = isGhostButtons, + ) +} + +@Composable +private fun TangemTopBarTitle(title: TextReference?, @DrawableRes titleIconRes: Int?, titleStyle: TextStyle) { + AnimatedVisibility( + visible = title != null, + label = "Title Visibility", + ) { + val wrappedTitle = remember(this) { requireNotNull(title) } + + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedVisibility( + visible = titleIconRes != null, + label = "Title Icon Visibility", + ) { + val wrappedTitleIconRes = remember(this) { + requireNotNull(titleIconRes) + } + Icon( + imageVector = ImageVector.vectorResource(id = wrappedTitleIconRes), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primary, + modifier = Modifier.size(TangemTheme.dimens2.x4), + ) + } + + Text( + text = wrappedTitle.resolveAnnotatedReference(), + color = TangemTheme.colors2.text.neutral.primary, + style = titleStyle, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } + } +} + +@Composable +private fun TangemTopBarIcon(@DrawableRes iconRes: Int) { + Icon( + imageVector = ImageVector.vectorResource(id = iconRes), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primary, + modifier = Modifier.fillMaxSize(), + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 375) +@Preview(showBackground = true, widthDp = 375, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemTopBar_Preview(@PreviewParameter(PreviewProvider::class) params: TangemTopBarPreviewData) { + TangemThemePreviewRedesign { + TangemTopBar( + title = params.title, + subtitle = params.subtitle, + startIconRes = params.startIconRes, + endIconRes = params.endIconRes, + titleIconRes = params.titleIconRes, + isGhostButtons = params.isGhostButtons, + onStartContentClick = {}, + onEndContentClick = {}, + modifier = Modifier.background(TangemTheme.colors2.surface.level1), + ) + } +} + +private class TangemTopBarPreviewData( + val title: TextReference? = null, + val subtitle: TextReference? = null, + val isGhostButtons: Boolean = false, + val titleIconRes: Int? = null, + val startIconRes: Int? = null, + val endIconRes: Int? = null, +) + +private class PreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemTopBarPreviewData( + title = stringReference("Title"), + startIconRes = R.drawable.ic_tangem_24, + endIconRes = R.drawable.ic_more_vertical_24, + isGhostButtons = true, + ), + TangemTopBarPreviewData( + title = stringReference("Title"), + subtitle = stringReference("Subtitle"), + startIconRes = R.drawable.ic_tangem_24, + endIconRes = R.drawable.ic_more_vertical_24, + isGhostButtons = true, + ), + TangemTopBarPreviewData( + title = stringReference("Title"), + subtitle = stringReference("Subtitle"), + titleIconRes = R.drawable.ic_tangem_24, + startIconRes = R.drawable.ic_tangem_24, + endIconRes = R.drawable.ic_more_vertical_24, + isGhostButtons = true, + ), + TangemTopBarPreviewData( + subtitle = stringReference("Subtitle"), + titleIconRes = R.drawable.ic_tangem_24, + startIconRes = R.drawable.ic_tangem_24, + endIconRes = R.drawable.ic_more_vertical_24, + isGhostButtons = true, + ), + TangemTopBarPreviewData( + title = stringReference("Title"), + endIconRes = R.drawable.ic_more_vertical_24, + isGhostButtons = true, + ), + TangemTopBarPreviewData( + title = stringReference("Title"), + startIconRes = R.drawable.ic_tangem_24, + isGhostButtons = true, + ), + TangemTopBarPreviewData( + title = combinedReference( + stringReference("$ 46,112"), + styledStringReference( + value = ".30", + spanStyleReference = { + TangemTheme.typography.caption1.copy(TangemTheme.colors2.text.neutral.secondary) + .toSpanStyle() + }, + ), + ), + ), + TangemTopBarPreviewData( + title = combinedReference( + stringReference("$ 46,112"), + styledStringReference( + value = ".30", + spanStyleReference = { + TangemTheme.typography.caption1.copy(TangemTheme.colors2.text.neutral.secondary) + .toSpanStyle() + }, + ), + ), + startIconRes = R.drawable.ic_tangem_24, + endIconRes = R.drawable.ic_more_vertical_24, + ), + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBarInner.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBarInner.kt new file mode 100644 index 0000000000..1014c640e4 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/TangemTopBarInner.kt @@ -0,0 +1,90 @@ +package com.tangem.core.ui.ds.topbar + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.conditional +import com.tangem.core.ui.extensions.conditionalCompose +import com.tangem.core.ui.res.TangemTheme + +/** + * Internal top bar composable that arranges optional start, center, and end content. + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8435-74860&m=dev) + * + * @param modifier Modifier to be applied to the top bar. + * @param content Center content of the top bar. + * @param startContent Optional start content of the top bar. + * @param onStartContentClick Optional click action for the start content. + * @param endContent Optional end content of the top bar. + * @param onEndContentClick Optional click action for the end content. + * @param isGhostButtons Flag to determine if ghost button styling should be applied. + * +[REDACTED_AUTHOR] + */ +@Composable +internal fun TangemTopBarInner( + modifier: Modifier = Modifier, + content: (@Composable () -> Unit)? = null, + startContent: (@Composable () -> Unit)? = null, + onStartContentClick: (() -> Unit)? = null, + endContent: (@Composable () -> Unit)? = null, + onEndContentClick: (() -> Unit)? = null, + isGhostButtons: Boolean = false, +) { + Box( + modifier = modifier + .height(TangemTheme.dimens2.x16) + .fillMaxWidth() + .padding(TangemTheme.dimens2.x4, TangemTheme.dimens2.x3), + ) { + val iconModifier = Modifier + .size(TangemTheme.dimens2.x10) + .clip(RoundedCornerShape(TangemTheme.dimens2.x25)) + .background(TangemTheme.colors2.button.backgroundSecondary) + + AnimatedVisibility( + visible = startContent != null, + modifier = Modifier.align(Alignment.CenterStart), + label = "Start Content Visibility", + ) { + Box( + modifier = iconModifier + .conditional(onStartContentClick != null) { + clickableSingle { onStartContentClick?.invoke() } + } + .conditionalCompose(isGhostButtons) { padding(TangemTheme.dimens2.x1) }, + ) { + startContent?.invoke() + } + } + + AnimatedVisibility( + visible = content != null, + modifier = Modifier.align(Alignment.Center), + ) { + content?.invoke() + } + + AnimatedVisibility( + visible = endContent != null, + modifier = Modifier.align(Alignment.CenterEnd), + label = "End Content Visibility", + ) { + Box( + modifier = iconModifier + .conditional(onEndContentClick != null) { + clickableSingle { onEndContentClick?.invoke() } + } + .conditionalCompose(isGhostButtons) { padding(TangemTheme.dimens2.x1) }, + ) { + endContent?.invoke() + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/MarkdownExtension.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/AnnotatedStringExt.kt similarity index 86% rename from core/ui/src/main/java/com/tangem/core/ui/extensions/MarkdownExtension.kt rename to core/ui/src/main/java/com/tangem/core/ui/extensions/AnnotatedStringExt.kt index f1e49e308f..e824de1e5d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/MarkdownExtension.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/AnnotatedStringExt.kt @@ -56,12 +56,31 @@ fun AnnotatedString.Builder.appendMarkdown(markdownText: String, node: ASTNode): return this } +/** + * Appends a single space character to the [AnnotatedString.Builder]. + */ fun AnnotatedString.Builder.appendSpace() = append(" ") +/** + * Appends text with the specified [Color] to the [AnnotatedString.Builder]. + * + * @param text The text to append. + * @param color The [Color] to apply to the appended text. + */ fun AnnotatedString.Builder.appendColored(text: String, color: Color) = withStyle(SpanStyle(color = color)) { append(text) } +/** + * Appends text with the specified [SpanStyle] to the [AnnotatedString.Builder]. + * + * @param text The text to append. + * @param spanStyle The [SpanStyle] to apply to the appended text. + */ +fun AnnotatedString.Builder.appendStyled(text: String, spanStyle: SpanStyle) = withStyle(spanStyle) { + append(text) +} + /** * Appends text from a template string to the AnnotatedString.Builder, replacing a placeholder (default "%s") * with custom styled content provided by a lambda. The lambda allows you to insert styled or complex content diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt index 8add413e59..79c329a101 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt @@ -2,15 +2,18 @@ package com.tangem.core.ui.extensions import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color /** * Utility class for keeping themed color reference from app theme. * - * It necessary to use [Immutable] annotation for runtime stability. + * It is necessary to use [Immutable] annotation for runtime stability. * * @property value color provider from theme */ +@Deprecated("Use TextReference with applied SpanStyleReference for colored text or ColorReference2 for color.") @Immutable data class ColorReference(val value: @Composable () -> Color) @@ -30,4 +33,17 @@ fun themedColor(value: @Composable () -> Color): ColorReference { @Composable fun ColorReference.resolveReference(): Color { return value() +} + +/** + * Utility functional interface for keeping themed [Color] reference from app theme. + * It is necessary to use [Stable] annotation for runtime stability. + */ +@Stable +@FunctionalInterface +fun interface ColorReference2 { + + @ReadOnlyComposable + @Composable + operator fun invoke(): Color } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/ImageReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/ImageReference.kt index 767065f05a..97397435f8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/ImageReference.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/ImageReference.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Immutable * It necessary to use [Immutable] annotation because all sealed interface has runtime stability. * All subclasses are stable. */ +@Immutable sealed class ImageReference { data class Url(val url: String) : ImageReference() diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt index cab83ed0d6..1f0a7b1208 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/ModifierExt.kt @@ -54,8 +54,8 @@ fun Modifier.conditional(condition: Boolean, modifier: Modifier.() -> Modifier): @Composable fun Modifier.conditionalCompose( condition: Boolean, - modifier: @Composable Modifier.() -> Modifier = { Modifier }, otherModifier: @Composable Modifier.() -> Modifier = { this }, + modifier: @Composable Modifier.() -> Modifier = { Modifier }, ): Modifier { return if (condition) { then(modifier(Modifier)) diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/SpanStyleReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/SpanStyleReference.kt new file mode 100644 index 0000000000..bd4ab76903 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/SpanStyleReference.kt @@ -0,0 +1,19 @@ +package com.tangem.core.ui.extensions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable +import androidx.compose.ui.text.SpanStyle + +/** + * Utility functional interface for keeping themed [SpanStyle] reference from app theme. + * It is necessary to use [Stable] annotation for runtime stability. + */ +@Stable +@FunctionalInterface +fun interface SpanStyleReference { + + @ReadOnlyComposable + @Composable + operator fun invoke(): SpanStyle +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt index 9141b2e002..e771427974 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt @@ -1,16 +1,30 @@ package com.tangem.core.ui.extensions +import android.content.res.Configuration import android.content.res.Resources import androidx.annotation.PluralsRes import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.* import androidx.compose.ui.text.AnnotatedString.Builder -import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp import com.tangem.core.res.getPluralStringSafe import com.tangem.core.res.getStringSafe +import com.tangem.core.ui.R +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview import com.tangem.utils.StringsSigns.THREE_STARS import org.intellij.markdown.MarkdownElementTypes import kotlin.contracts.ExperimentalContracts @@ -69,6 +83,35 @@ sealed interface TextReference { */ data class Combined(val refs: WrappedList) : TextReference + /** + * Styled string value + * + * @property value string value + * @property spanStyleReference text style reference + * @property onClick optional click action + */ + data class StyledStr( + val value: String, + val spanStyleReference: SpanStyleReference, + val onClick: (() -> Unit)? = null, + ) : TextReference + + /** + * Styled string resource + * + * @property id resource id + * @property formatArgs arguments. Impossible to use [kotlinx.collections.immutable.ImmutableList] because + * [Any] is unstable. + * @property spanStyleReference text style reference + * @property onClick optional click action + */ + data class StyledRes( + @StringRes val id: Int, + val formatArgs: WrappedList = WrappedList(emptyList()), + val spanStyleReference: SpanStyleReference, + val onClick: (() -> Unit)? = null, + ) : TextReference + companion object { /** Empty string as [TextReference] */ @@ -139,6 +182,42 @@ fun pluralReference( return TextReference.PluralRes(id, count, formatArgs) } +/** + * Creates a [TextReference] using a plain string value with optional span style and click action. + * + * @param value The plain string value. + * @param spanStyleReference A [SpanStyleReference] representing the text style to be applied. + * @param onClick An optional lambda function to be invoked when the text is clicked. + * @return A [TextReference] representing the styled string with click action. + */ +fun styledStringReference(value: String, spanStyleReference: SpanStyleReference, onClick: (() -> Unit)? = null) = + TextReference.StyledStr( + value = value, + onClick = onClick, + spanStyleReference = spanStyleReference, + ) + +/** + * Creates a [TextReference] using a string resource ID with optional format arguments, span style, and click action. + * + * @param id The resource ID of the string. + * @param formatArgs A list of format arguments to be applied to the string resource. + * @param spanStyleReference A [SpanStyleReference] representing the text style to be applied. + * @param onClick An optional lambda function to be invoked when the text is clicked. + * @return A [TextReference] representing the styled string with click action. + */ +fun styledResourceReference( + @StringRes id: Int, + formatArgs: WrappedList = WrappedList(emptyList()), + spanStyleReference: SpanStyleReference, + onClick: (() -> Unit)? = null, +) = TextReference.StyledRes( + id = id, + formatArgs = formatArgs, + spanStyleReference = spanStyleReference, + onClick = onClick, +) + /** * Combines multiple [TextReference] instances into a single [TextReference]. * @@ -165,9 +244,7 @@ fun combinedReference(vararg refs: TextReference): TextReference { fun TextReference.resolveReference(): String { return when (this) { is TextReference.Res -> { - val args = formatArgs - .map { if (it is TextReference) it.resolveReference() else it } - .toTypedArray() + val args = formatArgs.map { if (it is TextReference) it.resolveReference() else it }.toTypedArray() val resolvedReference = stringResourceSafe(id = id, *args) @@ -187,6 +264,12 @@ fun TextReference.resolveReference(): String { } } } + is TextReference.StyledRes -> { + val args = formatArgs.map { if (it is TextReference) it.resolveReference() else it }.toTypedArray() + + stringResourceSafe(id = id, *args) + } + is TextReference.StyledStr -> value } } @@ -194,9 +277,7 @@ fun TextReference.resolveReference(): String { fun TextReference.resolveReference(resources: Resources): String { return when (this) { is TextReference.Res -> { - val args = formatArgs - .map { if (it is TextReference) it.resolveReference(resources) else it } - .toTypedArray() + val args = formatArgs.map { if (it is TextReference) it.resolveReference(resources) else it }.toTypedArray() resources.getStringSafe(id, *args) } @@ -210,6 +291,12 @@ fun TextReference.resolveReference(resources: Resources): String { } } } + is TextReference.StyledRes -> { + val args = formatArgs.map { if (it is TextReference) it.resolveReference(resources) else it }.toTypedArray() + + resources.getStringSafe(id, *args) + } + is TextReference.StyledStr -> value } } @@ -218,9 +305,7 @@ fun TextReference.resolveReference(resources: Resources): String { fun TextReference.resolveAnnotatedReference(): AnnotatedString { return when (this) { is TextReference.Res -> { - val args = formatArgs - .map { if (it is TextReference) it.resolveReference() else it } - .toTypedArray() + val args = formatArgs.map { if (it is TextReference) it.resolveReference() else it }.toTypedArray() formatAnnotated(stringResourceSafe(id = id, *args)) } @@ -234,6 +319,21 @@ fun TextReference.resolveAnnotatedReference(): AnnotatedString { append(it.resolveAnnotatedReference()) } } + is TextReference.StyledRes -> { + val args = formatArgs.map { if (it is TextReference) it.resolveReference() else it }.toTypedArray() + val text = stringResourceSafe(id = id, *args) + + createStyledText( + text = text, + spanStyleReference = spanStyleReference, + onClick = onClick, + ) + } + is TextReference.StyledStr -> createStyledText( + text = value, + spanStyleReference = spanStyleReference, + onClick = onClick, + ) } } @@ -245,6 +345,8 @@ operator fun TextReference.plus(ref: TextReference): TextReference { is TextReference.Res, is TextReference.Str, is TextReference.Annotated, + is TextReference.StyledRes, + is TextReference.StyledStr, -> TextReference.Combined(refs = wrappedList(this, ref)) } } @@ -280,4 +382,121 @@ private fun formatAnnotated(rawString: String): AnnotatedString { */ fun TextReference.orMaskWithStars(maskWithStars: Boolean): TextReference { return if (maskWithStars) stringReference(THREE_STARS) else this -} \ No newline at end of file +} + +@ReadOnlyComposable +@Composable +private fun createStyledText( + text: String, + spanStyleReference: SpanStyleReference, + onClick: (() -> Unit)?, +): AnnotatedString = buildAnnotatedString { + if (onClick != null) { + withLink( + link = LinkAnnotation.Clickable( + tag = text, + linkInteractionListener = { onClick() }, + styles = TextLinkStyles(style = spanStyleReference()), + ), + block = { + appendStyled( + text = text, + spanStyle = spanStyleReference(), + ) + }, + ) + } else { + appendStyled( + text = text, + spanStyle = spanStyleReference(), + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TextReference_Preview(@PreviewParameter(TextReferencePreviewProvider::class) params: TextReference) { + TangemThemePreview { + val uriHandler = LocalUriHandler.current + + Column( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors.background.primary) + .padding(4.dp), + ) { + Text( + text = params.resolveAnnotatedReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = styledResourceReference( + id = R.string.common_read_more, + spanStyleReference = { + TangemTheme.typography.body1.copy(TangemTheme.colors.text.accent).toSpanStyle() + }, + onClick = { + uriHandler.openUri("https://tangem.com") + }, + ).resolveAnnotatedReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = stringReference("To be masked").orMaskWithStars(true).resolveAnnotatedReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + } + } +} + +private class TextReferencePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + stringReference("Simple string"), + resourceReference(R.string.common_tangem), + pluralReference( + id = R.plurals.common_days, + count = 5, + formatArgs = wrappedList(5), + ), + styledStringReference( + value = "Styled string", + spanStyleReference = { + TangemTheme.typography.subtitle2.copy(TangemTheme.colors.text.accent).toSpanStyle() + }, + ), + styledResourceReference( + id = R.string.common_tangem, + spanStyleReference = { + TangemTheme.typography.caption1.copy(TangemTheme.colors.text.accent).toSpanStyle() + }, + ), + combinedReference( + stringReference("Simple string"), + resourceReference(R.string.common_tangem), + pluralReference( + id = R.plurals.common_days, + count = 5, + formatArgs = wrappedList(5), + ), + styledStringReference( + value = "Styled string", + spanStyleReference = { + TangemTheme.typography.subtitle2.copy(TangemTheme.colors.text.accent).toSpanStyle() + }, + ), + styledResourceReference( + id = R.string.common_tangem, + spanStyleReference = { + TangemTheme.typography.caption1.copy(TangemTheme.colors.text.warning).toSpanStyle() + }, + ), + ), + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt index b3bfdd9a48..04801d50d7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt @@ -18,6 +18,18 @@ object TangemColorPalette { val Dark6 = Color(0xFF1E1E1E) // endregion Dark + // region Dark Alpha + val Dark_10 = Color(0x1A1E1E1E) + val Dark_20 = Color(0x331E1E1E) + val Dark_30 = Color(0x4D1E1E1E) + val Dark_40 = Color(0x661E1E1E) + val Dark_50 = Color(0x801E1E1E) + val Dark_60 = Color(0x991E1E1E) + val Dark_70 = Color(0xB31E1E1E) + val Dark_80 = Color(0xCC1E1E1E) + val Dark_90 = Color(0xE61E1E1E) + // endregion Dark Alpha + // region Light val Light1 = Color(0xFFF5F5F5) val Light1V2 = Color(0xFFF4F4F4) @@ -27,6 +39,18 @@ object TangemColorPalette { val Light5 = Color(0xFFB0B0B0) // endregion Light + // region Light Alpha + val Light_10 = Color(0x1AFFFFFF) + val Light_20 = Color(0x33FFFFFF) + val Light_30 = Color(0x4DFFFFFF) + val Light_40 = Color(0x66FFFFFF) + val Light_50 = Color(0x80FFFFFF) + val Light_60 = Color(0x99FFFFFF) + val Light_70 = Color(0xB3FFFFFF) + val Light_80 = Color(0xCCFFFFFF) + val Light_90 = Color(0xE6FFFFFF) + // endregion Light Alpha + // region Green val Green = Color(0xFF0C9F3D) val Meadow = Color(0xFF1ACE80) diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors.kt index 12f15c13b5..e4c3d5085d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors.kt @@ -169,19 +169,19 @@ class TangemColors internal constructor( @Stable class Control internal constructor( checked: Color, - unchecked: Color, + default: Color, key: Color, ) { var checked by mutableStateOf(checked) private set - var unchecked by mutableStateOf(unchecked) + var default by mutableStateOf(default) private set var key by mutableStateOf(key) private set fun update(other: Control) { checked = other.checked - unchecked = other.unchecked + default = other.default key = other.key } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt index 68ce8e0a85..c3d2a23e2a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt @@ -1,4 +1,5 @@ @file:Suppress("LongParameterList") + package com.tangem.core.ui.res import androidx.compose.runtime.Stable @@ -20,6 +21,7 @@ class TangemColors2 internal constructor( val fill: Fill, val skeleton: Skeleton, val markers: Markers, + val tabs: Tabs, ) { @Stable @@ -162,6 +164,7 @@ class TangemColors2 internal constructor( backgroundSecondary: Color, backgroundDisabled: Color, backgroundPositive: Color, + backgroundPrimaryInverse: Color, textPrimary: Color, textSecondary: Color, textDisabled: Color, @@ -178,6 +181,8 @@ class TangemColors2 internal constructor( private set var backgroundPositive by mutableStateOf(backgroundPositive) private set + var backgroundPrimaryInverse by mutableStateOf(backgroundPrimaryInverse) + private set var textPrimary by mutableStateOf(textPrimary) private set var textSecondary by mutableStateOf(textSecondary) @@ -198,6 +203,7 @@ class TangemColors2 internal constructor( backgroundSecondary = other.backgroundSecondary backgroundDisabled = other.backgroundDisabled backgroundPositive = other.backgroundPositive + backgroundPrimaryInverse = other.backgroundPrimaryInverse textPrimary = other.textPrimary textSecondary = other.textSecondary textDisabled = other.textDisabled @@ -520,6 +526,42 @@ class TangemColors2 internal constructor( } } + @Stable + class Tabs internal constructor( + textPrimary: Color, + textSecondary: Color, + textTertiary: Color, + backgroundPrimary: Color, + backgroundSecondary: Color, + backgroundTertiary: Color, + backgroundQuaternary: Color, + ) { + var textPrimary by mutableStateOf(textPrimary) + private set + var textSecondary by mutableStateOf(textSecondary) + private set + var textTertiary by mutableStateOf(textTertiary) + private set + var backgroundPrimary by mutableStateOf(backgroundPrimary) + private set + var backgroundSecondary by mutableStateOf(backgroundSecondary) + private set + var backgroundTertiary by mutableStateOf(backgroundTertiary) + private set + var backgroundQuaternary by mutableStateOf(backgroundQuaternary) + private set + + fun update(other: Tabs) { + textPrimary = other.textPrimary + textSecondary = other.textSecondary + textTertiary = other.textTertiary + backgroundPrimary = other.backgroundPrimary + backgroundSecondary = other.backgroundSecondary + backgroundTertiary = other.backgroundTertiary + backgroundQuaternary = other.backgroundQuaternary + } + } + fun update(other: TangemColors2) { text.update(other.text) graphic.update(other.graphic) @@ -532,5 +574,6 @@ class TangemColors2 internal constructor( fill.update(other.fill) skeleton.update(other.skeleton) markers.update(other.markers) + tabs.update(other.tabs) } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens2.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens2.kt new file mode 100644 index 0000000000..292a6555e3 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens2.kt @@ -0,0 +1,39 @@ +package com.tangem.core.ui.res + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Suppress("ConstructorParameterNaming") +@ConsistentCopyVisibility +@Immutable +data class TangemDimens2 internal constructor( + val x0: Dp = 0.dp, + val x0_5: Dp = 2.dp, + val x1: Dp = 4.dp, + val x2: Dp = 8.dp, + val x2_5: Dp = 10.dp, + val x3: Dp = 12.dp, + val x4: Dp = 16.dp, + val x5: Dp = 20.dp, + val x6: Dp = 24.dp, + val x7: Dp = 28.dp, + val x8: Dp = 32.dp, + val x9: Dp = 36.dp, + val x10: Dp = 40.dp, + val x11: Dp = 44.dp, + val x12: Dp = 48.dp, + val x13: Dp = 52.dp, + val x14: Dp = 56.dp, + val x15: Dp = 60.dp, + val x16: Dp = 64.dp, + val x17: Dp = 68.dp, + val x18: Dp = 72.dp, + val x19: Dp = 76.dp, + val x20: Dp = 80.dp, + val x21: Dp = 84.dp, + val x22: Dp = 88.dp, + val x23: Dp = 92.dp, + val x24: Dp = 96.dp, + val x25: Dp = 100.dp, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index 85535f739b..829a8ff0e1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -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 @@ -16,6 +18,8 @@ import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.components.LocalSystemBarsIconsController import com.tangem.core.ui.components.SystemBarsIconsController import com.tangem.core.ui.components.TangemShimmer +import com.tangem.core.ui.components.powersaving.PowerSavingState +import com.tangem.core.ui.components.powersaving.rememberPowerSavingState import com.tangem.core.ui.components.text.BladeAnimation import com.tangem.core.ui.components.text.rememberBladeAnimation import com.tangem.core.ui.haptic.DefaultHapticManager @@ -26,7 +30,9 @@ import com.tangem.core.ui.windowsize.WindowSize import com.tangem.core.ui.windowsize.rememberWindowSize import com.tangem.domain.apptheme.model.AppThemeMode import com.valentinilk.shimmer.Shimmer +import dev.chrisbanes.haze.HazeState +@OptIn(ExperimentalFoundationApi::class) @Composable fun TangemTheme( activity: Activity, @@ -36,6 +42,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) @@ -48,7 +57,13 @@ fun TangemTheme( overrideSystemBarColors = overrideSystemBarColors, typography = typography, dimens = dimens, - content = content, + content = { + if (uiDependencies.designFeatureToggles.isRedesignEnabled) { + TangemThemeRedesign { content() } + } else { + content() + } + }, ) } @@ -108,6 +123,7 @@ fun TangemTheme( LocalWindowSize provides windowSize, LocalBladeAnimation provides rememberBladeAnimation(), LocalSystemBarsIconsController provides systemBarsIconsController, + LocalPowerSavingState provides rememberPowerSavingState(), ) { CompositionLocalProvider( LocalTangemShimmer provides TangemShimmer, @@ -148,11 +164,21 @@ object TangemTheme { @ReadOnlyComposable get() = LocalTangemTypography.current + val typography2: TangemTypography2 + @Composable + @ReadOnlyComposable + get() = TangemTypography2(InterFamily) + val dimens: TangemDimens @Composable @ReadOnlyComposable get() = LocalTangemDimens.current + val dimens2: TangemDimens2 + @Composable + @ReadOnlyComposable + get() = LocalTangemDimens2.current + val shapes: TangemShapes @Composable @ReadOnlyComposable @@ -244,7 +270,7 @@ internal fun lightThemeColors(redesign: Boolean = false): TangemColors { ), control = TangemColors.Control( checked = TangemColorPalette.Dark6, - unchecked = TangemColorPalette.Light2, + default = TangemColorPalette.Light2, key = TangemColorPalette.White, ), stroke = TangemColors.Stroke( @@ -298,7 +324,7 @@ internal fun darkThemeColors(): TangemColors { ), control = TangemColors.Control( checked = TangemColorPalette.Azure, - unchecked = TangemColorPalette.Dark4, + default = TangemColorPalette.Dark4, key = TangemColorPalette.White, ), stroke = TangemColors.Stroke( @@ -337,10 +363,18 @@ internal val LocalTangemTypography = staticCompositionLocalOf { TangemTypography(RobotoFamily) } +internal val LocalTangemTypography2 = staticCompositionLocalOf { + TangemTypography2(InterFamily) +} + private val LocalTangemDimens = staticCompositionLocalOf { TangemDimens() } +private val LocalTangemDimens2 = staticCompositionLocalOf { + TangemDimens2() +} + private val LocalTangemShapes = staticCompositionLocalOf { error("No TangemShapes provided") } @@ -379,6 +413,18 @@ val LocalBladeAnimation = staticCompositionLocalOf { error("No MainBottomSheetColor provided") } +val LocalHazeState = staticCompositionLocalOf { + error("No HazeState provided") +} + +val LocalRedesignEnabled = staticCompositionLocalOf { + false +} + +val LocalPowerSavingState = compositionLocalOf { + error("No PowerSavingState provided") +} + /** * Determines whether the dark theme should be used based on the given [AppThemeMode]. * diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt index dd15fd2212..6b72e745b6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt @@ -37,6 +37,32 @@ fun TangemThemePreview( } } +@Composable +fun TangemThemePreviewRedesign( + isDark: Boolean? = null, + alwaysShowBottomSheets: Boolean = true, + rtl: Boolean = false, + content: @Composable () -> Unit, +) { + val isDarkTheme = isDark ?: isSystemInDarkTheme() + + CompositionLocalProvider( + LocalBottomSheetAlwaysVisible provides alwaysShowBottomSheets, + LocalLayoutDirection provides if (rtl) LayoutDirection.Rtl else LayoutDirection.Ltr, + ) { + BoxWithConstraints { + TangemTheme( + isDark = isDarkTheme, + windowSize = rememberWindowSizePreview(maxWidth, maxHeight), + ) { + TangemThemeRedesign( + content = content, + ) + } + } + } +} + /** * This is used to make the bottom sheet always visible in the Preview and should be `true` only in the Preview. * */ diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt index 476a412a20..efa104abf2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt @@ -1,8 +1,13 @@ @file:Suppress("LongMethod") + package com.tangem.core.ui.res +import androidx.compose.foundation.layout.Box import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.haze.ProvideHaze +import com.tangem.core.ui.components.haze.hazeSourceTangem /** * Provides additional theming for redesigned components. @@ -20,12 +25,17 @@ fun TangemThemeRedesign(content: @Composable () -> Unit) { colorScheme = tangemColorScheme(colors = themeColors), ) { CompositionLocalProvider( + LocalRedesignEnabled provides true, LocalTangemColors provides themeColors, LocalTangemColors2 provides if (LocalIsInDarkTheme.current) darkThemeColors2() else lightThemeColors2(), - LocalTangemTypography provides TangemTypography(InterFamily), + LocalTangemTypography2 provides TangemTypography2(InterFamily), LocalRootBackgroundColor provides remember(rootBackgroundColor) { mutableStateOf(rootBackgroundColor) }, ) { - content() + ProvideHaze { + Box(Modifier.hazeSourceTangem(zIndex = 1f)) { + content() + } + } } } } @@ -97,9 +107,10 @@ private fun lightThemeColors2(): TangemColors2 { ) val button = TangemColors2.Button( backgroundPrimary = TangemColorPalette.Dark6, - backgroundSecondary = TangemColorPalette.Dark6.copy(alpha = 0.1f), + backgroundSecondary = TangemColorPalette.Dark_10, backgroundDisabled = TangemColorPalette.Light3, backgroundPositive = TangemColorPalette.Azure, + backgroundPrimaryInverse = TangemColorPalette.White, textSecondary = TangemColorPalette.Dark6, textPrimary = TangemColorPalette.Light2, textDisabled = text.neutral.tertiary, @@ -154,6 +165,15 @@ private fun lightThemeColors2(): TangemColors2 { borderTintedBlue = TangemColorPalette.Azure.copy(alpha = 0.1f), borderTintedRed = TangemColorPalette.Amaranth.copy(alpha = 0.1f), ) + val tabs = TangemColors2.Tabs( + textPrimary = TangemColorPalette.Light2, + textSecondary = TangemColorPalette.Dark4, + textTertiary = TangemColorPalette.Black, + backgroundPrimary = TangemColorPalette.Dark6, + backgroundSecondary = TangemColorPalette.Dark_10, + backgroundTertiary = TangemColorPalette.White, + backgroundQuaternary = TangemColorPalette.Dark_20, + ) return TangemColors2( text = text, graphic = graphic, @@ -166,6 +186,7 @@ private fun lightThemeColors2(): TangemColors2 { field = field, skeleton = skeleton, markers = markers, + tabs = tabs, ) } @@ -236,9 +257,10 @@ private fun darkThemeColors2(): TangemColors2 { ) val button = TangemColors2.Button( backgroundPrimary = TangemColorPalette.Light1V2, - backgroundSecondary = TangemColorPalette.White.copy(alpha = 0.1f), + backgroundSecondary = TangemColorPalette.Light_10, backgroundDisabled = TangemColorPalette.Dark5, backgroundPositive = TangemColorPalette.Azure, + backgroundPrimaryInverse = TangemColorPalette.Light_10, textSecondary = TangemColorPalette.Light4, textPrimary = TangemColorPalette.Dark4, textDisabled = text.neutral.secondary, @@ -293,6 +315,15 @@ private fun darkThemeColors2(): TangemColors2 { borderTintedBlue = TangemColorPalette.Azure.copy(alpha = 0.1f), borderTintedRed = TangemColorPalette.Amaranth.copy(alpha = 0.1f), ) + val tabs = TangemColors2.Tabs( + textPrimary = TangemColorPalette.Dark4, + textSecondary = TangemColorPalette.Light5, + textTertiary = TangemColorPalette.White, + backgroundPrimary = TangemColorPalette.Light1, + backgroundSecondary = TangemColorPalette.Light_10, + backgroundTertiary = TangemColorPalette.Light_10, + backgroundQuaternary = TangemColorPalette.Light_10, + ) return TangemColors2( text = text, graphic = graphic, @@ -305,5 +336,6 @@ private fun darkThemeColors2(): TangemColors2 { field = field, skeleton = skeleton, markers = markers, + tabs = tabs, ) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt index d50a43f6ed..a86e82124c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.TextUnit @@ -17,11 +16,6 @@ internal val RobotoFamily = FontFamily( Font(R.font.roboto_medium, FontWeight.Medium), ) -internal val InterFamily = FontFamily( - Font(R.font.inter_regular), - Font(R.font.inter_italic, style = FontStyle.Italic), -) - @Immutable class TangemTypography internal constructor( fontFamily: FontFamily, diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography2.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography2.kt new file mode 100644 index 0000000000..4973fefe6f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography2.kt @@ -0,0 +1,348 @@ +package com.tangem.core.ui.res + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tangem.core.ui.R + +internal val InterFamily = FontFamily( + Font(R.font.inter_regular), + Font(R.font.inter_italic, style = FontStyle.Italic), +) + +@Stable +class TangemTypography2 internal constructor( + fontFamily: FontFamily, +) { + val titleRegular44: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 44.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TextUnit(value = 0.37f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 48f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingRegular34: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 34.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = 0.37f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 40f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingBold34: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 34.sp, + fontWeight = FontWeight.Bold, + letterSpacing = TextUnit(value = 0.37f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 40f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingRegular28: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 28.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TextUnit(value = 0.36f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 36f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingBold28: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + letterSpacing = TextUnit(value = 0.36f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 36f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingRegular22: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 22.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = 0.35f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 28f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingBold22: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + letterSpacing = TextUnit(value = 0.35f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 28f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingRegular20: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 20.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = 0.38f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 24f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingSemibold20: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TextUnit(value = 0.38f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 24f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingRegular17: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 17.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = -0.41f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val headingSemibold17: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TextUnit(value = -0.2f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val bodyRegular16: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = -0.32f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val bodySemibold16: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TextUnit(value = -0.32f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val bodyRegular15: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 15.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = -0.24f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val bodySemibold15: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + letterSpacing = TextUnit(value = -0.1f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val bodyRegular14: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + letterSpacing = TextUnit(value = -0.1f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 16f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val captionRegular13: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = -0.08f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 16f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val captionSemibold13: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TextUnit(value = 0.1f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 16f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val captionRegular12: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = 0f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 16f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val captionSemibold12: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + letterSpacing = TextUnit(value = 0.1f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 16f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val captionRegular11: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.Normal, + letterSpacing = TextUnit(value = 0.07f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 12f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val captionSemibold11: TextStyle = TextStyle( + fontFamily = fontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = TextUnit(value = 0.15f, type = TextUnitType.Sp), + lineHeight = TextUnit(value = 12f, type = TextUnitType.Sp), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360, heightDp = 1500) +@Preview(showBackground = true, widthDp = 360, heightDp = 1500, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemTypography2_Preview() { + TangemThemePreviewRedesign { + val typographyList = sequenceOf( + TangemTheme.typography2.titleRegular44, + TangemTheme.typography2.headingRegular34, + TangemTheme.typography2.headingBold34, + TangemTheme.typography2.headingRegular28, + TangemTheme.typography2.headingBold28, + TangemTheme.typography2.headingRegular22, + TangemTheme.typography2.headingBold22, + TangemTheme.typography2.headingRegular20, + TangemTheme.typography2.headingSemibold20, + TangemTheme.typography2.headingRegular17, + TangemTheme.typography2.headingSemibold17, + TangemTheme.typography2.bodyRegular16, + TangemTheme.typography2.bodySemibold16, + TangemTheme.typography2.bodyRegular15, + TangemTheme.typography2.bodySemibold15, + TangemTheme.typography2.bodyRegular14, + TangemTheme.typography2.captionRegular13, + TangemTheme.typography2.captionSemibold13, + TangemTheme.typography2.captionRegular12, + TangemTheme.typography2.captionSemibold12, + TangemTheme.typography2.captionRegular11, + TangemTheme.typography2.captionSemibold11, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .background(TangemTheme.colors2.surface.level1) + .padding(4.dp), + ) { + typographyList.forEach { textStyle -> + Box(modifier = Modifier.heightIn(min = 60.dp)) { + Text( + text = "Lorem ipsum", + style = textStyle, + color = TangemTheme.colors2.text.neutral.primary, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } +} +// endregion \ No newline at end of file diff --git a/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/java/com/tangem/core/ui/utils/ScrollUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/ScrollUtils.kt deleted file mode 100644 index 3cef274d3a..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/ScrollUtils.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.core.ui.utils - -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll - -fun Modifier.disableNestedScroll(): Modifier = nestedScroll(DisableParentConnection) - -private object DisableParentConnection : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - return available.copy(x = 0f) - } -} \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_check_default_24.xml b/core/ui/src/main/res/drawable/ic_check_default_24.xml new file mode 100644 index 0000000000..4177ae9043 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_check_default_24.xml @@ -0,0 +1,12 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_down_dynamic_24.xml b/core/ui/src/main/res/drawable/ic_down_dynamic_24.xml new file mode 100644 index 0000000000..01b2208a14 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_down_dynamic_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_drag_24.xml b/core/ui/src/main/res/drawable/ic_drag_24.xml index 9246315ec7..3cea032612 100644 --- a/core/ui/src/main/res/drawable/ic_drag_24.xml +++ b/core/ui/src/main/res/drawable/ic_drag_24.xml @@ -4,7 +4,9 @@ android:viewportWidth="24" android:viewportHeight="24"> + android:pathData="M5,8H19M5,16H19" + android:strokeWidth="2" + android:fillColor="#00000000" + android:strokeColor="#000000" + android:strokeLineCap="round"/> diff --git a/core/ui/src/main/res/drawable/ic_heart_broken_32.xml b/core/ui/src/main/res/drawable/ic_heart_broken_32.xml new file mode 100644 index 0000000000..2d9d8aad75 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_heart_broken_32.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_p2p_logo.xml b/core/ui/src/main/res/drawable/ic_p2p_logo.xml new file mode 100644 index 0000000000..f7070f1376 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_p2p_logo.xml @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/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/core/ui/src/main/res/drawable/ic_static_dynamic_24.xml b/core/ui/src/main/res/drawable/ic_static_dynamic_24.xml new file mode 100644 index 0000000000..ee4903259a --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_static_dynamic_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_up_dynamic_24.xml b/core/ui/src/main/res/drawable/ic_up_dynamic_24.xml new file mode 100644 index 0000000000..4f63f4b68f --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_up_dynamic_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index 7e38a27c0c..a07d7fac8a 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -70,5 +70,6 @@ dependencies { testImplementation(projects.common.test) testImplementation(projects.test.core) testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.turbine) // endregion } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt index 5dc4c61679..10b55769a9 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -1,11 +1,16 @@ package com.tangem.data.account.di import android.content.Context +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.squareup.moshi.Moshi import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.data.account.converter.AccountConverterFactoryContainer import com.tangem.data.account.featuretoggle.DefaultAccountsFeatureToggles import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher +import com.tangem.data.account.repository.AccountsExpandedDTO import com.tangem.data.account.repository.DefaultAccountsCRUDRepository +import com.tangem.data.account.repository.DefaultAccountsExpandedRepository import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.store.ArchivedAccountsStoreFactory import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration @@ -13,11 +18,16 @@ import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.data.common.account.WalletAccountsSaver import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.accounts.AccountTokenMigrationStore import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.datasource.utils.MoshiDataStoreSerializer +import com.tangem.datasource.utils.mapWithStringKeyTypes +import com.tangem.datasource.utils.setTypes import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.repository.AccountsExpandedRepository import com.tangem.domain.account.tokens.MainAccountTokensMigration import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -25,6 +35,8 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -63,6 +75,28 @@ internal object AccountDataModule { ) } + @Provides + @Singleton + fun provideAccountsExpandedRepository( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + dispatchers: CoroutineDispatcherProvider, + ): AccountsExpandedRepository { + val store = DataStoreFactory.create>>( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = mapWithStringKeyTypes(valueTypes = setTypes()), + defaultValue = emptyMap(), + ), + produceFile = { context.dataStoreFile(fileName = "account_expanded_store") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ) + + return DefaultAccountsExpandedRepository( + store = store, + ) + } + @Provides @Singleton fun provideWalletAccountsFetcher(impl: DefaultWalletAccountsFetcher): WalletAccountsFetcher = impl @@ -74,10 +108,16 @@ internal object AccountDataModule { @Provides @Singleton fun provideMainAccountTokensMigration( + defaultMainAccountTokensMigration: DefaultMainAccountTokensMigration, + ): MainAccountTokensMigration = defaultMainAccountTokensMigration + + @Provides + @Singleton + fun provideDefaultMainAccountTokensMigration( accountsResponseStoreFactory: AccountsResponseStoreFactory, userTokensSaver: UserTokensSaver, accountTokenMigrationStore: AccountTokenMigrationStore, - ): MainAccountTokensMigration { + ): DefaultMainAccountTokensMigration { return DefaultMainAccountTokensMigration( accountsResponseStoreFactory = accountsResponseStoreFactory, accountTokenMigrationStore = accountTokenMigrationStore, diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt index 8c475f3b58..bd25eb1add 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt @@ -2,6 +2,7 @@ package com.tangem.data.account.fetcher import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.account.utils.assignTokens import com.tangem.data.common.account.WalletAccountsFetcher @@ -50,6 +51,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory, private val eTagsStore: ETagsStore, private val dispatchers: CoroutineDispatcherProvider, + private val mainAccountTokensMigration: DefaultMainAccountTokensMigration, ) : WalletAccountsFetcher, WalletAccountsSaver { override suspend fun fetch(userWalletId: UserWalletId): GetWalletAccountsResponse { @@ -71,7 +73,8 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( throw fetchResult.error } - return updatedResponse + val migratedResponse = mainAccountTokensMigration.migrate(userWalletId).getOrNull() ?: updatedResponse + return migratedResponse } override suspend fun getSaved(userWalletId: UserWalletId): GetWalletAccountsResponse? { diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt new file mode 100644 index 0000000000..6fab883946 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt @@ -0,0 +1,66 @@ +package com.tangem.data.account.repository + +import androidx.datastore.core.DataStore +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.data.account.converter.toAccountId +import com.tangem.domain.account.models.AccountExpandedState +import com.tangem.domain.account.repository.AccountsExpandedRepository +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +internal class DefaultAccountsExpandedRepository( + private val store: DataStore>>, +) : AccountsExpandedRepository { + + override val expandedAccounts: Flow>> = store.data + .map { stored -> + stored.map { (rawWalletId, setOfDto) -> + val walletId = UserWalletId(rawWalletId) + val setOfState = setOfDto.mapTo(mutableSetOf()) { dto -> + AccountExpandedState( + accountId = dto.accountId.toAccountId(walletId), + isExpanded = dto.isExpanded, + ) + } + walletId to setOfState + }.toMap() + } + + override suspend fun syncStore(walletId: UserWalletId, existAccounts: Set) { + store.updateData { map -> + val setOfDto = map[walletId.stringValue] ?: return@updateData map + val existingAccountIds = existAccounts.map { it.value }.toSet() + val syncedSet = setOfDto + .filterTo(mutableSetOf()) { (accountId, _) -> existingAccountIds.contains(accountId) } + + map.plus(walletId.stringValue to syncedSet) + } + } + + override suspend fun update(accountState: AccountExpandedState) { + store.updateData { map -> + val walletId = accountState.accountId.userWalletId + val setOfDto = map[walletId.stringValue].orEmpty() + val newDto = AccountsExpandedDTO( + accountId = accountState.accountId.value, + isExpanded = accountState.isExpanded, + ) + val updatedSet = setOfDto + .filterTo(mutableSetOf()) { it.accountId != accountState.accountId.value } + .plus(newDto) + + map.plus(walletId.stringValue to updatedSet) + } + } +} + +@JsonClass(generateAdapter = true) +internal data class AccountsExpandedDTO( + @Json(name = "accountId") + val accountId: String, + @Json(name = "isExpanded") + val isExpanded: Boolean, +) \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt b/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt index d4e9d0325f..8e65c680ce 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt @@ -7,6 +7,7 @@ import arrow.core.raise.ensureNotNull import arrow.core.toNonEmptyListOrNull import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.data.account.converter.toDerivationIndex import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.utils.assignTokens import com.tangem.data.common.currency.UserTokensSaver @@ -37,6 +38,67 @@ internal class DefaultMainAccountTokensMigration( private val userTokensSaver: UserTokensSaver, ) : MainAccountTokensMigration { + internal suspend fun migrate(userWalletId: UserWalletId): Either = either { + val store = accountsResponseStoreFactory.create(userWalletId) + val response = store.getSyncOrNull() + ensureNotNull(response) { + val exception = IllegalStateException("No cached accounts response found") + Timber.e(exception) + exception + } + val mainAccount = findAccount(response = response, derivationIndex = DerivationIndex.Main) + val notMainAccounts = response.accounts + .filterNot { accountDTO -> accountDTO.derivationIndex.toDerivationIndex().isMain } + + if (notMainAccounts.isEmpty()) { + Timber.i("There is only the Main account. Nothing to migrate") + return@either response + } + + val unassignedTokens = mainAccount.groupUnassignedTokens() + + if (unassignedTokens.isEmpty()) { + Timber.i("No unassigned tokens found for migration") + return@either response + } + + var updatedMainAccount = mainAccount + val assignedTokensAccounts = notMainAccounts.mapNotNull { accountDTO -> + val derivationIndex = accountDTO.derivationIndex.toDerivationIndex() + val tokensForAccount = unassignedTokens[derivationIndex] + if (tokensForAccount.isNullOrEmpty()) return@mapNotNull null + updatedMainAccount = updatedMainAccount.copy( + tokens = updatedMainAccount.tokens.orEmpty() - tokensForAccount, + ) + accountDTO.assignTokens(userWalletId, tokensForAccount) + } + + val updatedResponse = response.copy( + accounts = response.accounts.map { account -> + val assignAccount = assignedTokensAccounts.find { it.id == account.id } + when { + account.id == mainAccount.id -> updatedMainAccount + assignAccount != null -> assignAccount + else -> account + } + }, + ) + + store.updateData { updatedResponse } + + val userTokensResponse = updatedResponse.toUserTokensResponse() + userTokensSaver.pushWithRetryer( + userWalletId = userWalletId, + response = userTokensResponse, + onFailSend = { + val exception = IllegalStateException("Failed to push updated tokens after migration") + Timber.e(exception) + raise(exception) + }, + ) + return@either updatedResponse + } + override suspend fun migrate( userWalletId: UserWalletId, derivationIndex: DerivationIndex, @@ -113,6 +175,22 @@ internal class DefaultMainAccountTokensMigration( } } + private fun WalletAccountDTO.groupUnassignedTokens(): Map> = + this.tokens + .orEmpty() + .mapNotNull { token -> + val blockchain = Blockchain.fromNetworkId(token.networkId) ?: return@mapNotNull null + val derivationPath = token.derivationPath ?: return@mapNotNull null + val accountNode = AccountNodeRecognizer(blockchain) + .recognize(derivationPath) + ?: return@mapNotNull null + + if (accountNode == DerivationIndex.Main.value.toLong()) return@mapNotNull null + val derivationIndex = DerivationIndex(accountNode.toInt()).getOrNull() ?: return@mapNotNull null + derivationIndex to token.copy(accountId = null) + } + .groupBy({ it.first }, { it.second }) + private fun WalletAccountDTO.findUnassignedTokens( derivationIndex: DerivationIndex, ): List? { diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt index 1bfbb38da1..4f358b2d09 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt @@ -1,11 +1,13 @@ package com.tangem.data.account.fetcher +import arrow.core.right import com.google.common.truth.Truth import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.converter.createWalletAccountDTO import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher.FetchResult import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.UserTokensSaver @@ -35,6 +37,7 @@ class DefaultWalletAccountsFetcherTest { private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() private val accountsResponseStore: AccountsResponseStore = mockk() private val accountsResponseStoreFlow = MutableStateFlow(value = null) + private val tokensMigration: DefaultMainAccountTokensMigration = mockk(relaxed = true) private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler = mockk(relaxUnitFun = true) @@ -49,16 +52,19 @@ class DefaultWalletAccountsFetcherTest { defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory, eTagsStore = eTagsStore, dispatchers = TestingCoroutineDispatcherProvider(), + mainAccountTokensMigration = tokensMigration, ) private val userWalletId = UserWalletId("011") private val eTag = "etag" + private val migratedAccountsResponse = createGetWalletAccountsResponse(userWalletId) @BeforeAll fun setUp() { every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore every { accountsResponseStore.data } returns accountsResponseStoreFlow + coEvery { tokensMigration.migrate(userWalletId) } returns migratedAccountsResponse.right() coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag } @@ -134,6 +140,7 @@ class DefaultWalletAccountsFetcherTest { accountsResponseStore.updateData(any()) accountsResponseStoreFactory.create(userWalletId = userWalletId) accountsResponseStore.updateData(any()) + tokensMigration.migrate(userWalletId) } coVerify(inverse = true) { @@ -181,6 +188,7 @@ class DefaultWalletAccountsFetcherTest { eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = newETag) accountsResponseStoreFactory.create(userWalletId = userWalletId) accountsResponseStore.updateData(any()) + tokensMigration.migrate(userWalletId) } coVerify(inverse = true) { @@ -236,6 +244,7 @@ class DefaultWalletAccountsFetcherTest { pushWalletAccounts = any(), storeWalletAccounts = any(), ) + tokensMigration.migrate(userWalletId) } coVerify(inverse = true) { diff --git a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsExpandedRepositoryTest.kt b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsExpandedRepositoryTest.kt new file mode 100644 index 0000000000..78c1e93de4 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsExpandedRepositoryTest.kt @@ -0,0 +1,125 @@ +package com.tangem.data.account.repository + +import app.cash.turbine.test +import com.google.common.truth.Truth +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.domain.account.models.AccountExpandedState +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultAccountsExpandedRepositoryTest { + + private val walletId = UserWalletId("011") + private val mainAccountId = AccountId.forMainCryptoPortfolio(walletId) + private val secondAccountId = AccountId.forCryptoPortfolio(walletId, DerivationIndex(1).getOrNull()!!) + + @Test + fun `expandedAccounts emits updated state when store changes`() = runTest { + val dataStore = MockStateDataStore>>( + default = emptyMap() + ) + + val repository = DefaultAccountsExpandedRepository(dataStore) + + repository.expandedAccounts.test { + // initial emission + val initial = awaitItem() + Truth.assertThat(initial.isEmpty()).isTrue() + + // update store + dataStore.updateData { + mapOf( + walletId.stringValue to setOf( + AccountsExpandedDTO( + accountId = mainAccountId.value, + isExpanded = true + ) + ) + ) + } + + // next emission + val updated = awaitItem() + val states = updated[walletId]!! + + Truth.assertThat(states.size).isEqualTo(1) + val state = states.first() + + Truth.assertThat(state.accountId).isEqualTo(mainAccountId) + Truth.assertThat(state.isExpanded).isTrue() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `expandedAccounts emits when update is called`() = runTest { + val dataStore = MockStateDataStore>>( + default = emptyMap() + ) + + val repository = DefaultAccountsExpandedRepository(dataStore) + + val state = AccountExpandedState( + accountId = mainAccountId, + isExpanded = true + ) + + repository.expandedAccounts.test { + // initial + awaitItem() + + // when + repository.update(state) + + // then + val updated = awaitItem() + val states = updated[walletId]!! + + Truth.assertThat(states.size).isEqualTo(1) + Truth.assertThat(states.first().isExpanded).isTrue() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `expandedAccounts emits synced state after syncStore`() = runTest { + val dataStore = MockStateDataStore( + mapOf( + walletId.stringValue to setOf( + AccountsExpandedDTO(mainAccountId.value, true), + AccountsExpandedDTO(secondAccountId.value, false) + ) + ) + ) + + val repository = DefaultAccountsExpandedRepository(dataStore) + + repository.expandedAccounts.test { + // initial + val initial = awaitItem() + Truth.assertThat(initial[walletId]!!.size).isEqualTo(2) + + // when + repository.syncStore( + walletId = walletId, + existAccounts = setOf(mainAccountId) // without secondAccountId + ) + + // then + val synced = awaitItem() + val states = synced[walletId]!! + + Truth.assertThat(states.size).isEqualTo(1) + Truth.assertThat(states.first().accountId.value).isEqualTo(mainAccountId.value) + + cancelAndIgnoreRemainingEvents() + } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt b/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt index 025b7d07bd..e4d0df9606 100644 --- a/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt @@ -1,5 +1,6 @@ package com.tangem.data.account.token +import arrow.core.right import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.converter.createWalletAccountDTO import com.tangem.data.account.store.AccountsResponseStore @@ -13,6 +14,7 @@ import com.tangem.datasource.local.accounts.AccountTokenMigrationStore import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.assertEither import com.tangem.test.core.assertEitherLeft import com.tangem.test.core.assertEitherRight import io.mockk.* @@ -214,6 +216,212 @@ class DefaultMainAccountTokensMigrationTest { } } + @Test + fun `migrate updates tokens for all accounts`() = runTest { + // Arrange + val unassignedToken1 = createBitcoin(accountIndex = 1) + val unassignedToken2 = createBitcoin(accountIndex = 2) + + val derivationIndex1 = DerivationIndex(1).getOrNull()!! + val derivationIndex2 = DerivationIndex(2).getOrNull()!! + + val mainAccount = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value, + derivationIndex = DerivationIndex.Main.value, + tokens = listOf( + createBitcoin(accountIndex = 0), + unassignedToken1, + unassignedToken2, + ), + ) + + val account1 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex1).value, + derivationIndex = derivationIndex1.value, + tokens = emptyList(), + ) + + val account2 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex2).value, + derivationIndex = derivationIndex2.value, + tokens = emptyList(), + ) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 3, + totalArchivedAccounts = 0, + ), + accounts = listOf(mainAccount, account1, account2), + unassignedTokens = emptyList(), + ) + + accountsResponseStoreFlow.value = response + + coEvery { accountsResponseStore.updateData(any()) } returns mockk() + + // Act + val actual = migration.migrate(userWalletId) + + val migratedResponse = response.copy( + accounts = listOf( + mainAccount.copy(tokens = mainAccount.tokens!! - unassignedToken1 - unassignedToken2), + account1.copy(tokens = listOf(unassignedToken1)), + account2.copy(tokens = listOf(unassignedToken2)), + ), + ) + + // Assert + assertEither(actual, migratedResponse.right()) + + coVerifySequence { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + accountsResponseStore.updateData(any()) + userTokensSaver.pushWithRetryer( + userWalletId = userWalletId, + response = migratedResponse.toUserTokensResponse(), + onFailSend = any(), + ) + } + } + + @Test + fun `migrate updates tokens only for one account`() = runTest { + // Arrange + val unassignedToken1 = createBitcoin(accountIndex = 1) + + val derivationIndex1 = DerivationIndex(1).getOrNull()!! + val derivationIndex2 = DerivationIndex(2).getOrNull()!! + + val mainAccount = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value, + derivationIndex = DerivationIndex.Main.value, + tokens = listOf( + createBitcoin(accountIndex = 0), + unassignedToken1, + ), + ) + + val account1 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex1).value, + derivationIndex = derivationIndex1.value, + tokens = emptyList(), + ) + + val account2 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex2).value, + derivationIndex = derivationIndex2.value, + tokens = emptyList(), + ) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 3, + totalArchivedAccounts = 0, + ), + accounts = listOf(mainAccount, account1, account2), + unassignedTokens = emptyList(), + ) + + accountsResponseStoreFlow.value = response + + coEvery { accountsResponseStore.updateData(any()) } returns mockk() + + // Act + val actual = migration.migrate(userWalletId) + + val migratedResponse = response.copy( + accounts = listOf( + mainAccount.copy(tokens = mainAccount.tokens!! - unassignedToken1), + account1.copy(tokens = listOf(unassignedToken1)), + account2, + ), + ) + + // Assert + assertEither(actual, migratedResponse.right()) + + coVerifySequence { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + accountsResponseStore.updateData(any()) + userTokensSaver.pushWithRetryer( + userWalletId = userWalletId, + response = migratedResponse.toUserTokensResponse(), + onFailSend = any(), + ) + } + } + + @Test + fun `migrate all skips when no unassigned tokens`() = runTest { + // Arrange + val derivationIndex1 = DerivationIndex(1).getOrNull()!! + val derivationIndex2 = DerivationIndex(2).getOrNull()!! + + val mainAccount = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value, + derivationIndex = DerivationIndex.Main.value, + tokens = listOf( + createBitcoin(accountIndex = 0), + ), + ) + + val account1 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex1).value, + derivationIndex = derivationIndex1.value, + tokens = emptyList(), + ) + + val account2 = createWalletAccountDTO( + userWalletId = userWalletId, + accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex2).value, + derivationIndex = derivationIndex2.value, + tokens = emptyList(), + ) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 3, + totalArchivedAccounts = 0, + ), + accounts = listOf(mainAccount, account1, account2), + unassignedTokens = emptyList(), + ) + + accountsResponseStoreFlow.value = response + + // Act + val actual = migration.migrate(userWalletId) + + // Assert + assertEither(actual, response.right()) + + coVerifySequence { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + coVerify(inverse = true) { + userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any()) + } + } + private fun createBitcoin(accountIndex: Int): UserTokensResponse.Token { return UserTokensResponse.Token( id = "ne", diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt index eccf6e4952..06bc2f1e85 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt @@ -127,7 +127,7 @@ class ResponseCryptoCurrenciesFactory @Inject constructor( } } - private fun createToken(blockchain: Blockchain, sdkToken: Token, network: Network): CryptoCurrency.Token { + fun createToken(blockchain: Blockchain, sdkToken: Token, network: Network): CryptoCurrency.Token { val id = getTokenId(network, sdkToken) return CryptoCurrency.Token( diff --git a/data/express/src/main/java/com/tangem/data/express/converter/ExpressProviderConverter.kt b/data/express/src/main/java/com/tangem/data/express/converter/ExpressProviderConverter.kt index 36c5e88460..09a2f55b15 100644 --- a/data/express/src/main/java/com/tangem/data/express/converter/ExpressProviderConverter.kt +++ b/data/express/src/main/java/com/tangem/data/express/converter/ExpressProviderConverter.kt @@ -19,6 +19,7 @@ internal class ExpressProviderConverter : Converter - - - - BooleanPropertyNaming:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository.<no name provided>$val last = res.tokens.size < request.limit - MultilineLambdaItParameter:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository${ val error = it as QuotesFetcher.Error.ApiOperationError val errorEvent = createDetailsErrorEvent( error = error.apiError, request = MarketsDataAnalyticsEvent.Details.Error.Request.Info, tokenSymbol = tokenSymbol, ) analyticsEventHandler.send(errorEvent.toEvent()) throw error.apiError } - MultilineLambdaItParameter:MarketsBatchUpdateFetcher.kt$MarketsBatchUpdateFetcher${ it.copy( tokenCharts = TokenMarketChartsConverter.convert( chartsToCopy = it.tokenCharts, tokenId = it.id, interval = updateRequest.interval, value = update, ), ) } - MultilineLambdaItParameter:MarketsBatchUpdateFetcher.kt$MarketsBatchUpdateFetcher${ val exception = if (it is QuotesFetcher.Error.ApiOperationError) { onApiResponseError(it.apiError) it.apiError } else { error("Cause: $it") } throw exception } - MultilineLambdaItParameter:TokenMarketInfoConverter.kt$TokenMarketInfoConverter${ TokenMarketInfo.Link( title = it.title, id = it.id, link = it.link, ) } - NullableBooleanCheck:TokenMarketListConverter.kt$TokenMarketListConverter$token.isUnderMarketCapLimit ?: false - SuspendFunWithFlowReturnType:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository$suspend - UnsafeCallOnNullableType:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository$network.contractAddress!! - UseEmptyCounterpart:MarketsDataAnalyticsEvent.kt$MarketsDataAnalyticsEvent.Details$mapOf() - UseEmptyCounterpart:MarketsDataAnalyticsEvent.kt$MarketsDataAnalyticsEvent.List$mapOf() - - diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index 2f974d4f31..9c1b3be3f1 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt @@ -90,14 +90,15 @@ internal class DefaultMarketsTokenRepository( requestTimeStamp.set(res.timestamp ?: 0) } - val last = res.tokens.size < request.limit + val isLast = res.tokens.size < request.limit val tokenMarketListWithMaxApy = TokenMarketListConverter.convert(res) return BatchFetchResult.Success( data = tokenMarketListWithMaxApy.tokens, - last = last, + last = isLast, empty = res.tokens.isEmpty(), + total = res.total, ) } }, @@ -227,8 +228,8 @@ internal class DefaultMarketsTokenRepository( currencyId = tokenId.value, field = QuotesFetcher.Field.ALL_PRICES, ) - .getOrElse { - val error = it as QuotesFetcher.Error.ApiOperationError + .getOrElse { fetchError -> + val error = fetchError as QuotesFetcher.Error.ApiOperationError val errorEvent = createDetailsErrorEvent( error = error.apiError, @@ -274,7 +275,7 @@ internal class DefaultMarketsTokenRepository( name = token.name, symbol = token.symbol, decimals = network.decimalCount ?: error("Unknown decimal"), - contractAddress = network.contractAddress!!, + contractAddress = requireNotNull(network.contractAddress) { "Contract address is required for token" }, ) } } diff --git a/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt b/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt index 82ab261728..11a6852e47 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt @@ -79,13 +79,13 @@ internal class MarketsBatchUpdateFetcher( currenciesIds = currenciesIds, fields = setOf(QuotesFetcher.Field.ALL_PRICES), ) - .getOrElse { - val exception = if (it is QuotesFetcher.Error.ApiOperationError) { - onApiResponseError(it.apiError) + .getOrElse { error -> + val exception = if (error is QuotesFetcher.Error.ApiOperationError) { + onApiResponseError(error.apiError) - it.apiError + error.apiError } else { - error("Cause: $it") + error("Cause: $error") } throw exception @@ -116,11 +116,11 @@ internal class MarketsBatchUpdateFetcher( Batch( key = batchToUpdate.key, - data = batchToUpdate.data.map { - it.copy( + data = batchToUpdate.data.map { tokenMarket -> + tokenMarket.copy( tokenCharts = TokenMarketChartsConverter.convert( - chartsToCopy = it.tokenCharts, - tokenId = it.id, + chartsToCopy = tokenMarket.tokenCharts, + tokenId = tokenMarket.id, interval = updateRequest.interval, value = update, ), diff --git a/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt b/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt index 5d04e7511f..56fef028a1 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/analytics/MarketsDataAnalyticsEvent.kt @@ -2,12 +2,13 @@ package com.tangem.data.markets.analytics import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR sealed interface MarketsDataAnalyticsEvent { sealed class List( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent(category = "Markets", event = event, params = params), MarketsDataAnalyticsEvent { data class Error( @@ -26,7 +27,7 @@ sealed interface MarketsDataAnalyticsEvent { sealed class Details( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params), MarketsDataAnalyticsEvent { data class Error( @@ -82,8 +83,4 @@ sealed interface MarketsDataAnalyticsEvent { Custom("Custom"), Unknown("Unknown"), } - - private companion object { - const val IS_NOT_HTTP_ERROR = "Is not http error" - } } \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt index a00841547c..a1044051b9 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt @@ -62,7 +62,7 @@ internal class TokenMarketInfoConverter( network.contractAddress.isNullOrEmpty() -> { TokenMarketInfo.Network( networkId = network.networkId, - exchangeable = network.exchangeable, + isExchangeable = network.exchangeable, contractAddress = network.contractAddress, decimalCount = network.decimalCount, ) @@ -71,7 +71,7 @@ internal class TokenMarketInfoConverter( blockchain.canHandleTokens() -> { TokenMarketInfo.Network( networkId = network.networkId, - exchangeable = network.exchangeable, + isExchangeable = network.exchangeable, contractAddress = blockchain.reformatContractAddress(network.contractAddress), decimalCount = network.decimalCount, ) @@ -147,11 +147,11 @@ internal class TokenMarketInfoConverter( @JvmName("convertLink") private fun List.convert(): List { - return map { + return map { link -> TokenMarketInfo.Link( - title = it.title, - id = it.id, - link = it.link, + title = link.title, + id = link.id, + link = link.link, ) } } diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt index ed6cb59dc4..f43e7ddae1 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt @@ -31,7 +31,7 @@ internal object TokenMarketListConverter : Converter) { + if (currencies.isEmpty()) { + Timber.d("No currencies to clear for wallet: $userWalletId") + return + } + withContext(dispatchers.default) { val (networks, tokens) = currencies.partitionByType() - coroutineScope { - launch { cleanStore(userWalletId = userWalletId, networks = networks) } - launch { cleanWalletManager(userWalletId = userWalletId, networks = networks, tokens = tokens) } - } + awaitAll( + async { clearStatusesStore(userWalletId = userWalletId, networks = networks) }, + async { clearBlockchainSDK(userWalletId = userWalletId, networks = networks, tokens = tokens) }, + ) } } - private suspend fun cleanStore(userWalletId: UserWalletId, networks: Set) { + private suspend fun clearStatusesStore(userWalletId: UserWalletId, networks: Set) { if (networks.isNotEmpty()) { - networksStatusesStore.clear(userWalletId = userWalletId, networks = networks) + runSuspendCatching { + networksStatusesStore.clear(userWalletId = userWalletId, networks = networks) + } + .onFailure { Timber.e(it, "Failed to clear network statuses for wallet: $userWalletId") } } } - private suspend fun cleanWalletManager( + private suspend fun clearBlockchainSDK( userWalletId: UserWalletId, networks: Set, tokens: Set, ) { if (networks.isNotEmpty()) { - walletManagersFacade.remove(userWalletId = userWalletId, networks = networks) + runSuspendCatching { + walletManagersFacade.remove(userWalletId = userWalletId, networks = networks) + } + .onFailure { + Timber.e(it, "Failed to remove networks from Blockchain SDK for wallet: $userWalletId") + } } if (tokens.isNotEmpty()) { - walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = tokens) + runSuspendCatching { + walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = tokens) + } + .onFailure { + Timber.e(it, "Failed to remove tokens from Blockchain SDK for wallet: $userWalletId") + } } } diff --git a/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt b/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt index c2913c5f5a..5e7d1d6083 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt @@ -7,6 +7,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks +import io.mockk.coEvery import io.mockk.coVerifyOrder import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -45,7 +46,7 @@ class DefaultNetworksCleanerTest { // Assert coVerifyOrder { - networksStatusesStore.clear(userWalletId, setOf(network)) + networksStatusesStore.clear(userWalletId = userWalletId, networks = setOf(network)) walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network)) walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token)) } @@ -66,12 +67,15 @@ class DefaultNetworksCleanerTest { @Test fun `should clear only networks when there are no tokens`() = runTest { + // Arrange val currencies = listOf(coin) + // Act cleaner(userWalletId = userWalletId, currencies = currencies) + // Assert coVerifyOrder { - networksStatusesStore.clear(userWalletId, setOf(network)) + networksStatusesStore.clear(userWalletId = userWalletId, networks = setOf(network)) walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network)) } @@ -98,4 +102,27 @@ class DefaultNetworksCleanerTest { walletManagersFacade.remove(userWalletId = any(), networks = any()) } } + + @Test + fun `should handle exception during cleaning`() = runTest { + // Arrange + val currencies = listOf(coin, token) + + val exception = Exception("Test exception") + coEvery { networksStatusesStore.clear(userWalletId, setOf(network)) } throws exception + coEvery { walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network)) } throws exception + coEvery { + walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token)) + } throws exception + + // Act + cleaner(userWalletId = userWalletId, currencies = currencies) + + // Assert + coVerifyOrder { + networksStatusesStore.clear(userWalletId = userWalletId, networks = setOf(network)) + walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network)) + walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token)) + } + } } \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/DefaultNewsErrorResolver.kt b/data/news/src/main/java/com/tangem/data/news/DefaultNewsErrorResolver.kt new file mode 100644 index 0000000000..3d35ed1f4a --- /dev/null +++ b/data/news/src/main/java/com/tangem/data/news/DefaultNewsErrorResolver.kt @@ -0,0 +1,22 @@ +package com.tangem.data.news + +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.domain.models.news.NewsError +import com.tangem.domain.news.NewsErrorResolver + +internal class DefaultNewsErrorResolver : NewsErrorResolver { + + override fun resolve(throwable: Throwable?): NewsError { + return when (throwable) { + is ApiResponseError.HttpException -> { + NewsError.HttpError( + code = throwable.code.numericCode, + message = throwable.message.orEmpty(), + ) + } + else -> { + NewsError.NotHttpError + } + } + } +} \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt b/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt index 9a5aa274f1..530884bfcf 100644 --- a/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt +++ b/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt @@ -1,9 +1,13 @@ package com.tangem.data.news.di +import com.tangem.data.news.DefaultNewsErrorResolver import com.tangem.data.news.repository.DefaultNewsRepository import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.local.news.details.NewsDetailsStore +import com.tangem.datasource.local.news.liked.NewsLikedStore import com.tangem.datasource.local.news.trending.TrendingNewsStore +import com.tangem.datasource.local.news.viewed.NewsViewedStore +import com.tangem.domain.news.NewsErrorResolver import com.tangem.domain.news.repository.NewsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -23,12 +27,21 @@ internal object NewsDataModule { dispatchers: CoroutineDispatcherProvider, newsDetailsStore: NewsDetailsStore, trendingNewsStore: TrendingNewsStore, + newsViewedStore: NewsViewedStore, + newsLikedStore: NewsLikedStore, + newsErrorResolver: NewsErrorResolver, ): NewsRepository { return DefaultNewsRepository( newsApi = newsApi, dispatchers = dispatchers, newsDetailsStore = newsDetailsStore, trendingNewsStore = trendingNewsStore, + newsViewedStore = newsViewedStore, + newsLikedStore = newsLikedStore, + newsErrorResolver = newsErrorResolver, ) } + + @Provides + fun provideNewsErrorResolver(): NewsErrorResolver = DefaultNewsErrorResolver() } \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt index 09cc6015b6..ec3ad96779 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -1,115 +1,177 @@ package com.tangem.data.news.repository +import arrow.core.Either +import arrow.core.flatten +import arrow.core.left +import arrow.core.right import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.fold import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.api.news.models.response.NewsTrendingResponse import com.tangem.datasource.local.news.details.NewsDetailsStore +import com.tangem.datasource.local.news.liked.NewsLikedStore import com.tangem.datasource.local.news.trending.TrendingNewsStore -import com.tangem.domain.models.news.* +import com.tangem.datasource.local.news.viewed.NewsViewedStore +import com.tangem.domain.models.news.ArticleCategory +import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.models.news.TrendingNews +import com.tangem.domain.news.NewsErrorResolver import com.tangem.domain.news.model.NewsListBatchFlow import com.tangem.domain.news.model.NewsListBatchingContext import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.repository.NewsRepository -import com.tangem.pagination.BatchFetchResult -import com.tangem.pagination.BatchListSource +import com.tangem.pagination.* import com.tangem.pagination.exception.EndOfPaginationException import com.tangem.pagination.fetcher.BatchFetcher -import com.tangem.pagination.toBatchFlow import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runSuspendCatching -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* import timber.log.Timber /** * Implementation of [NewsRepository]. [REDACTED_AUTHOR] */ +@Suppress("LongParameterList") internal class DefaultNewsRepository( private val newsApi: NewsApi, private val dispatchers: CoroutineDispatcherProvider, private val newsDetailsStore: NewsDetailsStore, private val trendingNewsStore: TrendingNewsStore, + private val newsViewedStore: NewsViewedStore, + private val newsLikedStore: NewsLikedStore, + private val newsErrorResolver: NewsErrorResolver, ) : NewsRepository { override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow { - return BatchListSource( + val newsBatchFlow = BatchListSource( fetchDispatcher = dispatchers.io, context = context, generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: INITIAL_BATCH_KEY }, batchFetcher = createBatchFetcher(batchSize), ).toBatchFlow() + + return updateViewedStatusForNewsBatch(newsBatchFlow, context.coroutineScope) } - override suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle { - val cached = newsDetailsStore.getSyncOrNull(newsId) - if (cached != null) return cached + override fun getNews(config: NewsListConfig, limit: Int): Flow> { + return flow { + val items = newsApi.getNews( + page = FIRST_PAGE, + limit = limit, + language = config.language, + snapshot = config.snapshot, + tokenIds = config.tokenIds.takeIf { it.isNotEmpty() }, + categoryIds = config.categoryIds.takeIf { it.isNotEmpty() }, + ).fold( + onSuccess = { response -> + response.items + }, + onError = { error -> + Timber.e(error, "Failed to get list of news") + emptyList() + }, + ) - fetchDetailedArticlesInternal(newsIds = listOf(newsId), language = language) - - return requireNotNull(newsDetailsStore.getSyncOrNull(newsId)) { - "Unable to load detailed article with id=$newsId" + val shortArticles = items.map { it.toDomainShortArticle() } + emit(shortArticles) } + .flowOn(dispatchers.io) + .combine(newsViewedStore.getAll()) { articlesToUpdate, viewedFlags -> + articlesToUpdate.map { article -> + val isViewed = viewedFlags[article.id] == true + article.copy(viewed = isViewed) + }.sortedBy { it.viewed } + } } override fun observeDetailedArticles(): Flow> { - return newsDetailsStore.getAll().map { articles -> - articles.associateBy(DetailedArticle::id) - } + return newsDetailsStore.getAll() + .combine(newsLikedStore.getAll()) { articles, likedFlags -> + articles.map { article -> + article.copy( + isLiked = likedFlags[article.id] == true, + ) + } + } + .map { articles -> + articles.associateBy(DetailedArticle::id) + } } - override suspend fun fetchDetailedArticles(newsIds: Collection, language: String?) { - fetchDetailedArticlesInternal(newsIds = newsIds, language = language) - } + override suspend fun fetchDetailedArticles( + newsIds: Collection, + language: String?, + ): Either, Unit> = fetchDetailedArticlesInternal(newsIds = newsIds, language = language) override suspend fun fetchTrendingNews(limit: Int, language: String?) { fetchAndStoreTrendingNews(limit = limit, language = language) } override fun observeTrendingNews(): Flow { - return trendingNewsStore.get(TRENDING_NEWS_KEY) - } - - override suspend fun updateTrendingNewsViewed(articleIds: Collection, viewed: Boolean) { - if (articleIds.isEmpty()) return - - val currentResult = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY) ?: return - val currentArticles = when (currentResult) { - is TrendingNews.Data -> currentResult.articles - is TrendingNews.Error -> return - } - if (currentArticles.isEmpty()) return - - val ids = articleIds.toSet() - val updated = currentArticles.map { article -> - if (article.id in ids) { - article.copy(viewed = viewed) - } else { - article + return combine( + trendingNewsStore.get(TRENDING_NEWS_KEY), + newsViewedStore.getAll(), + ) { trendingNews, viewedFlags -> + when (trendingNews) { + is TrendingNews.Data -> { + val articlesWithViewedFlags = trendingNews.articles + .map { article -> + article.copy(viewed = viewedFlags[article.id] == true) + } + .sortedBy { it.viewed } + TrendingNews.Data(articlesWithViewedFlags) + } + is TrendingNews.Error -> trendingNews } } - - trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(updated)) } override suspend fun getCategories(): List { - return newsApi.getCategories().getOrThrow().items.map { dto -> - ArticleCategory( - id = dto.id, - name = dto.name, - ) + return withContext(dispatchers.io) { + newsApi.getCategories().getOrThrow().items.map { dto -> + ArticleCategory( + id = dto.id, + name = dto.name, + ) + } } } - private suspend fun fetchDetailedArticlesInternal(newsIds: Collection, language: String?) = + override suspend fun updateNewsViewed(articleIds: Collection, viewed: Boolean) { + newsViewedStore.updateViewed(articleIds, viewed) + } + + private suspend fun isNewsLiked(articleId: Int): Boolean { + return newsLikedStore.getSync()[articleId] == true + } + + override suspend fun toggleNewsLiked(articleId: Int) = withContext(dispatchers.io) { + val isNewsLikedValue = !isNewsLiked(articleId) + newsLikedStore.updateLiked(listOf(articleId), isNewsLikedValue) + } + + private fun updateViewedStatusForNewsBatch( + newsBatchFlow: NewsListBatchFlow, + scope: CoroutineScope, + ): NewsListBatchFlow { + return NewsBatchFlowWithViewedStatus( + upstream = newsBatchFlow, + newsViewedStore = newsViewedStore, + scope = scope, + ) + } + + private suspend fun fetchDetailedArticlesInternal( + newsIds: Collection, + language: String?, + ): Either, Unit> = Either.catch { withContext(dispatchers.io) { - if (newsIds.isEmpty()) return@withContext + if (newsIds.isEmpty()) return@withContext Unit.right() val uniqueIds = newsIds.distinct() val idsToFetch = buildList { @@ -119,34 +181,51 @@ internal class DefaultNewsRepository( } } - if (idsToFetch.isEmpty()) return@withContext + if (idsToFetch.isEmpty()) return@withContext Unit.right() - val fetchedArticles = coroutineScope { + val fetchedArticles = supervisorScope { idsToFetch.map { newsId -> async { - newsApi.getNewsDetails(newsId = newsId, language = language) - .getOrThrow() - .toDomainDetailedArticle() + Either.catch { + newsApi.getNewsDetails(newsId = newsId, language = language) + .getOrThrow() + .toDomainDetailedArticle( + isLiked = isNewsLiked(newsId), + ) + }.mapLeft { + newsId to it + } } }.awaitAll() } - if (fetchedArticles.isNotEmpty()) { - newsDetailsStore.store( - articles = fetchedArticles.associateBy(DetailedArticle::id), - ) - } + fetchedArticles + .filterIsInstance>() + .map { it.value } + .let { articles -> + if (articles.isNotEmpty()) { + newsDetailsStore.store( + articles = articles.associateBy(DetailedArticle::id), + ) + } + } + + val errors = fetchedArticles + .filterIsInstance>>() + .associate { it.value.first to it.value.second } + + if (errors.isEmpty()) Unit.right() else errors.left() } + }.mapLeft { t -> mapOf(GLOBAL_ERROR_ID to t) }.flatten() private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?) { return withContext(dispatchers.io) { - val apiResponse = newsApi.getTrendingNews(limit = limit, language = language) - when (val result = apiResponse) { + when (val apiResponse = newsApi.getTrendingNews(limit = limit, language = language)) { is ApiResponse.Error -> { Timber.e( - result.cause.cause, + apiResponse.cause.cause, "Trending news fetch failed cause: ${ - when (val error = result.cause) { + when (val error = apiResponse.cause) { is ApiResponseError.HttpException -> error.code is ApiResponseError.NetworkException -> "NetworkException" is ApiResponseError.TimeoutException -> "TimeoutException" @@ -157,51 +236,31 @@ internal class DefaultNewsRepository( trendingNewsStore.clear() trendingNewsStore.store( key = TRENDING_NEWS_KEY, - value = TrendingNews.Error( - NewsError.Unknown( - message = result.cause.message, - code = null, - ), - ), + value = TrendingNews.Error(error = newsErrorResolver.resolve(apiResponse.cause.cause)), ) } is ApiResponse.Success -> { - val freshArticles = result.data.items.map { it.toDomainShortArticle() } - val cachedArticles = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY) - val currentArticles = when (cachedArticles) { - is TrendingNews.Data -> cachedArticles.articles - is TrendingNews.Error -> emptyList() - null -> emptyList() - } - val merged = mergeTrendingArticles(current = currentArticles, fresh = freshArticles).take(limit) - trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(merged)) - TrendingNews.Data(merged) + val freshArticles = apiResponse.data.items.map { it.toDomainShortArticle() } + val articles = freshArticles.take(limit) + trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(articles)) + TrendingNews.Data(articles) } } } } - private fun mergeTrendingArticles(current: List, fresh: List): List { - if (current.isEmpty()) return fresh - - val currentById = current.associateBy(ShortArticle::id) - - return fresh.map { article -> - val stored = currentById[article.id] ?: return@map article - article.copy(viewed = stored.viewed) - } - } - private fun createBatchFetcher(batchSize: Int): BatchFetcher> { return NewsBatchFetcher( newsApi = newsApi, batchSize = batchSize, + newsViewedStore = newsViewedStore, ) } private class NewsBatchFetcher( private val newsApi: NewsApi, private val batchSize: Int, + private val newsViewedStore: NewsViewedStore, ) : BatchFetcher> { private var state: NewsPaginationState? = null @@ -266,12 +325,18 @@ internal class DefaultNewsRepository( page = page, limit = limit, language = params.language, - snapshot = snapshotOverride, + snapshot = snapshotOverride?.takeIf { it.isNotEmpty() }, tokenIds = params.tokenIds.takeIf { it.isNotEmpty() }, categoryIds = params.categoryIds.takeIf { it.isNotEmpty() }, ).getOrThrow() - val items = response.items.map { it.toDomainShortArticle() } + val articles = response.items.map { it.toDomainShortArticle() } + val viewedFlags = newsViewedStore.getSync() + + val items = articles.map { article -> + val isViewed = viewedFlags[article.id] == true + article.copy(viewed = isViewed) + } val batchResult = BatchFetchResult.Success( data = items, @@ -301,9 +366,41 @@ internal class DefaultNewsRepository( val params: NewsListConfig, ) + private class NewsBatchFlowWithViewedStatus( + private val upstream: NewsListBatchFlow, + newsViewedStore: NewsViewedStore, + scope: CoroutineScope, + ) : NewsListBatchFlow { + + override val state: StateFlow>> = combine( + upstream.state, + newsViewedStore.getAll(), + ) { batchListState, viewedFlags -> + val updatedBatches = batchListState.data.map { batch -> + val updatedArticles = batch.data.map { article -> + val isViewed = viewedFlags[article.id] == true + article.copy(viewed = isViewed) + } + Batch(key = batch.key, data = updatedArticles) + } + BatchListState( + data = updatedBatches, + status = batchListState.status, + ) + }.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = BatchListState(emptyList(), upstream.state.value.status), + ) + + override val updateResults: SharedFlow>>> + get() = upstream.updateResults + } + private companion object { private const val INITIAL_BATCH_KEY = 0 private const val FIRST_PAGE = 1 private const val TRENDING_NEWS_KEY = "trending_news" + private const val GLOBAL_ERROR_ID = -1 } } \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt b/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt index 6f34dd3ed4..50a571f289 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt @@ -4,13 +4,9 @@ import com.tangem.datasource.api.news.models.response.NewsArticleDto import com.tangem.datasource.api.news.models.response.NewsDetailsResponse import com.tangem.datasource.api.news.models.response.NewsOriginalArticleDto import com.tangem.datasource.api.news.models.response.NewsRelatedTokenDto -import com.tangem.domain.models.news.ArticleCategory -import com.tangem.domain.models.news.DetailedArticle -import com.tangem.domain.models.news.OriginalArticle -import com.tangem.domain.models.news.RelatedToken -import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.models.news.* -internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle { +internal fun NewsDetailsResponse.toDomainDetailedArticle(isLiked: Boolean): DetailedArticle { return DetailedArticle( id = id, createdAt = createdAt, @@ -24,6 +20,7 @@ internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle { shortContent = shortContent, content = content, originalArticles = originalArticles.map { it.toDomainOriginalArticle() }, + isLiked = isLiked, ) } @@ -54,7 +51,10 @@ internal fun NewsOriginalArticleDto.toDomainOriginalArticle(): OriginalArticle { return OriginalArticle( id = id, title = title, - sourceName = sourceName, + source = Source( + id = source.id, + name = source.name, + ), locale = language, publishedAt = publishedAt, url = url, diff --git a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt index af884a8963..851849db2a 100644 --- a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt +++ b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt @@ -38,6 +38,8 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import timber.log.Timber import java.util.concurrent.ConcurrentHashMap @@ -63,6 +65,7 @@ internal class DefaultNFTRepository @Inject constructor( private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains) private val nftRuntimeStores = ConcurrentHashMap() + private val nftRuntimeStoresMutex = Mutex() private val nftPersistenceStores = ConcurrentHashMap() private val collectionIdConverter = NFTSdkCollectionIdentifierConverter @@ -426,16 +429,17 @@ internal class DefaultNFTRepository @Inject constructor( private suspend fun getNFTRuntimeStore(userWalletId: UserWalletId, network: Network): NFTRuntimeStore { val storeId = (userWalletId to network).formatted() - return nftRuntimeStores.getOrPut(storeId) { - nftRuntimeStoreFactory.provide(network).also { - nftRuntimeStores[storeId] = it - val storedCollections = getStoredCollections(userWalletId, network) - val storedPrices = getStoredPrices(userWalletId, network) - it.initialize( - collections = storedCollections, - prices = storedPrices, - ) - } + return nftRuntimeStoresMutex.withLock { + nftRuntimeStores[storeId]?.let { return it } + val store = nftRuntimeStoreFactory.provide(network) + val storedCollections = getStoredCollections(userWalletId, network) + val storedPrices = getStoredPrices(userWalletId, network) + store.initialize( + collections = storedCollections, + prices = storedPrices, + ) + nftRuntimeStores[storeId] = store + store } } diff --git a/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..8752d83b0e 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -2,21 +2,30 @@ package com.tangem.data.staking import arrow.core.Either import arrow.core.getOrElse +import arrow.core.raise.Raise import arrow.core.raise.either import arrow.core.raise.ensure -import com.tangem.data.staking.converters.ethpool.* +import com.tangem.data.staking.converters.ethpool.P2PEthPoolBroadcastResultConverter +import com.tangem.data.staking.converters.ethpool.P2PEthPoolErrorConverter +import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingAccountConverter +import com.tangem.data.staking.converters.ethpool.P2PEthPoolUnsignedTxConverter +import com.tangem.data.staking.converters.ethpool.P2PEthPoolVaultConverter import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.ethpool.P2PEthPoolApi import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest -import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolDepositRequest -import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolUnstakeRequest -import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolWithdrawRequest +import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolTransactionRequest +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolTransactionResponse import com.tangem.datasource.local.token.P2PEthPoolVaultsStore +import com.tangem.domain.models.staking.P2PEthPoolStakingAccount import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingOption -import com.tangem.domain.staking.model.ethpool.* -import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult +import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork +import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx +import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.repositories.P2PEthPoolRepository import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow @@ -26,25 +35,43 @@ 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, ) : P2PEthPoolRepository { private val vaultConverter = P2PEthPoolVaultConverter - private val accountInfoConverter = P2PEthPoolAccountConverter - private val rewardConverter = P2PEthPoolRewardConverter + private val accountConverter = P2PEthPoolStakingAccountConverter private val broadcastResultConverter = P2PEthPoolBroadcastResultConverter private val errorConverter = P2PEthPoolErrorConverter + /** + * Handles P2PEthPool API response with error checking and result extraction. + * Reduces duplication across all API call methods. + */ + private inline fun Raise.handleApiResponse( + response: ApiResponse>, + transform: (T) -> R, + ): R = when (response) { + is ApiResponse.Success -> { + val data = response.data + ensure(data.error == null) { + errorConverter.convertFromErrorDetails(requireNotNull(data.error)) + } + val result = requireNotNull(data.result) { "Result is null in successful response" } + transform(result) + } + is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + } + override suspend fun fetchVaults(network: P2PEthPoolNetwork) { val vaults = if (stakingFeatureToggles.isEthStakingEnabled) { getVaults(network).getOrElse { error -> - Timber.e("Error fetching P2P vaults: $error") + Timber.e("Error fetching P2PEthPool vaults: $error") emptyList() } } else { @@ -56,17 +83,8 @@ internal class DefaultP2PEthPoolRepository( override suspend fun getVaults(network: P2PEthPoolNetwork): Either> = either { withContext(dispatchers.io) { - val response = p2pApi.getVaults(network.value) - when (response) { - is ApiResponse.Success -> { - val data = response.data - ensure(data.error == null) { - errorConverter.convertFromErrorDetails(requireNotNull(data.error)) - } - val result = requireNotNull(data.result) { "Result is null in successful response" } - result.vaults.map { vaultConverter.convert(it) } - } - is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + handleApiResponse(p2pEthPoolApi.getVaults(network.value)) { result -> + result.vaults.map { vaultConverter.convert(it) } } } } @@ -76,81 +94,60 @@ internal class DefaultP2PEthPoolRepository( delegatorAddress: String, vaultAddress: String, amount: String, - ): Either = either { - withContext(dispatchers.io) { - val requestBody = P2PEthPoolDepositRequest( - delegatorAddress = delegatorAddress, - vaultAddress = vaultAddress, - amount = amount.toDoubleOrNull() ?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")), - ) - val response = p2pApi.createDepositTransaction(network.value, requestBody) - when (response) { - is ApiResponse.Success -> { - val data = response.data - ensure(data.error == null) { - errorConverter.convertFromErrorDetails(requireNotNull(data.error)) - } - val result = requireNotNull(data.result) { "Result is null in successful response" } - P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction) - } - is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) - } - } - } + ): Either = createStakingTransaction( + network = network, + delegatorAddress = delegatorAddress, + vaultAddress = vaultAddress, + amount = amount, + apiCall = p2pEthPoolApi::createDepositTransaction, + ) override suspend fun createUnstakeTransaction( network: P2PEthPoolNetwork, - stakerPublicKey: String, - stakeTransactionHash: String, - ): Either = either { - withContext(dispatchers.io) { - val requestBody = P2PEthPoolUnstakeRequest( - stakerPublicKey = stakerPublicKey, - stakeTransactionHash = stakeTransactionHash, - ) - val response = p2pApi.createUnstakeTransaction(network.value, requestBody) - when (response) { - is ApiResponse.Success -> { - val data = response.data - ensure(data.error == null) { - errorConverter.convertFromErrorDetails(requireNotNull(data.error)) - } - val result = requireNotNull(data.result) { "Result is null in successful response" } - // Note: API returns only hex string for unstake, not full transaction structure - P2PEthPoolUnsignedTx( - serializeTx = result.unstakeTransactionHex, - to = "", // Will be parsed from hex by wallet - data = result.unstakeTransactionHex, - value = java.math.BigDecimal.ZERO, - nonce = 0, - chainId = network.chainId, - gasLimit = java.math.BigDecimal.ZERO, - maxFeePerGas = java.math.BigDecimal.ZERO, - maxPriorityFeePerGas = java.math.BigDecimal.ZERO, - ) - } - is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) - } - } - } + delegatorAddress: String, + vaultAddress: String, + amount: String, + ): Either = createStakingTransaction( + network = network, + delegatorAddress = delegatorAddress, + vaultAddress = vaultAddress, + amount = amount, + apiCall = p2pEthPoolApi::createUnstakeTransaction, + ) override suspend fun createWithdrawTransaction( network: P2PEthPoolNetwork, - stakerAddress: String, + delegatorAddress: String, + vaultAddress: String, + amount: String, + ): Either = createStakingTransaction( + network = network, + delegatorAddress = delegatorAddress, + vaultAddress = vaultAddress, + amount = amount, + apiCall = p2pEthPoolApi::createWithdrawTransaction, + ) + + private suspend fun createStakingTransaction( + network: P2PEthPoolNetwork, + delegatorAddress: String, + vaultAddress: String, + amount: String, + apiCall: + suspend ( + String, + P2PEthPoolTransactionRequest, + ) -> ApiResponse>, ): Either = either { withContext(dispatchers.io) { - val requestBody = P2PEthPoolWithdrawRequest(stakerAddress = stakerAddress) - val response = p2pApi.createWithdrawTransaction(network.value, requestBody) - when (response) { - is ApiResponse.Success -> { - val data = response.data - ensure(data.error == null) { - errorConverter.convertFromErrorDetails(requireNotNull(data.error)) - } - val result = requireNotNull(data.result) { "Result is null in successful response" } - P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction) - } - is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + val requestBody = P2PEthPoolTransactionRequest( + delegatorAddress = delegatorAddress, + vaultAddress = vaultAddress, + amount = amount.toBigDecimalOrNull() + ?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")), + ) + handleApiResponse(apiCall(network.value, requestBody)) { result -> + P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction) } } } @@ -161,17 +158,8 @@ internal class DefaultP2PEthPoolRepository( ): Either = either { withContext(dispatchers.io) { val requestBody = P2PEthPoolBroadcastRequest(signedTransaction = signedTransaction) - val response = p2pApi.broadcastTransaction(network.value, requestBody) - when (response) { - is ApiResponse.Success -> { - val data = response.data - ensure(data.error == null) { - errorConverter.convertFromErrorDetails(requireNotNull(data.error)) - } - val result = requireNotNull(data.result) { "Result is null in successful response" } - broadcastResultConverter.convert(result) - } - is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + handleApiResponse(p2pEthPoolApi.broadcastTransaction(network.value, requestBody)) { result -> + broadcastResultConverter.convert(result) } } } @@ -180,48 +168,18 @@ internal class DefaultP2PEthPoolRepository( network: P2PEthPoolNetwork, delegatorAddress: String, vaultAddress: String, - ): Either = either { + ): Either = either { withContext(dispatchers.io) { - val response = p2pApi.getAccountInfo(network.value, delegatorAddress, vaultAddress) - when (response) { - is ApiResponse.Success -> { - val data = response.data - ensure(data.error == null) { - errorConverter.convertFromErrorDetails(requireNotNull(data.error)) - } - val result = requireNotNull(data.result) { "Result is null in successful response" } - accountInfoConverter.convert(result) - } - is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) + handleApiResponse( + p2pEthPoolApi.getAccountInfo(network.value, delegatorAddress, vaultAddress), + ) { result -> + accountConverter.convert(result) } } } - override suspend fun getRewards( - network: P2PEthPoolNetwork, - delegatorAddress: String, - vaultAddress: String, - period: Int?, - ): Either> = either { - withContext(dispatchers.io) { - val response = p2pApi.getRewards( - network = network.value, - delegatorAddress = delegatorAddress, - vaultAddress = vaultAddress, - period = period, - ) - when (response) { - is ApiResponse.Success -> { - val data = response.data - ensure(data.error == null) { - errorConverter.convertFromErrorDetails(requireNotNull(data.error)) - } - val result = requireNotNull(data.result) { "Result is null in successful response" } - result.rewards.map { rewardConverter.convert(it) } - } - is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause)) - } - } + override fun getVaultsFlow(): Flow> { + return p2pEthPoolVaultsStore.get() } override fun getStakingAvailability(): Flow { @@ -231,7 +189,7 @@ internal class DefaultP2PEthPoolRepository( if (vaults.isEmpty()) { return@map StakingAvailability.TemporaryUnavailable } else { - StakingAvailability.Available(StakingOption.P2P(vaults)) + StakingAvailability.Available(StakingOption.P2PEthPool(vaults)) } } } @@ -241,15 +199,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 { + override 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..626219cb64 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt @@ -3,7 +3,7 @@ package com.tangem.data.staking import arrow.core.getOrElse import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toBlockchain -import com.tangem.data.staking.store.StakingBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.domain.card.common.TapWorkarounds.isWallet2 import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.staking.StakingBalance @@ -30,7 +30,7 @@ import kotlinx.coroutines.withContext internal class DefaultStakingRepository( private val stakeKitRepository: StakeKitRepository, private val p2pEthPoolRepository: P2PEthPoolRepository, - private val stakingBalanceStoreV2: StakingBalancesStore, + private val stakingBalanceStoreV2: StakeKitBalancesStore, private val dispatchers: CoroutineDispatcherProvider, private val getUserWalletUseCase: GetUserWalletUseCase, private val stakingFeatureToggles: StakingFeatureToggles, @@ -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/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 deleted file mode 100644 index da6432d026..0000000000 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.data.staking.converters.ethpool - -import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolRewardDTO -import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward -import com.tangem.utils.converter.Converter - -/** - * Converter from P2P Reward Entry DTO to Domain model - */ -internal object P2PEthPoolRewardConverter : Converter { - - override fun convert(value: P2PEthPoolRewardDTO): P2PEthPoolReward { - return P2PEthPoolReward( - date = value.date, - apy = value.apy.toBigDecimal(), - balance = value.balance, - rewards = value.rewards, - ) - } -} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingAccountConverter.kt similarity index 54% rename from data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt rename to data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingAccountConverter.kt index a19708587c..5661bf65ad 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolStakingAccountConverter.kt @@ -4,17 +4,20 @@ import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountRespon import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitQueueDTO import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitRequestDTO import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolStakeDTO -import com.tangem.domain.staking.model.ethpool.* +import com.tangem.domain.models.staking.P2PEthPoolExitQueue +import com.tangem.domain.models.staking.P2PEthPoolExitRequest +import com.tangem.domain.models.staking.P2PEthPoolStake +import com.tangem.domain.models.staking.P2PEthPoolStakingAccount import com.tangem.utils.converter.Converter -import org.joda.time.Instant +import kotlinx.datetime.Instant /** - * Converter from P2P Account Info Response to Domain model + * Converts P2PEthPool Account API response to domain [P2PEthPoolStakingAccount]. */ -internal object P2PEthPoolAccountConverter : Converter { +internal object P2PEthPoolStakingAccountConverter : Converter { - override fun convert(value: P2PEthPoolAccountResponse): P2PEthPoolAccount { - return P2PEthPoolAccount( + override fun convert(value: P2PEthPoolAccountResponse): P2PEthPoolStakingAccount { + return P2PEthPoolStakingAccount( delegatorAddress = value.delegatorAddress, vaultAddress = value.vaultAddress, stake = convertStake(value.stake), @@ -24,26 +27,26 @@ internal object P2PEthPoolAccountConverter : Converter, source: StatusSource): Set { + return responses + .groupBy { it.delegatorAddress } + .map { (delegatorAddress, accountResponses) -> + convert(delegatorAddress, accountResponses, source) + } + .toSet() + } + + private fun convert( + delegatorAddress: String, + responses: List, + source: StatusSource, + ): StakingBalance { + val stakingId = StakingID( + integrationId = StakingIntegrationID.P2PEthPool.value, + address = delegatorAddress, + ) + + val accounts = responses.map { P2PEthPoolStakingAccountConverter.convert(it) } + + val hasActivePosition = accounts.any { account -> + account.stake.assets > BigDecimal.ZERO || + account.exitQueue.total > BigDecimal.ZERO || + account.availableToWithdraw > BigDecimal.ZERO + } + + return if (hasActivePosition) { + StakingBalance.Data.P2PEthPool( + stakingId = stakingId, + source = source, + accounts = accounts, + ) + } else { + StakingBalance.Empty( + stakingId = stakingId, + source = source, + ) + } + } +} \ No newline at end of file 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/P2PStakingBalanceConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PStakingBalanceConverter.kt deleted file mode 100644 index 83f9d0d2f7..0000000000 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PStakingBalanceConverter.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.tangem.data.staking.converters.ethpool - -import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse -import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitQueueDTO -import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitRequestDTO -import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolStakeDTO -import com.tangem.domain.models.StatusSource -import com.tangem.domain.models.staking.* -import com.tangem.domain.staking.model.StakingIntegrationID -import kotlinx.datetime.Instant - -/** Converts P2P ETH Pool API response to [StakingBalance.Data.P2P] */ -internal object P2PStakingBalanceConverter { - - fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance.Data.P2P { - val stakingId = StakingID( - integrationId = StakingIntegrationID.P2P.EthereumPooled.value, - address = response.delegatorAddress, - ) - - val account = P2PStakingAccount( - delegatorAddress = response.delegatorAddress, - vaultAddress = response.vaultAddress, - stake = convertStake(response.stake), - availableToUnstake = response.availableToUnstake, - availableToWithdraw = response.availableToWithdraw, - exitQueue = convertExitQueue(response.exitQueue), - ) - - return StakingBalance.Data.P2P( - stakingId = stakingId, - source = source, - account = account, - ) - } - - private fun convertStake(dto: P2PEthPoolStakeDTO): P2PStake { - return P2PStake( - assets = dto.assets, - totalEarnedAssets = dto.totalEarnedAssets, - ) - } - - private fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PExitQueue { - return P2PExitQueue( - total = dto.total.toBigDecimal(), - requests = dto.requests.map(::convertExitRequest), - ) - } - - private fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PExitRequest { - return P2PExitRequest( - ticket = dto.ticket, - totalAssets = dto.totalAssets.toBigDecimal(), - timestamp = Instant.fromEpochSeconds(dto.timestamp), - withdrawalTimestamp = Instant.fromEpochSeconds(dto.withdrawalTimestamp), - isClaimable = dto.isClaimable, - ) - } -} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/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..60fd0250f8 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt @@ -1,10 +1,10 @@ package com.tangem.data.staking.di import androidx.datastore.core.DataStore -import com.tangem.data.staking.store.DefaultP2PBalancesStore -import com.tangem.data.staking.store.DefaultStakingBalancesStore -import com.tangem.data.staking.store.P2PBalancesStore -import com.tangem.data.staking.store.StakingBalancesStore +import com.tangem.data.staking.store.DefaultP2PEthPoolBalancesStore +import com.tangem.data.staking.store.DefaultStakeKitBalancesStore +import com.tangem.data.staking.store.P2PEthPoolBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO import com.tangem.datasource.local.datastore.RuntimeSharedStore @@ -28,8 +28,8 @@ internal object StakingBalanceSupplierModule { fun provideStakingBalancesStore( persistenceStore: DataStore>>, dispatchers: CoroutineDispatcherProvider, - ): StakingBalancesStore { - return DefaultStakingBalancesStore( + ): StakeKitBalancesStore { + return DefaultStakeKitBalancesStore( runtimeStore = RuntimeSharedStore(), persistenceStore = persistenceStore, dispatchers = dispatchers, @@ -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..f1c17a770a 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -5,7 +5,8 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.data.staking.* import com.tangem.data.staking.converters.error.StakeKitErrorConverter -import com.tangem.data.staking.store.StakingBalancesStore +import com.tangem.data.staking.store.P2PEthPoolBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.data.staking.toggles.DefaultStakingFeatureToggles import com.tangem.data.staking.utils.DefaultStakingCleaner import com.tangem.datasource.api.ethpool.P2PEthPoolApi @@ -16,6 +17,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.P2PEthPoolVaultsStore import com.tangem.datasource.local.token.StakingActionsStore import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.repositories.* import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.staking.utils.StakingCleaner @@ -55,7 +57,7 @@ internal object StakingDataModule { fun provideStakingRepository( stakeKitRepository: StakeKitRepository, p2pEthPoolRepository: P2PEthPoolRepository, - stakingBalancesStore: StakingBalancesStore, + stakeKitBalancesStore: StakeKitBalancesStore, dispatchers: CoroutineDispatcherProvider, getUserWalletUseCase: GetUserWalletUseCase, stakingFeatureToggles: StakingFeatureToggles, @@ -64,7 +66,7 @@ internal object StakingDataModule { return DefaultStakingRepository( stakeKitRepository = stakeKitRepository, p2pEthPoolRepository = p2pEthPoolRepository, - stakingBalanceStoreV2 = stakingBalancesStore, + stakingBalanceStoreV2 = stakeKitBalancesStore, dispatchers = dispatchers, getUserWalletUseCase = getUserWalletUseCase, walletManagersFacade = walletManagersFacade, @@ -75,13 +77,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, @@ -136,11 +138,15 @@ internal object StakingDataModule { @Provides @Singleton fun provideStakingCleaner( - stakingBalancesStore: StakingBalancesStore, + stakingIdFactory: StakingIdFactory, + stakeKitBalancesStore: StakeKitBalancesStore, + p2pEthPoolBalancesStore: P2PEthPoolBalancesStore, dispatchers: CoroutineDispatcherProvider, ): StakingCleaner { return DefaultStakingCleaner( - stakingBalancesStore = stakingBalancesStore, + stakingIdFactory = stakingIdFactory, + stakeKitBalancesStore = stakeKitBalancesStore, + p2pEthPoolBalancesStore = p2pEthPoolBalancesStore, dispatchers = dispatchers, ) } diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcher.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcher.kt index 8a2411fc11..4a1a5c9a1b 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcher.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcher.kt @@ -5,8 +5,8 @@ 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.StakingBalancesStore +import com.tangem.data.staking.store.P2PEthPoolBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.ethpool.P2PEthPoolApi @@ -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 stakeKitBalancesStore 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] */ @@ -55,11 +56,11 @@ import javax.inject.Inject internal class DefaultMultiStakingBalanceFetcher @Inject constructor( private val userWalletsStore: UserWalletsStore, private val stakingYieldsStore: StakingYieldsStore, - private val stakingBalancesStore: StakingBalancesStore, - private val p2pBalancesStore: P2PBalancesStore, + private val stakeKitBalancesStore: StakeKitBalancesStore, + 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()) } } } } @@ -105,19 +106,22 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( Timber.e(throwable, "Unable to fetch staking balances $params") if (stakeKitIds.isNotEmpty()) { - stakingBalancesStore.storeError( + stakeKitBalancesStore.storeError( userWalletId = params.userWalletId, stakingIds = stakeKitIds.toSet(), ) } - if (p2pIds.isNotEmpty()) { - p2pBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = p2pIds.toSet()) + if (p2pEthPoolIds.isNotEmpty()) { + p2PEthPoolBalancesStore.storeError( + userWalletId = params.userWalletId, + stakingIds = p2pEthPoolIds.toSet(), + ) } } } private suspend fun fetchStakeKitBalances(userWalletId: UserWalletId, stakingIds: Set) { - stakingBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds) + stakeKitBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds) val availableStakingIds = getAvailableStakingIds( userWalletId = userWalletId, @@ -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() @@ -255,7 +270,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( ) if (unavailableStakingIds.isNotEmpty()) { - stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = unavailableStakingIds.toSet()) + stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = unavailableStakingIds.toSet()) } return availableStakingIds.toSet().ifEmpty { @@ -307,7 +322,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( Timber.i( "Successfully fetched staking balances for $userWalletId:\n${yieldBalances.joinToString("\n")}", ) - stakingBalancesStore.storeActual(userWalletId = userWalletId, values = yieldBalances) + stakeKitBalancesStore.storeActual(userWalletId = userWalletId, values = yieldBalances) if (!allResponsesReceived(requests, yieldBalances)) { val values = stakingIds.filter { stakingId -> @@ -317,13 +332,13 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor( } } - stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = values.toSet()) + stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = values.toSet()) } }, onError = { throwable -> Timber.e(throwable, "Unable to fetch staking balances $userWalletId") - stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) + stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) throw throwable }, diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducer.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducer.kt index 2ab3c5ad8c..404059222f 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducer.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducer.kt @@ -2,8 +2,8 @@ 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.StakingBalancesStore +import com.tangem.data.staking.store.P2PEthPoolBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.staking.multi.MultiStakingBalanceProducer import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -19,30 +19,30 @@ 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 stakeKitBalancesStore StakeKit staking balances store + * @property p2PEthPoolBalancesStore P2PEthPool balances store * @property dispatchers dispatchers * [REDACTED_AUTHOR] */ internal class DefaultMultiStakingBalanceProducer @AssistedInject constructor( @Assisted val params: MultiStakingBalanceProducer.Params, - private val stakingBalancesStore: StakingBalancesStore, - private val p2pBalancesStore: P2PBalancesStore, + private val stakeKitBalancesStore: StakeKitBalancesStore, + private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore, private val dispatchers: CoroutineDispatcherProvider, ) : MultiStakingBalanceProducer { override val fallback: Option> = emptySet().some() override fun produce(): Flow> { - val stakeKitFlow = stakingBalancesStore.get(userWalletId = params.userWalletId) - val p2pFlow = p2pBalancesStore.get(userWalletId = params.userWalletId) + val stakeKitFlow = stakeKitBalancesStore.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/P2PBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/BaseStakingBalancesStore.kt similarity index 60% 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/BaseStakingBalancesStore.kt index 02479075c3..a9763e801e 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/BaseStakingBalancesStore.kt @@ -1,29 +1,35 @@ package com.tangem.data.staking.store -import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.Flow /** - * Store for P2P ETH Pool staking balances + * Base interface for staking balances stores. + * + * Defines common read/query operations shared by all staking provider stores. */ -interface P2PBalancesStore { +interface BaseStakingBalancesStore { + /** Get flow of staking balances for a wallet */ fun get(userWalletId: UserWalletId): Flow> + /** Get a single staking balance synchronously */ suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): StakingBalance? + /** Get all staking balances for a wallet synchronously */ suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set? + /** Refresh a single staking balance from cache */ suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID) + /** Refresh multiple staking balances from cache */ suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set) - suspend fun storeActual(userWalletId: UserWalletId, values: Set) - + /** Store error state for staking balances */ suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set) + /** Clear staking balances */ suspend fun clear(userWalletId: UserWalletId, stakingIds: Set) } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt similarity index 82% 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..d43261002e 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,14 +1,13 @@ 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 import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.addOrReplace import kotlinx.coroutines.CoroutineScope @@ -20,22 +19,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) @@ -46,12 +45,10 @@ internal class DefaultP2PBalancesStore( runtimeStore.store( value = cachedData.map { (stringWalletId, responses) -> val key = UserWalletId(stringWalletId) - val value = responses.map { response -> - P2PStakingBalanceConverter.convert( - response = response, - source = StatusSource.CACHE, - ) - }.toSet() + val value = P2PEthPoolStakingBalanceConverter.convertAll( + responses = responses, + source = StatusSource.CACHE, + ) key to value }.toMap(), @@ -90,6 +87,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, @@ -107,12 +113,10 @@ internal class DefaultP2PBalancesStore( } private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set) { - val newBalances = values.map { response -> - P2PStakingBalanceConverter.convert( - response = response, - source = StatusSource.ACTUAL, - ) - }.toSet() + val newBalances = P2PEthPoolStakingBalanceConverter.convertAll( + responses = values, + source = StatusSource.ACTUAL, + ) runtimeStore.update(default = emptyMap()) { saved -> saved.toMutableMap().apply { @@ -146,13 +150,13 @@ internal class DefaultP2PBalancesStore( } private suspend fun clearInPersistence(userWalletId: UserWalletId, stakingIds: Set) { - val integrationIds = stakingIds.map { it.integrationId }.toSet() + val addressesToClear = stakingIds.map { it.address }.toSet() persistenceStore.updateData { current -> current.toMutableMap().apply { this[userWalletId.stringValue] = this[userWalletId.stringValue].orEmpty() .filterNot { response -> - StakingIntegrationID.P2P.EthereumPooled.value in integrationIds + response.delegatorAddress in addressesToClear } .toSet() } @@ -187,5 +191,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/DefaultStakingBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakeKitBalancesStore.kt similarity index 88% rename from data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakingBalancesStore.kt rename to data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakeKitBalancesStore.kt index b2b0ba10a9..16c5404a01 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakingBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakeKitBalancesStore.kt @@ -22,7 +22,7 @@ internal typealias WalletIdWithWrappers = Map> /** - * Default implementation of [StakingBalancesStore] + * Default implementation of [StakeKitBalancesStore] * * @property runtimeStore runtime store * @property persistenceStore persistence store @@ -30,11 +30,11 @@ internal typealias WalletIdWithStakingBalances = Map, private val persistenceStore: DataStore, dispatchers: CoroutineDispatcherProvider, -) : StakingBalancesStore { +) : StakeKitBalancesStore { private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) @@ -97,6 +97,23 @@ internal class DefaultStakingBalancesStore( } override suspend fun clear(userWalletId: UserWalletId, stakingIds: Set) { + coroutineScope { + launch { clearInRuntime(userWalletId = userWalletId, stakingIds = stakingIds) } + launch { clearInPersistence(userWalletId = userWalletId, stakingIds = stakingIds) } + } + } + + private suspend fun clearInRuntime(userWalletId: UserWalletId, stakingIds: Set) { + runtimeStore.update(default = emptyMap()) { stored -> + stored.toMutableMap().apply { + this[userWalletId] = this[userWalletId].orEmpty() + .filterNot { it.stakingId in stakingIds } + .toSet() + } + } + } + + private suspend fun clearInPersistence(userWalletId: UserWalletId, stakingIds: Set) { persistenceStore.updateData { current -> current.toMutableMap().apply { this[userWalletId.stringValue] = this[userWalletId.stringValue].orEmpty() diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt new file mode 100644 index 0000000000..ecd7442ce6 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/store/P2PEthPoolBalancesStore.kt @@ -0,0 +1,19 @@ +package com.tangem.data.staking.store + +import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse +import com.tangem.domain.models.staking.StakingID +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Store for P2PEthPool staking balances. + * + * Extends [BaseStakingBalancesStore] with P2PEthPool-specific storage operations. + */ +interface P2PEthPoolBalancesStore : BaseStakingBalancesStore { + + /** Store actual P2PEthPool account balances */ + suspend fun storeActual(userWalletId: UserWalletId, values: Set) + + /** Store empty state for accounts with no active positions */ + suspend fun storeEmpty(userWalletId: UserWalletId, stakingIds: Set) +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/StakeKitBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/StakeKitBalancesStore.kt new file mode 100644 index 0000000000..e9ea59d60d --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/store/StakeKitBalancesStore.kt @@ -0,0 +1,15 @@ +package com.tangem.data.staking.store + +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Store for StakeKit staking balances. + * + * Extends [BaseStakingBalancesStore] with StakeKit-specific storage operations. + */ +interface StakeKitBalancesStore : BaseStakingBalancesStore { + + /** Store actual StakeKit yield balances */ + suspend fun storeActual(userWalletId: UserWalletId, values: Set) +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/StakingBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/StakingBalancesStore.kt deleted file mode 100644 index 5d730dd5b7..0000000000 --- a/data/staking/src/main/java/com/tangem/data/staking/store/StakingBalancesStore.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.tangem.data.staking.store - -import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO -import com.tangem.domain.models.staking.StakingBalance -import com.tangem.domain.models.staking.StakingID -import com.tangem.domain.models.wallet.UserWalletId -import kotlinx.coroutines.flow.Flow - -/** Store of StakeKit [StakingBalance] */ -interface StakingBalancesStore { - - fun get(userWalletId: UserWalletId): Flow> - - suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): StakingBalance? - - suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set? - - suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID) - - suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set) - - suspend fun storeActual(userWalletId: UserWalletId, values: Set) - - suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set) - - suspend fun clear(userWalletId: UserWalletId, stakingIds: Set) -} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt b/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt index c55bf2154e..0fe16ead81 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt @@ -1,29 +1,78 @@ package com.tangem.data.staking.utils -import com.tangem.data.staking.store.StakingBalancesStore +import com.tangem.data.staking.store.P2PEthPoolBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.utils.StakingCleaner import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import timber.log.Timber /** * Default implementation of [StakingCleaner]. * - * @property stakingBalancesStore Store to manage staking balances. + * @property stakeKitBalancesStore Store to manage StakeKit staking balances. + * @property p2pEthPoolBalancesStore Store to manage P2PEthPool staking balances. * @property dispatchers Coroutine dispatchers provider. * [REDACTED_AUTHOR] */ internal class DefaultStakingCleaner( - private val stakingBalancesStore: StakingBalancesStore, + private val stakingIdFactory: StakingIdFactory, + private val stakeKitBalancesStore: StakeKitBalancesStore, + private val p2pEthPoolBalancesStore: P2PEthPoolBalancesStore, private val dispatchers: CoroutineDispatcherProvider, ) : StakingCleaner { - override suspend fun invoke(userWalletId: UserWalletId, stakingIds: Set) { - if (stakingIds.isEmpty()) return + override suspend fun invoke(userWalletId: UserWalletId, currencies: List) { + if (currencies.isEmpty()) { + Timber.d("No currencies to clear for wallet: $userWalletId") + return + } - with(dispatchers.default) { - stakingBalancesStore.clear(userWalletId, stakingIds) + val stakingIds = currencies.mapNotNullTo(hashSetOf()) { + stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() + } + + if (stakingIds.isEmpty()) { + Timber.d("All currencies have no stakingIds to clear for wallet: $userWalletId") + return + } + + invoke(userWalletId = userWalletId, stakingIds = stakingIds) + } + + override suspend fun invoke(userWalletId: UserWalletId, stakingIds: Set) { + if (stakingIds.isEmpty()) { + Timber.d("No stakingIds to clear for wallet: $userWalletId") + return + } + + withContext(dispatchers.default) { + awaitAll( + async { clearStakeKitBalancesStore(userWalletId = userWalletId, stakingIds = stakingIds) }, + async { clearP2PEthPoolBalancesStore(userWalletId = userWalletId, stakingIds = stakingIds) }, + ) } } + + private suspend fun clearStakeKitBalancesStore(userWalletId: UserWalletId, stakingIds: Set) { + runSuspendCatching { + stakeKitBalancesStore.clear(userWalletId, stakingIds) + } + .onFailure { Timber.e(it, "Failed to clear StakeKit balance statuses for wallet: $userWalletId") } + } + + private suspend fun clearP2PEthPoolBalancesStore(userWalletId: UserWalletId, stakingIds: Set) { + runSuspendCatching { + p2pEthPoolBalancesStore.clear(userWalletId, stakingIds) + } + .onFailure { Timber.e(it, "Failed to clear P2PEthPool balance statuses for wallet: $userWalletId") } + } } \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt index a0bacfe34f..0b2a63f262 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/StakingBalanceExt.kt @@ -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,9 +11,11 @@ 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( - response = this, +internal fun P2PEthPoolAccountResponse.toDomain( + source: StatusSource = StatusSource.CACHE, +): StakingBalance { + return P2PEthPoolStakingBalanceConverter.convertAll( + responses = setOf(this), source = source, - ) + ).first() } \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcherTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcherTest.kt index e380157061..65412ad690 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcherTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceFetcherTest.kt @@ -4,8 +4,8 @@ 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.StakingBalancesStore +import com.tangem.data.staking.store.P2PEthPoolBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError @@ -35,26 +35,26 @@ 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 stakeKitBalancesStore: StakeKitBalancesStore = 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, + stakeKitBalancesStore = stakeKitBalancesStore, + p2PEthPoolBalancesStore = p2PEthPoolBalancesStore, stakeKitApi = stakeKitApi, - p2pApi = p2pApi, - p2pVaultsStore = p2pVaultsStore, + p2pEthPoolApi = p2pEthPoolApi, + p2pEthPoolVaultsStore = p2pEthPoolVaultsStore, dispatchers = TestingCoroutineDispatcherProvider(), ) @BeforeEach fun resetMocks() { - clearMocks(userWalletsStore, stakingYieldsStore, stakingBalancesStore, stakeKitApi) + clearMocks(userWalletsStore, stakingYieldsStore, stakeKitBalancesStore, stakeKitApi) } @Test @@ -81,13 +81,13 @@ internal class DefaultMultiStakingBalanceFetcherTest { // Assert coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) - stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) stakingYieldsStore.getSyncWithTimeout() stakeKitApi.getMultipleYieldBalances(requests) - stakingBalancesStore.storeActual(userWalletId = userWalletId, values = result) + stakeKitBalancesStore.storeActual(userWalletId = userWalletId, values = result) } - coVerify(inverse = true) { stakingBalancesStore.storeError(any(), any()) } + coVerify(inverse = true) { stakeKitBalancesStore.storeError(any(), any()) } assertEitherRight(actual) } @@ -113,11 +113,11 @@ internal class DefaultMultiStakingBalanceFetcherTest { // Assert coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) - stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) stakingYieldsStore.getSyncWithTimeout() - stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(solanaId)) + stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(solanaId)) stakeKitApi.getMultipleYieldBalances(requests) - stakingBalancesStore.storeActual(userWalletId = userWalletId, values = result) + stakeKitBalancesStore.storeActual(userWalletId = userWalletId, values = result) } assertEitherRight(actual) @@ -138,11 +138,11 @@ internal class DefaultMultiStakingBalanceFetcherTest { coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) } coVerify(inverse = true) { - stakingBalancesStore.refresh(userWalletId = any(), stakingIds = any()) + stakeKitBalancesStore.refresh(userWalletId = any(), stakingIds = any()) stakingYieldsStore.getSyncWithTimeout() stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any()) - stakingBalancesStore.storeActual(userWalletId = any(), values = any()) - stakingBalancesStore.storeError(userWalletId = any(), stakingIds = any()) + stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) + stakeKitBalancesStore.storeError(userWalletId = any(), stakingIds = any()) } val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${userWallet.toOption()}") @@ -164,11 +164,11 @@ internal class DefaultMultiStakingBalanceFetcherTest { coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) } coVerify(inverse = true) { - stakingBalancesStore.refresh(userWalletId = any(), stakingIds = any()) + stakeKitBalancesStore.refresh(userWalletId = any(), stakingIds = any()) stakingYieldsStore.getSyncWithTimeout() stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any()) - stakingBalancesStore.storeActual(userWalletId = any(), values = any()) - stakingBalancesStore.storeError(userWalletId = any(), stakingIds = any()) + stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) + stakeKitBalancesStore.storeError(userWalletId = any(), stakingIds = any()) } val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${null.toOption()}") @@ -190,14 +190,14 @@ internal class DefaultMultiStakingBalanceFetcherTest { // Assert coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) - stakingBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) + stakeKitBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) stakingYieldsStore.getSyncWithTimeout() - stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds) + stakeKitBalancesStore.storeError(userWalletId, tonAndSolanaIds) } coVerify(inverse = true) { stakeKitApi.getMultipleYieldBalances(any()) - stakingBalancesStore.storeActual(userWalletId = any(), values = any()) + stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) } val expected = IllegalStateException("No enabled yields for ${params.userWalletId}") @@ -219,14 +219,14 @@ internal class DefaultMultiStakingBalanceFetcherTest { // Assert coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) - stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) stakingYieldsStore.getSyncWithTimeout() - stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds) + stakeKitBalancesStore.storeError(userWalletId, tonAndSolanaIds) } coVerify(inverse = true) { stakeKitApi.getMultipleYieldBalances(any()) - stakingBalancesStore.storeActual(userWalletId = any(), values = any()) + stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) } val expected = IllegalStateException("No enabled yields for ${params.userWalletId}") @@ -253,14 +253,14 @@ internal class DefaultMultiStakingBalanceFetcherTest { // Assert coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) - stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) stakingYieldsStore.getSyncWithTimeout() - stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds) + stakeKitBalancesStore.storeError(userWalletId, tonAndSolanaIds) } coVerify(inverse = true) { stakeKitApi.getMultipleYieldBalances(any()) - stakingBalancesStore.storeActual(userWalletId = any(), values = any()) + stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) } val expected = IllegalStateException("No enabled yields for ${params.userWalletId}") @@ -284,14 +284,14 @@ internal class DefaultMultiStakingBalanceFetcherTest { // Assert coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) - stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) stakingYieldsStore.getSyncWithTimeout() - stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds) + stakeKitBalancesStore.storeError(userWalletId, tonAndSolanaIds) } coVerify(inverse = true) { stakeKitApi.getMultipleYieldBalances(any()) - stakingBalancesStore.storeActual(userWalletId = any(), values = any()) + stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) } val expected = IllegalStateException( @@ -329,13 +329,13 @@ internal class DefaultMultiStakingBalanceFetcherTest { // Assert coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) - stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) + stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds) stakingYieldsStore.getSyncWithTimeout() stakeKitApi.getMultipleYieldBalances(requests) - stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = tonAndSolanaIds) + stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = tonAndSolanaIds) } - coVerify(inverse = true) { stakingBalancesStore.storeActual(userWalletId = any(), values = any()) } + coVerify(inverse = true) { stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) } val expected = ApiResponseError.NetworkException() diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducerTest.kt index faf28b5731..4ac3d369fa 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducerTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiStakingBalanceProducerTest.kt @@ -3,8 +3,8 @@ 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.StakingBalancesStore +import com.tangem.data.staking.store.P2PEthPoolBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.data.staking.toDomain import com.tangem.domain.models.StatusSource import com.tangem.domain.models.staking.* @@ -27,14 +27,14 @@ internal class DefaultMultiStakingBalanceProducerTest { private val params = MultiStakingBalanceProducer.Params(userWalletId = UserWalletId("011")) - private val stakingBalancesStore = mockk() - private val p2pBalancesStore = mockk() + private val stakeKitBalancesStore = mockk() + private val p2PEthPoolBalancesStore = mockk() private val dispatchers = TestingCoroutineDispatcherProvider() private val producer = DefaultMultiStakingBalanceProducer( params = params, - stakingBalancesStore = stakingBalancesStore, - p2pBalancesStore = p2pBalancesStore, + stakeKitBalancesStore = stakeKitBalancesStore, + p2PEthPoolBalancesStore = p2PEthPoolBalancesStore, dispatchers = dispatchers, ) @@ -47,14 +47,14 @@ internal class DefaultMultiStakingBalanceProducerTest { val networksStatusesFlow = flowOf(balances) - every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow - every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) + every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) val actual = producer.produce() // check after producer.produce() - verify { stakingBalancesStore.get(params.userWalletId) } - verify { p2pBalancesStore.get(params.userWalletId) } + verify { stakeKitBalancesStore.get(params.userWalletId) } + verify { p2PEthPoolBalancesStore.get(params.userWalletId) } val values = getEmittedValues(flow = actual) @@ -66,14 +66,14 @@ internal class DefaultMultiStakingBalanceProducerTest { fun `test that flow is updated if balances are updated`() = runTest { val networksStatusesFlow = MutableSharedFlow>(replay = 2) - every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow - every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) + every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) val actual = producer.produce() // check after producer.produce() - verify { stakingBalancesStore.get(params.userWalletId) } - verify { p2pBalancesStore.get(params.userWalletId) } + verify { stakeKitBalancesStore.get(params.userWalletId) } + verify { p2PEthPoolBalancesStore.get(params.userWalletId) } // first emit val balances = setOf( @@ -107,14 +107,14 @@ internal class DefaultMultiStakingBalanceProducerTest { fun `test that flow is filtered the same balance`() = runTest { val networksStatusesFlow = MutableSharedFlow>(replay = 2) - every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow - every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) + every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) val actual = producer.produce() // check after producer.produce() - verify { stakingBalancesStore.get(params.userWalletId) } - verify { p2pBalancesStore.get(params.userWalletId) } + verify { stakeKitBalancesStore.get(params.userWalletId) } + verify { p2PEthPoolBalancesStore.get(params.userWalletId) } // first emit val wrappers = setOf( @@ -156,14 +156,14 @@ internal class DefaultMultiStakingBalanceProducerTest { } .buffer(capacity = 5) - every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow - every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) + every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) val actual = producer.produceWithFallback() // check after producer.produce() - verify { stakingBalancesStore.get(params.userWalletId) } - verify { p2pBalancesStore.get(params.userWalletId) } + verify { stakeKitBalancesStore.get(params.userWalletId) } + verify { p2PEthPoolBalancesStore.get(params.userWalletId) } val values1 = getEmittedValues(flow = actual) @@ -180,14 +180,14 @@ 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 { stakeKitBalancesStore.get(params.userWalletId) } returns emptyFlow() + every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns emptyFlow() val actual = producer.produce() // check after producer.produce() - verify { stakingBalancesStore.get(params.userWalletId) } - verify { p2pBalancesStore.get(params.userWalletId) } + verify { stakeKitBalancesStore.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 { stakeKitBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances) + every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(p2pEthPoolBalances) val actual = producer.produce() // check after producer.produce() - verify { stakingBalancesStore.get(params.userWalletId) } - verify { p2pBalancesStore.get(params.userWalletId) } + verify { stakeKitBalancesStore.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 { stakeKitBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances) + every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns p2pEthPoolFlow val actual = producer.produce() // check after producer.produce() - verify { stakingBalancesStore.get(params.userWalletId) } - verify { p2pBalancesStore.get(params.userWalletId) } + verify { stakeKitBalancesStore.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/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreGetMethodTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreGetMethodTest.kt index 8ca3111517..b3716181f1 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreGetMethodTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreGetMethodTest.kt @@ -20,7 +20,7 @@ internal class StakingBalancesStoreGetMethodTest { private val runtimeStore = RuntimeSharedStore() private val persistenceStore = MockStateDataStore(default = emptyMap()) - private val store = DefaultStakingBalancesStore( + private val store = DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreInitializationTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreInitializationTest.kt index cf08a1f8e3..5ccbe2c608 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreInitializationTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreInitializationTest.kt @@ -27,7 +27,7 @@ internal class StakingBalancesStoreInitializationTest { every { persistenceStore.data } returns emptyFlow() - DefaultStakingBalancesStore( + DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), @@ -41,7 +41,7 @@ internal class StakingBalancesStoreInitializationTest { val runtimeStore = RuntimeSharedStore() val persistenceStore = MockStateDataStore(default = emptyMap()) - DefaultStakingBalancesStore( + DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), @@ -63,7 +63,7 @@ internal class StakingBalancesStoreInitializationTest { } } - DefaultStakingBalancesStore( + DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreUpdateMethodsTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreUpdateMethodsTest.kt index 18d1b25415..602f2b4dc8 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreUpdateMethodsTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/StakingBalancesStoreUpdateMethodsTest.kt @@ -23,7 +23,7 @@ internal class StakingBalancesStoreUpdateMethodsTest { private val runtimeStore = RuntimeSharedStore() private val persistenceStore = MockStateDataStore(default = emptyMap()) - private val store = DefaultStakingBalancesStore( + private val store = DefaultStakeKitBalancesStore( runtimeStore = runtimeStore, persistenceStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt index 630865f7ba..81e8af5f3b 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt @@ -1,55 +1,170 @@ package com.tangem.data.staking.utils -import com.tangem.data.staking.store.StakingBalancesStore +import arrow.core.right +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.data.staking.store.P2PEthPoolBalancesStore +import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks -import io.mockk.coVerifyOrder +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DefaultStakingCleanerTest { - private val stakingBalancesStore = mockk(relaxed = true) + private val stakingIdFactory = mockk(relaxed = true) + private val stakeKitBalancesStore = mockk(relaxed = true) + private val p2pEthPoolBalancesStore = mockk(relaxed = true) private val cleaner = DefaultStakingCleaner( - stakingBalancesStore = stakingBalancesStore, + stakingIdFactory = stakingIdFactory, + stakeKitBalancesStore = stakeKitBalancesStore, + p2pEthPoolBalancesStore = p2pEthPoolBalancesStore, dispatchers = TestingCoroutineDispatcherProvider(), ) + private val userWalletId = UserWalletId("011") - private val stakingIds = setOf( - StakingID(integrationId = StakingIntegrationID.StakeKit.Coin.Cardano.value, address = "0x1"), - ) @BeforeEach fun setUp() { - clearMocks(stakingBalancesStore) + clearMocks(stakingIdFactory, stakeKitBalancesStore, p2pEthPoolBalancesStore) } - @Test - fun `should clear yields balances when called`() = runTest { - // Act - cleaner(userWalletId = userWalletId, stakingIds = stakingIds) + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ClearByStakingIds { - // Assert - coVerifyOrder { - stakingBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + private val stakingIds = setOf( + StakingID(integrationId = StakingIntegrationID.StakeKit.Coin.Cardano.value, address = "0x1"), + ) + + @Test + fun `should clear yields balances when called`() = runTest { + // Act + cleaner(userWalletId = userWalletId, stakingIds = stakingIds) + + // Assert + coVerify { + stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + } + } + + @Test + fun `should handle empty stakingIds`() = runTest { + // Act + cleaner(userWalletId = userWalletId, stakingIds = emptySet()) + + // Assert + coVerify(inverse = true) { + stakeKitBalancesStore.clear(userWalletId = any(), stakingIds = any()) + p2pEthPoolBalancesStore.clear(userWalletId = any(), stakingIds = any()) + } + } + + @Test + fun `should catch exception from stakingBalancesStore and not throw`() = runTest { + // Arrange + coEvery { stakeKitBalancesStore.clear(userWalletId, stakingIds) } throws Exception() + + // Act + cleaner(userWalletId = userWalletId, stakingIds = stakingIds) + + // Assert + coVerify { + stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + } + } + + @Test + fun `should catch exception from p2pEthPoolBalancesStore and not throw`() = runTest { + // Arrange + coEvery { p2pEthPoolBalancesStore.clear(userWalletId, stakingIds) } throws Exception() + + // Act + cleaner(userWalletId = userWalletId, stakingIds = stakingIds) + + // Assert + coVerify { + stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + } } } - @Test - fun `should handle empty stakingIds`() = runTest { - // Act - cleaner(userWalletId = userWalletId, stakingIds = emptySet()) + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ClearByCurrencies { - // Assert - coVerifyOrder(inverse = true) { - stakingBalancesStore.clear(userWalletId = any(), stakingIds = any()) + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + private val coin = cryptoCurrencyFactory.ethereum + + @Test + fun `should clear yields balances when called with single currency`() = runTest { + // Arrange + val stakingId = StakingID( + integrationId = "stake_kit_coin_eth", + address = "0xabc", + ) + + coEvery { stakingIdFactory.create(userWalletId, coin) } returns stakingId.right() + + // Act + cleaner(userWalletId = userWalletId, currency = coin) + + // Assert + coVerify { + stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = coin) + stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + } + } + + @Test + fun `should handle empty list of currencies`() = runTest { + // Act + cleaner(userWalletId = userWalletId, currencies = emptyList()) + + // Assert + coVerify(inverse = true) { + stakingIdFactory.create(userWalletId = any(), cryptoCurrency = any()) + stakeKitBalancesStore.clear(userWalletId = any(), stakingIds = any()) + p2pEthPoolBalancesStore.clear(userWalletId = any(), stakingIds = any()) + } + } + + @Test + fun `should catch exception from stakingBalancesStore and not throw`() = runTest { + // Arrange + val stakingId = StakingID( + integrationId = "stake_kit_coin_eth", + address = "0xabc", + ) + + coEvery { stakingIdFactory.create(userWalletId, coin) } returns stakingId.right() + coEvery { + stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + } throws Exception() + + // Act + cleaner(userWalletId = userWalletId, currency = coin) + + // Assert + coVerify { + stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = coin) + stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + } } } } \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt index b15bc7b0bf..d95f8ab901 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt @@ -75,11 +75,11 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( swapTxType = swapTxType, ) - val providers = expressRepository.getProviders( + val expressProviders = expressRepository.getFilteredProviders( userWallet = userWallet, filterProviderTypes = filterProviderTypes, - ) - val expressProviders = providers.associateBy(ExpressProvider::providerId) + swapTxType = swapTxType, + ).associateBy(ExpressProvider::providerId) allPairs.map { pair -> async { @@ -125,11 +125,11 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( swapTxType = swapTxType, ) - val providers = expressRepository.getProviders( + val mappedProviders = expressRepository.getFilteredProviders( userWallet = userWallet, filterProviderTypes = filterProviderTypes, - ) - val mappedProviders = providers.associateBy(ExpressProvider::providerId) + swapTxType = swapTxType, + ).associateBy(ExpressProvider::providerId) allPairs.map { pair -> async { @@ -434,4 +434,20 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( true } } +} + +private suspend fun ExpressRepository.getFilteredProviders( + userWallet: UserWallet, + filterProviderTypes: List, + swapTxType: SwapTxType, +): List { + return getProviders( + userWallet = userWallet, + filterProviderTypes = filterProviderTypes, + ).let { allProviders -> + when (swapTxType) { + SwapTxType.SendWithSwap -> allProviders.filterNot { it.isExchangeOnlyWithinSingleAddress } + SwapTxType.Swap -> allProviders + } + } } \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt index 051b7d9a47..552341da60 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt @@ -17,6 +17,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.swap.models.SwapTransactionListModel +import com.tangem.lib.crypto.derivation.AccountNodeRecognizer import com.tangem.utils.converter.Converter internal class SavedSwapTransactionListConverter( @@ -132,7 +133,7 @@ internal class SavedSwapTransactionListConverter( blockchain = blockchain, extraDerivationPath = token.derivationPath, userWallet = userWallet, - accountIndex = token.accountId?.toIntOrNull()?.let { DerivationIndex(it).getOrNull() }, + accountIndex = token.getDerivationIndex(), ) } } @@ -150,6 +151,10 @@ internal class SavedSwapTransactionListConverter( } private fun UserTokensResponse.Token.getDerivationIndex(): DerivationIndex? { - return accountId?.toIntOrNull()?.let { DerivationIndex(it).getOrNull() } + val blockchain = Blockchain.fromNetworkId(networkId) ?: return null + val accountNodeRecognizer = AccountNodeRecognizer(blockchain) + return derivationPath + ?.let { accountNodeRecognizer.recognize(it) } + ?.let { DerivationIndex(it.toInt()).getOrNull() } } } \ No newline at end of file diff --git a/data/tokens/build.gradle.kts b/data/tokens/build.gradle.kts index 39d4e0ed16..171fb3f750 100644 --- a/data/tokens/build.gradle.kts +++ b/data/tokens/build.gradle.kts @@ -46,6 +46,10 @@ dependencies { implementation(projects.libs.blockchainSdk) // endregion + // region Project - Features API + implementation(projects.features.sendV2.api) + // endregion + // region Tangem SDKs implementation(tangemDeps.blockchain) implementation(tangemDeps.card.core) diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt index 1941b6ee4b..8c0efab318 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt @@ -22,6 +22,7 @@ import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -72,10 +73,12 @@ internal object TokensDataModule { fun provideCurrencyChecksRepository( walletManagersFacade: WalletManagersFacade, coroutineDispatcherProvider: CoroutineDispatcherProvider, + sendFeatureToggles: SendFeatureToggles, ): CurrencyChecksRepository { return DefaultCurrencyChecksRepository( walletManagersFacade = walletManagersFacade, coroutineDispatchers = coroutineDispatcherProvider, + sendFeatureToggles = sendFeatureToggles, ) } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt index 336256a2de..a24dc8b95b 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt @@ -1,10 +1,8 @@ package com.tangem.data.tokens.repository +import com.tangem.blockchain.blockchains.ethereum.eip1559.isGaslessTxSupported import com.tangem.blockchain.blockchains.polkadot.ExistentialDepositProvider -import com.tangem.blockchain.common.FeeResourceAmountProvider -import com.tangem.blockchain.common.MinimumSendAmountProvider -import com.tangem.blockchain.common.ReserveAmountProvider -import com.tangem.blockchain.common.UtxoAmountLimitProvider +import com.tangem.blockchain.common.* import com.tangem.data.tokens.converters.UtxoConverter import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -17,6 +15,7 @@ import com.tangem.domain.tokens.model.blockchains.UtxoAmountLimit import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.isZero import com.tangem.utils.extensions.orZero @@ -26,6 +25,7 @@ import java.math.BigDecimal internal class DefaultCurrencyChecksRepository( private val walletManagersFacade: WalletManagersFacade, private val coroutineDispatchers: CoroutineDispatcherProvider, + private val sendFeatureToggles: SendFeatureToggles, ) : CurrencyChecksRepository { override suspend fun getExistentialDeposit(userWalletId: UserWalletId, network: Network): BigDecimal? { @@ -66,6 +66,12 @@ internal class DefaultCurrencyChecksRepository( } } + override fun isNetworkSupportedForGaslessTx(network: Network): Boolean { + if (!sendFeatureToggles.isGaslessTransactionsEnabled) return false + val blockchain = Blockchain.fromId(network.rawId) + return blockchain.isGaslessTxSupported + } + override suspend fun getFeeResourceAmount(userWalletId: UserWalletId, network: Network): CurrencyAmount? { val manager = walletManagersFacade.getOrCreateWalletManager( userWalletId = userWalletId, diff --git a/data/transaction/build.gradle.kts b/data/transaction/build.gradle.kts index fa74ec6c43..7becb2b968 100644 --- a/data/transaction/build.gradle.kts +++ b/data/transaction/build.gradle.kts @@ -24,6 +24,9 @@ dependencies { implementation(projects.core.datasource) implementation(projects.core.utils) + /** Common */ + implementation(projects.data.common) + /** Domain */ implementation(projects.libs.blockchainSdk) implementation(projects.domain.legacy) @@ -34,6 +37,9 @@ dependencies { implementation(projects.domain.transaction) implementation(projects.domain.demo) + /** Api */ + implementation(projects.features.sendV2.api) + /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt new file mode 100644 index 0000000000..b4d16fbe7e --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -0,0 +1,146 @@ +package com.tangem.data.transaction + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.Token +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter +import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.gasless.GaslessTxServiceApi +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessSignedTransactionResult +import com.tangem.domain.transaction.models.GaslessTransactionData +import com.tangem.features.send.v2.api.SendFeatureToggles +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.math.BigInteger + +class DefaultGaslessTransactionRepository( + private val gaslessTxServiceApi: GaslessTxServiceApi, + private val coroutineDispatcherProvider: CoroutineDispatcherProvider, + private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, + private val sendFeatureToggles: SendFeatureToggles, +) : GaslessTransactionRepository { + + private val supportedTokensState = MutableStateFlow>>(hashMapOf()) + private val allFeeRecipientAddress = mutableSetOf() + private val addressesMutex = Mutex() + + private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder() + private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter() + + override suspend fun getSupportedTokens(network: Network): Set { + return withContext(coroutineDispatcherProvider.io) { + val storedTokens = supportedTokensState.value[network.id] + if (storedTokens != null && storedTokens.isNotEmpty()) { + return@withContext storedTokens + } + + val supportedTokensData = gaslessTxServiceApi.getSupportedTokens().getOrThrow() + if (supportedTokensData.isSuccess) { + val networkBlockchain = Blockchain.fromId(network.rawId) + val supportedTokens = supportedTokensData.result.tokens + .filter { + it.chainId == networkBlockchain.getChainId() + } + .map { token -> + responseCryptoCurrenciesFactory.createToken( + blockchain = networkBlockchain, + sdkToken = Token( + contractAddress = token.tokenAddress, + name = token.tokenName, + symbol = token.tokenSymbol, + decimals = token.decimals, + ), + network = network, + ) + }.toSet() + // update local cache + supportedTokensState.update { current -> + val newMap = current.toMutableMap() + newMap[network.id] = supportedTokens + newMap + } + return@withContext supportedTokens + } else { + error("Gasless service returned unsuccessful response") + } + } + } + + override suspend fun getTokenFeeReceiverAddress(): String { + return withContext(coroutineDispatcherProvider.io) { + val response = gaslessTxServiceApi.getFeeRecipient().getOrThrow() + if (response.isSuccess) { + response.result.address + } else { + error("Gasless service returned unsuccessful response") + } + } + } + + override suspend fun signGaslessTransaction( + gaslessTransactionData: GaslessTransactionData, + signature: String, + userAddress: String, + network: Network, + eip7702Auth: Eip7702Authorization?, + ): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) { + val blockchain = Blockchain.fromId(network.rawId) + val transactionRequest = gaslessTransactionRequestBuilder.build( + gaslessTransaction = gaslessTransactionData, + signature = signature, + userAddress = userAddress, + chainId = blockchain.getChainId() ?: error("ChainId is null for blockchain: $blockchain"), + eip7702Auth = eip7702Auth, + ) + + val response = gaslessTxServiceApi.signGaslessTransaction(transactionRequest).getOrThrow() + + if (!response.isSuccess) { + error("Gasless service returned unsuccessful response") + } + + // Convert DTO to domain model + signedTransactionResultConverter.convert(response.result) + } + + override fun getBaseGasForTransaction(): BigInteger { + return BASE_GAS_FOR_TRANSACTION + } + + override fun getChainIdForNetwork(network: Network): Int { + val networkBlockchain = Blockchain.fromId(network.rawId) + return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}") + } + + override suspend fun getGaslessFeeAddresses(): Set { + if (!sendFeatureToggles.isGaslessTransactionsEnabled) { + return EMPTY_ADDRESSES + } + return addressesMutex.withLock { + allFeeRecipientAddress.ifEmpty { + val allFeeAddresses = getAllFeeRecipientAddresses() + allFeeRecipientAddress.addAll(allFeeAddresses) + allFeeRecipientAddress + } + } + } + + private suspend fun getAllFeeRecipientAddresses(): Set { + // TODO Replace with other backend call to get all fee recipient addresses when available + return setOf(getTokenFeeReceiverAddress()) + } + + private companion object { + val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000") + val EMPTY_ADDRESSES = emptySet() + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt new file mode 100644 index 0000000000..be0a8b25f4 --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt @@ -0,0 +1,74 @@ +package com.tangem.data.transaction + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.Token +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessSignedTransactionResult +import com.tangem.domain.transaction.models.GaslessTransactionData +import java.math.BigInteger + +class MockedGaslessTransactionRepository( + private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, +) : GaslessTransactionRepository { + + override suspend fun getSupportedTokens(network: Network): Set { + val usdcPolygon = responseCryptoCurrenciesFactory.createToken( + blockchain = Blockchain.Polygon, + sdkToken = Token( + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + name = "Tether", + symbol = "USDT", + decimals = 6, + ), + network = network, + ) + return setOf(usdcPolygon) + } + + override fun getChainIdForNetwork(network: Network): Int { + val networkBlockchain = Blockchain.fromId(network.rawId) + return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}") + } + + override suspend fun getGaslessFeeAddresses(): Set { + return setOf("0x2bD8AA05a92CcDaeDE5d42EB7D196ed07F8F8EF3") + } + + override suspend fun getTokenFeeReceiverAddress(): String { + return TOKEN_RECEIVER_ADDRESS + } + + override suspend fun signGaslessTransaction( + gaslessTransactionData: GaslessTransactionData, + signature: String, + userAddress: String, + network: Network, + eip7702Auth: Eip7702Authorization?, + ): GaslessSignedTransactionResult = GaslessSignedTransactionResult( + txHash = "0x000", + ) + + override fun getBaseGasForTransaction(): BigInteger { + return BASE_GAS_FOR_TRANSACTION + } + + private companion object { + + const val TOKEN_RECEIVER_ADDRESS = "0x" + + val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000") + val SUPPORTED_BLOCKCHAINS = arrayOf( + Blockchain.Ethereum, + Blockchain.BSC, + Blockchain.Base, + Blockchain.Polygon, + Blockchain.Arbitrum, + Blockchain.XDC, + Blockchain.Optimism, + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessSignedTransactionResultConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessSignedTransactionResultConverter.kt new file mode 100644 index 0000000000..b4188cd05a --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessSignedTransactionResultConverter.kt @@ -0,0 +1,19 @@ +package com.tangem.data.transaction.convertes + +import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO +import com.tangem.domain.transaction.models.GaslessSignedTransactionResult +import com.tangem.utils.converter.Converter + +/** + * Converts DTO GaslessSignedTransactionResult from API to domain model. + * Transforms string representations of gas parameters to BigInteger for type safety. + */ +class GaslessSignedTransactionResultConverter : + Converter { + + override fun convert(value: GaslessSignedTransactionResultDTO): GaslessSignedTransactionResult { + return GaslessSignedTransactionResult( + txHash = value.txHash, + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt new file mode 100644 index 0000000000..b7caa4b13a --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt @@ -0,0 +1,55 @@ +package com.tangem.data.transaction.convertes + +import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest +import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessTransactionData +import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO + +/** + * Builder for creating complete GaslessTransactionRequest from domain model. + * Combines transaction data with signature and user information. + */ +class GaslessTransactionRequestBuilder( + private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(), +) { + + /** + * Creates complete gasless transaction request. + * + * @param gaslessTransaction domain model of transaction + * @param signature transaction signature in hex format (with 0x prefix) + * @param userAddress user's Ethereum address + * @param chainId blockchain network chain ID + * @param eip7702Auth optional EIP-7702 authorization for account abstraction + * @return complete request ready for API submission + */ + fun build( + gaslessTransaction: GaslessTransactionData, + signature: String, + userAddress: String, + chainId: Int, + eip7702Auth: Eip7702Authorization? = null, + ): GaslessTransactionRequest { + return GaslessTransactionRequest( + gaslessTransaction = converter.convert(gaslessTransaction), + signature = signature, + userAddress = userAddress, + chainId = chainId, + eip7702Auth = eip7702Auth?.toDTO(), + ) + } + + /** + * Converts domain Eip7702Authorization to DTO. + */ + private fun Eip7702Authorization.toDTO(): Eip7702AuthorizationDTO { + return Eip7702AuthorizationDTO( + chainId = chainId, + address = address, + nonce = nonce.toString(), + yParity = yParity, + r = r, + s = s, + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt new file mode 100644 index 0000000000..1d20602465 --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt @@ -0,0 +1,44 @@ +package com.tangem.data.transaction.convertes + +import com.tangem.blockchain.extensions.formatHex +import com.tangem.datasource.api.gasless.models.FeeData +import com.tangem.datasource.api.gasless.models.TransactionData +import com.tangem.domain.transaction.models.GaslessTransactionData +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.toHexString +import com.tangem.datasource.api.gasless.models.GaslessTransactionData as GaslessTransactionDataDTO + +/** + * Converts domain GaslessTransactionData to DTO for API requests. + * Note: This converter only handles the transaction data conversion. + * Additional fields (signature, userAddress, chainId) must be added separately + * to create complete GaslessTransactionRequest. + */ +class GaslessTxDataToGaslessRequestConverter : Converter { + + override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO { + return GaslessTransactionDataDTO( + transaction = convertTransaction(value.transaction), + fee = convertFee(value.fee), + nonce = value.nonce.toString(), + ) + } + + private fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData { + return TransactionData( + to = transaction.to, + value = transaction.value.toString(), + data = transaction.data.toHexString().formatHex(), + ) + } + + private fun convertFee(fee: GaslessTransactionData.Fee): FeeData { + return FeeData( + feeToken = fee.feeToken, + maxTokenFee = fee.maxTokenFee.toString(), + coinPriceInToken = fee.coinPriceInToken.toString(), + feeTransferGasLimit = fee.feeTransferGasLimit.toString(), + baseGas = fee.baseGas.toString(), + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt index b2f5a28882..8083f49496 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt @@ -1,17 +1,22 @@ package com.tangem.data.transaction.di +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.transaction.DefaultFeeRepository +import com.tangem.data.transaction.DefaultGaslessTransactionRepository import com.tangem.data.transaction.DefaultTransactionRepository import com.tangem.data.transaction.DefaultWalletAddressServiceRepository import com.tangem.data.transaction.error.DefaultFeeErrorResolver +import com.tangem.datasource.api.gasless.GaslessTxServiceApi import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.WalletAddressServiceRepository import com.tangem.domain.transaction.error.FeeErrorResolver import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -65,4 +70,20 @@ internal object TransactionDataModule { fun providerFeeErrorResolver(): FeeErrorResolver { return DefaultFeeErrorResolver() } + + @Provides + @Singleton + fun provideGaslessTransactionRepository( + responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, + gaslessTxServiceApi: GaslessTxServiceApi, + coroutineDispatcherProvider: CoroutineDispatcherProvider, + sendFeatureToggles: SendFeatureToggles, + ): GaslessTransactionRepository { + return DefaultGaslessTransactionRepository( + gaslessTxServiceApi = gaslessTxServiceApi, + coroutineDispatcherProvider = coroutineDispatcherProvider, + responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, + sendFeatureToggles = sendFeatureToggles, + ) + } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt index 9c27024b35..1f2acd1d9b 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt @@ -44,6 +44,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( override suspend fun getTangemPayAvailability(): Boolean { return onboardingRepository.checkCustomerEligibility() + .also { isEligible -> if (!isEligible) reset() } } private suspend fun getUserWalletsData(): List { @@ -120,7 +121,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( } } - private fun reset() { + override fun reset() { cachedEligibleWallets = null eligibleWalletsDeferred?.cancel() eligibleWalletsDeferred = null diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index d877f0f1f3..0872260f70 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -29,7 +29,9 @@ import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject private const val VALID_STATUS = "valid" -private const val APPROVED_KYC_STATUS = "APPROVED" +private const val APPROVED_KYC_STATUS = "approved" +private const val IN_PROGRESS_KYC_STATUS = "in_progress" +private const val DECLINED_KYC_STATUS = "declined" private const val TAG = "TangemPay: OnboardingRepository" @Suppress("LongParameterList") @@ -168,8 +170,9 @@ internal class DefaultOnboardingRepository @Inject constructor( ProductInstance(id = instance.id, cardId = instance.cardId, cardFrozenState = cardFrozenState) } return CustomerInfo( + customerId = response?.id, productInstance = productInstance, - isKycApproved = response?.kyc?.status == APPROVED_KYC_STATUS, + kycStatus = getKycStatus(status = response?.kyc?.status), cardInfo = cardInfo, ).also { lastFetchedCustomerInfoMap[userWalletId] = it @@ -182,11 +185,8 @@ internal class DefaultOnboardingRepository @Inject constructor( return Either.Right(hasTangemPay) } - return requestHelper.performWithStaticToken { staticToken -> - tangemPayApi.checkCustomerWalletId( - authHeader = staticToken, - customerWalletId = userWalletId.stringValue, - ) + return requestHelper.performWithStaticToken { + tangemPayApi.checkCustomerWalletId(customerWalletId = userWalletId.stringValue) }.map { response -> val id = response.result?.id val isTangemPayEnabled = response.result?.isTangemPayEnabled == true @@ -202,7 +202,7 @@ internal class DefaultOnboardingRepository @Inject constructor( } override suspend fun checkCustomerEligibility(): Boolean { - val response = requestHelper.performWithoutToken { + val response = requestHelper.performWithStaticToken { tangemPayApi.checkCustomerEligibility() }.getOrNull() @@ -236,4 +236,13 @@ internal class DefaultOnboardingRepository @Inject constructor( setHideMainOnboardingBanner(userWalletId) } } + + private fun getKycStatus(status: String?): CustomerInfo.KycStatus { + return when (status?.lowercase()) { + IN_PROGRESS_KYC_STATUS -> CustomerInfo.KycStatus.PENDING + DECLINED_KYC_STATUS -> CustomerInfo.KycStatus.REJECTED + APPROVED_KYC_STATUS -> CustomerInfo.KycStatus.APPROVED + else -> CustomerInfo.KycStatus.INIT + } + } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt index 536aa2afde..3e83c4ff0d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt @@ -11,7 +11,6 @@ import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.pay.TangemPayAuthApi import com.tangem.datasource.api.pay.models.request.RefreshCustomerWalletAccessTokenRequest import com.tangem.datasource.api.pay.models.response.TangemPayGetTokensResponse -import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.visa.error.VisaApiError @@ -32,7 +31,6 @@ private const val TAG = "TangemPayRequestPerformer" @Singleton internal class TangemPayRequestPerformer @Inject constructor( private val errorConverter: TangemPayErrorConverter, - private val environmentConfigStorage: EnvironmentConfigStorage, private val dispatchers: CoroutineDispatcherProvider, private val tangemPayAuthApi: TangemPayAuthApi, private val tangemPayStorage: TangemPayStorage, @@ -41,23 +39,10 @@ internal class TangemPayRequestPerformer @Inject constructor( private val customerWalletAddresses = ConcurrentHashMap() private val tokensMutex = Mutex() - suspend fun performWithStaticToken( - requestBlock: suspend (header: String) -> ApiResponse, - ): Either = withContext(dispatchers.io) { - catch( - block = { - val staticToken = - environmentConfigStorage.getConfigSync().bffStaticToken ?: error("BFF static token is null") - when (val apiResponse = requestBlock(staticToken)) { - is ApiResponse.Error -> errorConverter.convert(apiResponse.cause).left() - is ApiResponse.Success -> apiResponse.data.right() - } - }, - catch = { errorConverter.convert(it).left() }, - ) - } - - suspend fun performWithoutToken(requestBlock: suspend () -> ApiResponse): Either = + /** + * Static token added in headers [com.tangem.datasource.api.common.config.TangemPay] + */ + suspend fun performWithStaticToken(requestBlock: suspend () -> ApiResponse): Either = withContext(dispatchers.io) { catch( block = { diff --git a/data/wallet-connect/detekt-baseline-debug.xml b/data/wallet-connect/detekt-baseline-debug.xml index f357361c71..8ddf2c2bb4 100644 --- a/data/wallet-connect/detekt-baseline-debug.xml +++ b/data/wallet-connect/detekt-baseline-debug.xml @@ -27,7 +27,6 @@ MultilineLambdaItParameter:WcEthMessageSignUseCase.kt$LegacySdkHelper${ val char = it.toInt().toChar() if (char.isAscii()) char else return null } MultilineLambdaItParameter:WcEthNetwork.kt$WcEthNetwork${ if (this == WcEthMethodName.AddEthereumChain) { WcEthMethod.AddEthereumChain(rawChain = it).right() } else { WcEthMethod.SwitchEthereumChain(rawChain = it).right() } } MultilineLambdaItParameter:WcEthNetwork.kt$WcEthNetwork${ if (this == WcEthMethodName.SignTransaction) { WcEthMethod.SignTransaction(transaction = it).right() } else { WcEthMethod.SendTransaction(transaction = it).right() } } - MultilineLambdaItParameter:WcNetworksConverter.kt$WcNetworksConverter${ val walletAddress = walletManagersFacade.getDefaultAddress(wallet.walletId, it) walletAddress?.lowercase() == caip10.accountAddress.lowercase() } MultilineLambdaItParameter:WcPairSdkDelegate.kt$WcPairSdkDelegate${ proposalCallback.cancel() return@coroutineScope it.left() } MultilineLambdaItParameter:WcPairSdkDelegate.kt$WcPairSdkDelegate${ store.removePendingApproval(forSave) it.left() } MultilineLambdaItParameter:WcSdkSessionConverter.kt$WcSdkSessionConverter${ WcAppMetaDataConverter.convert( value = WcAppMetaDataConverter.Input( originUrl = value.originUrl, peerMetaData = it, ), ) } diff --git a/data/wallet-manager/build.gradle.kts b/data/wallet-manager/build.gradle.kts index 31bb6bc3c8..e703847de0 100644 --- a/data/wallet-manager/build.gradle.kts +++ b/data/wallet-manager/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(projects.domain.walletManager) implementation(projects.domain.demo) implementation(projects.domain.card) + implementation(projects.domain.transaction) api(projects.domain.models) /** Domain models */ diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index 842ce80393..c26cf7ee59 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -35,6 +35,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.domain.txhistory.models.PaginationWrapper import com.tangem.domain.txhistory.models.TxHistoryState @@ -61,6 +62,7 @@ internal class DefaultWalletManagersFacade @Inject constructor( private val userWalletsStore: UserWalletsStore, private val assetLoader: AssetLoader, private val dispatchers: CoroutineDispatcherProvider, + private val gaslessTransactionRepository: GaslessTransactionRepository, blockchainSDKFactory: BlockchainSDKFactory, ) : WalletManagersFacade { @@ -284,6 +286,7 @@ internal class DefaultWalletManagersFacade @Inject constructor( items = SdkTransactionHistoryItemConverter( smartContractMethods = readSmartContractMethods(), yieldSupplyAddresses = YIELD_SUPPLY_ADDRESSES, + gaslessFeeAddresses = gaslessTransactionRepository.getGaslessFeeAddresses(), ).convertList(itemsResult.data.items), ) is Result.Failure -> error(itemsResult.error.message ?: itemsResult.error.customMessage) diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt index 51d7e2fd6e..99f7e8fa99 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt @@ -9,11 +9,13 @@ import com.tangem.blockchain.transactionhistory.models.TransactionHistoryItem as internal class SdkTransactionHistoryItemConverter( smartContractMethods: Map, yieldSupplyAddresses: Set, + gaslessFeeAddresses: Set, ) : Converter { private val typeConverter by lazy { SdkTransactionTypeConverter( smartContractMethods = smartContractMethods, yieldSupplyAddresses = yieldSupplyAddresses, + gaslessFeeAddresses = gaslessFeeAddresses, ) } override fun convert(value: SdkTransactionHistoryItem): TxInfo = TxInfo( diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt index 639350d503..44850b9ec5 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverter.kt @@ -13,8 +13,11 @@ import com.tangem.utils.converter.Converter internal class SdkTransactionTypeConverter( private val smartContractMethods: Map, private val yieldSupplyAddresses: Set, + gaslessFeeAddresses: Set, ) : Converter { + private val gaslessFeeAddressesLowercase: Set = gaslessFeeAddresses.map { it.lowercase() }.toSet() + override fun convert(value: TransactionHistoryItem): TxInfo.TransactionType { val (type, destination) = value.type to value.destinationType val source = value.sourceType @@ -71,10 +74,12 @@ internal class SdkTransactionTypeConverter( "buyVoucher", "buyVoucherPOL", "delegate", + "pooledStake", -> TxInfo.TransactionType.Staking.Stake "sellVoucher_new", "sellVoucher_newPOL", "undelegate", + "pooledUnstake", -> TxInfo.TransactionType.Staking.Unstake "unstakeClaimTokens_new", "unstakeClaimTokens_newPOL", @@ -123,8 +128,23 @@ internal class SdkTransactionTypeConverter( ) } "supplyTopUp" -> TxInfo.TransactionType.YieldSupply.Topup + "gaslessTransaction" -> getTypeForGaslessMethod(destination) null -> TxInfo.TransactionType.UnknownOperation else -> TxInfo.TransactionType.Operation(name = methodName.replaceFirstChar { it.titlecase() }) } ?: TxInfo.TransactionType.Operation(name = methodName?.replaceFirstChar { it.titlecase() }.orEmpty()) } + + private fun getTypeForGaslessMethod(destination: TransactionHistoryItem.DestinationType): TxInfo.TransactionType { + return when (destination) { + is TransactionHistoryItem.DestinationType.Multiple -> TxInfo.TransactionType.UnknownOperation + is TransactionHistoryItem.DestinationType.Single -> { + val address = destination.addressType.address.lowercase() + if (gaslessFeeAddressesLowercase.contains(address)) { + TxInfo.TransactionType.GaslessFee + } else { + TxInfo.TransactionType.UnknownOperation + } + } + } + } } \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt index d7faa6176a..3565a65952 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt @@ -1,6 +1,7 @@ package com.tangem.data.yield.supply import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.TransactionStatus import com.tangem.blockchain.yieldsupply.YieldSupplyProvider import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.toBlockchain @@ -18,20 +19,16 @@ import com.tangem.datasource.local.preferences.utils.get import com.tangem.datasource.local.preferences.utils.store import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.models.YieldMarketToken -import com.tangem.domain.yield.supply.models.YieldSupplyEnterStatus +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.runSuspendCatching import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -import timber.log.Timber import java.util.concurrent.ConcurrentHashMap internal class DefaultYieldSupplyRepository( @@ -43,7 +40,7 @@ internal class DefaultYieldSupplyRepository( private val appPreferencesStore: AppPreferencesStore, ) : YieldSupplyRepository { - private val statusMap: MutableMap = ConcurrentHashMap() + private val statusMap: MutableMap = ConcurrentHashMap() override suspend fun getCachedMarkets(): List? = withContext(dispatchers.io) { val cache = store.getSyncOrNull().orEmpty() @@ -126,23 +123,36 @@ internal class DefaultYieldSupplyRepository( ).getOrThrow().isActive } - override suspend fun saveTokenProtocolStatus( + override suspend fun saveTokenProtocolPendingStatus( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, - yieldSupplyEnterStatus: YieldSupplyEnterStatus?, + yieldSupplyPendingStatus: YieldSupplyPendingStatus?, ) { val key = getTokenProtocolStatusKey(userWalletId, cryptoCurrency) - if (yieldSupplyEnterStatus != null) { - statusMap[key] = yieldSupplyEnterStatus + if (yieldSupplyPendingStatus != null) { + statusMap[key] = yieldSupplyPendingStatus } else { statusMap.remove(key) } } - override fun getTokenProtocolStatus( + override suspend fun getPendingTxHashes(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): List { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + network = cryptoCurrency.network, + ) ?: return emptyList() + + return walletManager.wallet.recentTransactions + .filter { it.status == TransactionStatus.Unconfirmed } + .map { + it.hash.orEmpty() + } + } + + override fun getTokenProtocolPendingStatus( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, - ): YieldSupplyEnterStatus? { + ): YieldSupplyPendingStatus? { return statusMap[getTokenProtocolStatusKey(userWalletId, cryptoCurrency)] } @@ -153,32 +163,6 @@ internal class DefaultYieldSupplyRepository( } } - override suspend fun getTokenPendingStatus( - userWalletId: UserWalletId, - cryptoCurrencyStatus: CryptoCurrencyStatus, - ): YieldSupplyEnterStatus? = runSuspendCatching { - val cryptoCurrency = cryptoCurrencyStatus.currency - val walletManager = walletManagersFacade.getOrCreateWalletManager( - userWalletId = userWalletId, - blockchain = cryptoCurrency.network.toBlockchain(), - derivationPath = cryptoCurrency.network.derivationPath.value, - ) ?: error("Wallet manager not found") - - val pendingTxs = cryptoCurrencyStatus.value.pendingTransactions - - val yieldAddress = walletManager.calculateYieldModuleAddress() - val hasRecentYieldEnterTxs = pendingTxs.hasYieldEnterTransactions(yieldAddress) - val hasRecentYieldExitTxs = pendingTxs.hasYieldExitTransactions() - - when { - hasRecentYieldEnterTxs -> YieldSupplyEnterStatus.Enter - hasRecentYieldExitTxs -> YieldSupplyEnterStatus.Exit - else -> null - } - }.onFailure { exception -> - Timber.w(exception, "Failed to get pending yield supply status") - }.getOrNull() - override fun getShouldShowYieldPromoBanner(): Flow { return appPreferencesStore.get(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, true) } @@ -187,16 +171,6 @@ internal class DefaultYieldSupplyRepository( appPreferencesStore.store(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, shouldShow) } - private fun Set.hasYieldEnterTransactions(yieldAddress: String) = any { - it.type == TxInfo.TransactionType.YieldSupply.Enter || - it.type == TxInfo.TransactionType.Approve && - (it.interactionAddressType as? TxInfo.InteractionAddressType.Contract)?.address == yieldAddress - } - - private fun Set.hasYieldExitTransactions() = any { - it.type == TxInfo.TransactionType.YieldSupply.Exit - } - private fun getTokenProtocolStatusKey(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String = "${userWalletId}_${cryptoCurrency.id.value}" } \ No newline at end of file diff --git a/detekt_baseline_report.txt b/detekt_baseline_report.txt index d36313f946..6637a2e509 100644 --- a/detekt_baseline_report.txt +++ b/detekt_baseline_report.txt @@ -1,7 +1,7 @@ ========================================== Detekt Baseline Updater & Issue Counter ========================================== -Date: 2025-12-13 00:34:09 +Date: 2026-01-04 19:04:51 Step 1: Running detekt to check for new issues... @@ -17,13 +17,13 @@ Counting issues in baseline files... ========================================== Summary: - Total Issues: 1271 - Modules with Issues: 69 - Average Issues per Module: 18 + Total Issues: 1065 + Modules with Issues: 65 + Average Issues per Module: 16 Progress: - Fixed: 662 out of 1933 (34%) - Remaining: 1271 + Fixed: 868 out of 1933 (44%) + Remaining: 1065 ========================================== All Modules with Issues (sorted by count) @@ -31,54 +31,51 @@ All Modules with Issues (sorted by count) Module Issues ──────────────────────────────────────────────────────────────── -features/markets/impl 147 -features/wallet/impl 144 +features/wallet/impl 138 features/onboarding-v2/impl 128 -data/wallet-connect 54 -features/hot-wallet/impl 51 +data/wallet-connect 53 +features/hot-wallet/impl 50 features/walletconnect/impl 47 -features/staking/impl 46 features/tokendetails/impl 45 features/manage-tokens/impl 44 -domain/wallets 37 -features/nft/impl 34 -features/tester/impl 28 -core/ui 26 -domain/tokens 25 -common/ui 24 +domain/wallets 36 +features/staking/impl 35 +features/nft/impl 33 +features/tester/impl 27 +domain/tokens 23 features/yield-supply/impl 21 features/tangempay/details/impl 21 domain/models 21 data/visa 21 data/nft 19 +core/ui 19 core/pagination 16 +common/ui 15 domain/staking/models 13 data/wallets 13 features/token-recieve/impl 11 features/qr-scanning/impl 11 data/onramp 11 -core/datasource 11 core/analytics/models 11 -data/markets 10 +core/datasource 10 features/details/impl 9 domain/account/status 9 data/yield-supply 9 data/networks 9 domain/visa/models 8 domain/transaction 8 -domain/staking 8 core/utils 8 libs/tangem-sdk-api 7 features/referral/impl 7 domain/tokens/models 7 +domain/staking 7 data/txhistory 7 -features/welcome/impl 6 domain/wallet-connect/models 6 domain/onramp 6 domain/account 6 libs/visa 5 +features/welcome/impl 5 features/home/impl 5 -domain/markets 5 domain/core 5 data/account 5 domain/onramp/models 4 @@ -97,7 +94,6 @@ domain/transaction/models 1 domain/quotes 1 domain/promo 1 domain/onboarding 1 -domain/markets/models 1 domain/feedback/models 1 domain/express/models 1 ──────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/AccountExpandedState.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountExpandedState.kt new file mode 100644 index 0000000000..5da580f8eb --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountExpandedState.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.account.models + +import com.tangem.domain.models.account.AccountId + +data class AccountExpandedState( + val accountId: AccountId, + val isExpanded: Boolean, +) \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt index db2dc28e25..0431d854da 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt @@ -99,6 +99,7 @@ data class AccountList private constructor( return accounts.flatMap { account -> when (account) { is Account.CryptoPortfolio -> account.cryptoCurrencies + is Account.Payment -> TODO("[REDACTED_JIRA]") } } } diff --git a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt new file mode 100644 index 0000000000..79e58ae8c3 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.account.repository + +import com.tangem.domain.account.models.AccountExpandedState +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow + +interface AccountsExpandedRepository { + + val expandedAccounts: Flow>> + + suspend fun syncStore(walletId: UserWalletId, existAccounts: Set) + suspend fun update(accountState: AccountExpandedState) +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt index c2d429c650..fced65957e 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt @@ -5,11 +5,13 @@ import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.* import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher +import com.tangem.domain.account.status.utils.CryptoCurrencyMetadataCleaner import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.networks.utils.NetworksCleaner +import com.tangem.domain.nft.utils.NFTCleaner import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher @@ -98,9 +100,7 @@ internal object AccountStatusUseCaseModule { derivationsRepository: DerivationsRepository, walletManagersFacade: WalletManagersFacade, cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, - stakingIdFactory: StakingIdFactory, - networksCleaner: NetworksCleaner, - stakingCleaner: StakingCleaner, + cryptoCurrencyMetadataCleaner: CryptoCurrencyMetadataCleaner, expressServiceFetcher: ExpressServiceFetcher, dispatchers: CoroutineDispatcherProvider, ): ManageCryptoCurrenciesUseCase { @@ -111,9 +111,7 @@ internal object AccountStatusUseCaseModule { derivationsRepository = derivationsRepository, walletManagersFacade = walletManagersFacade, cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, - stakingIdFactory = stakingIdFactory, - networksCleaner = networksCleaner, - stakingCleaner = stakingCleaner, + cryptoCurrencyMetadataCleaner = cryptoCurrencyMetadataCleaner, expressServiceFetcher = expressServiceFetcher, parallelUpdatingScope = CoroutineScope(SupervisorJob() + dispatchers.default), dispatchers = dispatchers, @@ -157,4 +155,20 @@ internal object AccountStatusUseCaseModule { dispatchers = dispatchers, ) } + + @Provides + @Singleton + fun provideCryptoCurrencyMetadataCleaner( + networksCleaner: NetworksCleaner, + stakingCleaner: StakingCleaner, + nftCleaner: NFTCleaner, + dispatchers: CoroutineDispatcherProvider, + ): CryptoCurrencyMetadataCleaner { + return CryptoCurrencyMetadataCleaner( + networksCleaner = networksCleaner, + stakingCleaner = stakingCleaner, + nftCleaner = nftCleaner, + dispatchers = dispatchers, + ) + } } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt index bf10f519ad..77367a7909 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt @@ -97,6 +97,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo } .distinctUntilChanged() } + is Account.Payment -> TODO("[REDACTED_JIRA]") } } } @@ -192,6 +193,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo priceChangeLce = lceLoading(), ) } + is Account.Payment -> TODO("[REDACTED_JIRA]") } }, totalAccounts = accountList.totalAccounts, diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt index 51ffcfa1e8..dd64e3d0cc 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt @@ -7,6 +7,7 @@ import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher +import com.tangem.domain.account.status.utils.CryptoCurrencyMetadataCleaner import com.tangem.domain.core.utils.eitherOn import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.express.models.ExpressAsset @@ -17,9 +18,6 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.networks.utils.NetworksCleaner -import com.tangem.domain.staking.StakingIdFactory -import com.tangem.domain.staking.utils.StakingCleaner import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.derivations.DerivationsRepository @@ -36,9 +34,7 @@ import timber.log.Timber * @property currenciesRepository Repository for managing currencies. * @property derivationsRepository Repository for deriving public keys. * @property cryptoCurrencyBalanceFetcher Fetcher for updating crypto currency balances. - * @property stakingIdFactory Factory for creating staking IDs. - * @property networksCleaner Cleaner for removing obsolete network data. - * @property stakingCleaner Cleaner for removing obsolete staking data. + * @property cryptoCurrencyMetadataCleaner Cleaner for removing metadata of deleted currencies. * @property expressServiceFetcher Fetcher for updating express service data. * @property parallelUpdatingScope Coroutine scope for parallel updates. * @property dispatchers Coroutine dispatchers for managing threading. @@ -53,9 +49,7 @@ class ManageCryptoCurrenciesUseCase( private val derivationsRepository: DerivationsRepository, private val walletManagersFacade: WalletManagersFacade, private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, - private val stakingIdFactory: StakingIdFactory, - private val networksCleaner: NetworksCleaner, - private val stakingCleaner: StakingCleaner, + private val cryptoCurrencyMetadataCleaner: CryptoCurrencyMetadataCleaner, private val expressServiceFetcher: ExpressServiceFetcher, private val parallelUpdatingScope: CoroutineScope, private val dispatchers: CoroutineDispatcherProvider, @@ -306,21 +300,12 @@ class ManageCryptoCurrenciesUseCase( if (currencies.isEmpty()) return coroutineScope { - listOf( - launch { networksCleaner(userWalletId = userWalletId, currencies = currencies) }, - launch { clearStaking(userWalletId = userWalletId, currencies = currencies) }, - ) + launch { + cryptoCurrencyMetadataCleaner(userWalletId = userWalletId, currencies = currencies) + } } } - private suspend fun clearStaking(userWalletId: UserWalletId, currencies: List) { - val stakingIds = currencies.mapNotNullTo(hashSetOf()) { - stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() - } - - stakingCleaner(userWalletId = userWalletId, stakingIds = stakingIds) - } - private data class TempID( val networkId: String, val derivationPath: Network.DerivationPath, diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyMetadataCleaner.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyMetadataCleaner.kt new file mode 100644 index 0000000000..15fdc5ff07 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyMetadataCleaner.kt @@ -0,0 +1,69 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.utils.NetworksCleaner +import com.tangem.domain.nft.utils.NFTCleaner +import com.tangem.domain.staking.utils.StakingCleaner +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext + +/** + * Class to clean up all data related to a specific cryptocurrency in a user's wallet. + * + * @property networksCleaner Utility to clean network-related data. + * @property stakingCleaner Utility to clean staking-related data. + * @property nftCleaner Utility to clean NFT-related data. + * @property dispatchers Coroutine dispatchers for managing threading. + * + * @see Notion + * +[REDACTED_AUTHOR] + */ +class CryptoCurrencyMetadataCleaner( + private val networksCleaner: NetworksCleaner, + private val stakingCleaner: StakingCleaner, + private val nftCleaner: NFTCleaner, + private val dispatchers: CoroutineDispatcherProvider, +) { + + /** + * Cleans up data for a single cryptocurrency in the specified user wallet. + * + * @param userWalletId The ID of the user's wallet. + * @param currency The cryptocurrency to be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency) { + invoke(userWalletId = userWalletId, currencies = listOf(currency)) + } + + /** + * Cleans up data for multiple cryptocurrencies in the specified user wallet. + * + * @param userWalletId The ID of the user's wallet. + * @param currencies The list of cryptocurrencies to be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, currencies: List) { + if (currencies.isEmpty()) return + + return withContext(dispatchers.default) { + awaitAll( + async { networksCleaner(userWalletId = userWalletId, currencies = currencies) }, + async { clearStaking(userWalletId = userWalletId, currencies = currencies) }, + async { clearNFTs(userWalletId = userWalletId, currencies = currencies) }, + ) + } + } + + private suspend fun clearStaking(userWalletId: UserWalletId, currencies: List) { + stakingCleaner(userWalletId = userWalletId, currencies = currencies) + } + + private suspend fun clearNFTs(userWalletId: UserWalletId, currencies: List) { + val networks = currencies.mapTo(destination = hashSetOf(), transform = CryptoCurrency::network) + + nftCleaner(userWalletId = userWalletId, networks = networks) + } +} \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyCleanerTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyCleanerTest.kt new file mode 100644 index 0000000000..e1ce153ad3 --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyCleanerTest.kt @@ -0,0 +1,74 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.utils.NetworksCleaner +import com.tangem.domain.nft.utils.NFTCleaner +import com.tangem.domain.staking.utils.StakingCleaner +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CryptoCurrencyCleanerTest { + + private val networksCleaner: NetworksCleaner = mockk(relaxUnitFun = true) + private val stakingCleaner: StakingCleaner = mockk(relaxUnitFun = true) + private val nftCleaner: NFTCleaner = mockk(relaxUnitFun = true) + + private val cleaner = CryptoCurrencyMetadataCleaner( + networksCleaner = networksCleaner, + stakingCleaner = stakingCleaner, + nftCleaner = nftCleaner, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("011") + + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + private val coin = cryptoCurrencyFactory.ethereum + private val token = cryptoCurrencyFactory.createToken(Blockchain.Ethereum) + + @AfterEach + fun tearDown() { + clearMocks(networksCleaner, stakingCleaner, nftCleaner) + } + + @Test + fun `should not call cleaners when currencies list is empty`() = runTest { + // Act + cleaner(userWalletId = userWalletId, currencies = emptyList()) + + // Assert + coVerify(inverse = true) { + networksCleaner(userWalletId = any(), currencies = any()) + stakingCleaner(userWalletId = any(), currencies = any()) + nftCleaner(userWalletId = any(), networks = any()) + } + } + + @Test + fun `should call both cleaners when currencies list is not empty`() = runTest { + // Arrange + val currencies = listOf(coin, token) + + // Act + cleaner(userWalletId = userWalletId, currencies = currencies) + + // Assert + coVerify { + networksCleaner(userWalletId = userWalletId, currencies = currencies) + stakingCleaner(userWalletId = userWalletId, currencies = currencies) + nftCleaner(userWalletId = userWalletId, networks = setOf(coin.network, token.network)) + } + } +} \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProvider.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProvider.kt index 9a26b89580..49b5797e0d 100644 --- a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProvider.kt +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProvider.kt @@ -16,6 +16,7 @@ import java.math.BigDecimal * @property privacyPolicy privacy policy link * @property isRecommended flag that indicates if this provider is recommended * @property slippage provider slippage + * @property isExchangeOnlyWithinSingleAddress flag that indicates if exchange is only allowed within a single address * * Uses to store transaction data in datastore, when extends - should always add default value * to support backward compatibility @@ -40,4 +41,6 @@ data class ExpressProvider( val isRecommended: Boolean = false, @Json(name = "slippage") val slippage: BigDecimal?, + @Json(name = "exchangeOnlyWithinSingleAddress") + val isExchangeOnlyWithinSingleAddress: Boolean = false, ) \ No newline at end of file diff --git a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt index 520f49e395..1dae5fb656 100644 --- a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt +++ b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/FeedbackEmailType.kt @@ -18,6 +18,9 @@ sealed interface FeedbackEmailType { /** User rate the app as "can be better" */ data class RateCanBeBetter(override val walletMetaInfo: WalletMetaInfo) : FeedbackEmailType + /** User has problem with backup */ + data class BackupProblem(override val walletMetaInfo: WalletMetaInfo) : FeedbackEmailType + /** User has problem with scanning */ data object ScanningProblem : FeedbackEmailType { override val walletMetaInfo: WalletMetaInfo? = null @@ -81,5 +84,7 @@ sealed interface FeedbackEmailType { ) : Visa() data class FeatureIsBeta(override val walletMetaInfo: WalletMetaInfo) : Visa() + + data class KycRejected(override val walletMetaInfo: WalletMetaInfo, val customerId: String) : Visa() } } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt index 00815bea99..d10954f517 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt @@ -54,6 +54,10 @@ internal class FeedbackDataBuilder { builder.appendKeyValue("User Wallet ID", userWalletId) } + fun addCustomerId(customerId: String) { + builder.appendKeyValue("ID: ", customerId) + } + fun addUserWalletMetaInfo(walletMetaInfo: WalletMetaInfo) { builder.appendKeyValue("Mobile Wallet is backed up", walletMetaInfo.hotWalletIsBackedUp?.toString()) builder.appendKeyValue("Card ID", walletMetaInfo.cardId) @@ -116,6 +120,7 @@ internal class FeedbackDataBuilder { is FeedbackEmailType.Visa.Dispute, is FeedbackEmailType.Visa.FeatureIsBeta, is FeedbackEmailType.Visa.Withdrawal, + is FeedbackEmailType.Visa.KycRejected, -> return is FeedbackEmailType.Visa.DisputeV2 -> when (type.item) { is TangemPayTxHistoryItem.Collateral -> "Receive/Withdraw" diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt index 07e0eb137e..f1228c50c6 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt @@ -48,6 +48,7 @@ class SendFeedbackEmailUseCase( private fun getAddress(type: FeedbackEmailType): String { return when { + type is FeedbackEmailType.Visa.KycRejected -> PAERA_SUPPORT_EMAIL type.isVisaEmail() -> TANGEM_VISA_SUPPORT_EMAIL type.walletMetaInfo?.isStart2Coin == true -> START2COIN_SUPPORT_EMAIL else -> TANGEM_SUPPORT_EMAIL @@ -95,8 +96,10 @@ class SendFeedbackEmailUseCase( is FeedbackEmailType.Visa.Dispute, is FeedbackEmailType.Visa.DisputeV2, is FeedbackEmailType.Visa.FeatureIsBeta, + is FeedbackEmailType.Visa.KycRejected, -> this is FeedbackEmailType.DirectUserRequest, + is FeedbackEmailType.BackupProblem, is FeedbackEmailType.RateCanBeBetter, is FeedbackEmailType.StakingProblem, is FeedbackEmailType.SwapProblem, diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt index 1e4ca44bd2..ec9d994636 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt @@ -28,6 +28,7 @@ internal class EmailMessageBodyResolver( is FeedbackEmailType.SwapProblem -> addSwapProblemBody(type) is FeedbackEmailType.CurrencyDescriptionError -> addTokenInfo(type) is FeedbackEmailType.PreActivatedWallet -> addUserRequestBody(type.walletMetaInfo) + is FeedbackEmailType.BackupProblem -> addUserRequestBody(type.walletMetaInfo) is FeedbackEmailType.ScanningProblem, is FeedbackEmailType.CardAttestationFailed, -> addPhoneInfoBody() @@ -38,6 +39,7 @@ internal class EmailMessageBodyResolver( is FeedbackEmailType.Visa.DisputeV2 -> addTangemPayDisputeRequestBody(type) is FeedbackEmailType.Visa.Withdrawal -> addTangemPayWithdrawalRequestBody(type) is FeedbackEmailType.Visa.FeatureIsBeta -> addTangemPayBetaRequestBody(type) + is FeedbackEmailType.Visa.KycRejected -> addTangemPayKycRejectedRequestBody(type) } return build() @@ -69,6 +71,12 @@ internal class EmailMessageBodyResolver( } } + private fun FeedbackDataBuilder.addTangemPayKycRejectedRequestBody(type: FeedbackEmailType.Visa.KycRejected) { + addTangemPayPhoneInfoBody(type) + addDelimiter() + addCustomerId(customerId = type.customerId) + } + private suspend fun FeedbackDataBuilder.addTangemPayWithdrawalRequestBody( type: FeedbackEmailType.Visa.Withdrawal, ) { diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt index 36940c948e..4064444636 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt @@ -27,7 +27,9 @@ internal class EmailMessageTitleResolver(private val resources: Resources) { is FeedbackEmailType.Visa.FailedIssueCard, is FeedbackEmailType.Visa.Withdrawal, is FeedbackEmailType.Visa.FeatureIsBeta, + is FeedbackEmailType.Visa.KycRejected, is FeedbackEmailType.PreActivatedWallet, + is FeedbackEmailType.BackupProblem, -> R.string.feedback_preface_support is FeedbackEmailType.RateCanBeBetter -> R.string.feedback_preface_rate_negative is FeedbackEmailType.ScanningProblem -> R.string.feedback_preface_scan_failed diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt index 4b1e4f9799..451c9a1beb 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt @@ -25,6 +25,7 @@ internal class EmailSubjectResolver(private val resources: Resources) { resources.getStringSafe(R.string.feedback_subject_support_tangem) } } + is FeedbackEmailType.BackupProblem -> resources.getStringSafe(R.string.feedback_subject_backup_problem) is FeedbackEmailType.RateCanBeBetter -> resources.getStringSafe(R.string.feedback_subject_rate_negative) is FeedbackEmailType.ScanningProblem -> resources.getStringSafe(R.string.feedback_subject_scan_failed) is FeedbackEmailType.TransactionSendingProblem, @@ -46,6 +47,7 @@ internal class EmailSubjectResolver(private val resources: Resources) { -> "[Visa] [DISPUTE] {auto-filled subject}" is FeedbackEmailType.Visa.Withdrawal -> "[Visa] [WITHDRAWAL] {auto-filled subject}" is FeedbackEmailType.Visa.FeatureIsBeta -> "[VISA] [FEEDBACK]" + is FeedbackEmailType.Visa.KycRejected -> "Tangem Pay - [VISA] [KYC REJECTED]" } } } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt index e1ec372cc9..c1bc6d1043 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt @@ -4,4 +4,5 @@ internal const val GMAIL_MAX_FILE_SIZE = 24_900_000 // ≈ 25 MB internal const val START2COIN_SUPPORT_EMAIL = "cardsupport@start2coin.com" internal const val TANGEM_SUPPORT_EMAIL = "support@tangem.com" -internal const val TANGEM_VISA_SUPPORT_EMAIL = "pay@tangem.com" \ No newline at end of file +internal const val TANGEM_VISA_SUPPORT_EMAIL = "pay@tangem.com" +internal const val PAERA_SUPPORT_EMAIL = "support@paera.com" \ No newline at end of file diff --git a/domain/legacy/src/main/assets/contract_methods.json b/domain/legacy/src/main/assets/contract_methods.json index fe538d3526..5f421e8506 100644 --- a/domain/legacy/src/main/assets/contract_methods.json +++ b/domain/legacy/src/main/assets/contract_methods.json @@ -236,5 +236,20 @@ "info": "yieldModule", "source": "https://github.com/tangem/tangem-yield-module-contracts/", "name": "supplyTopUp" + }, + "0xf9609f08": { + "info": "P2PEthPool", + "source": "https://docs.p2p.org/", + "name": "pooledStake" + }, + "0xac9650d8": { + "info": "P2PEthPool", + "source": "https://docs.p2p.org/", + "name": "pooledUnstake" + }, + "0xaa03b617": { + "info": "GaslessTransactions", + "source": "https://github.com/tangem-developments/tangem-gasless-service", + "name": "gaslessTransaction" } } diff --git a/domain/markets/detekt-baseline-debug.xml b/domain/markets/detekt-baseline-debug.xml deleted file mode 100644 index 64d62630c1..0000000000 --- a/domain/markets/detekt-baseline-debug.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - BooleanPropertyNaming:FilterAvailableNetworksForWalletUseCase.kt$FilterAvailableNetworksForWalletUseCase$private val useNewRepository: Boolean - BooleanPropertyNaming:GetStakingNotificationMaxApyUseCase.kt$GetStakingNotificationMaxApyUseCase$val showStakingNotification = if (!hideClicked && walletFirstUsageDate != 0L) { currentDate - walletFirstUsageDate > TWO_WEEKS_IN_MILLIS } else { false } - MultilineLambdaItParameter:FilterAvailableNetworksForWalletUseCase.kt$FilterAvailableNetworksForWalletUseCase${ val blockchain = Blockchain.fromNetworkId(it.networkId) supportedBlockchains.contains(blockchain) } - SuspendFunWithFlowReturnType:GetStakingNotificationMaxApyUseCase.kt$GetStakingNotificationMaxApyUseCase$suspend - SuspendFunWithFlowReturnType:MarketsTokenRepository.kt$MarketsTokenRepository$suspend - - diff --git a/domain/markets/models/detekt-baseline-main.xml b/domain/markets/models/detekt-baseline-main.xml deleted file mode 100644 index 17228539ba..0000000000 --- a/domain/markets/models/detekt-baseline-main.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - BooleanPropertyNaming:TokenMarketInfo.kt$TokenMarketInfo.Network$val exchangeable: Boolean - - diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt index 5354432e4c..bbd7a840ce 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt @@ -23,7 +23,7 @@ data class TokenMarketInfo( @Serializable data class Network( val networkId: String, - val exchangeable: Boolean, + val isExchangeable: Boolean, val contractAddress: String?, val decimalCount: Int?, ) diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/FilterAvailableNetworksForWalletUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/FilterAvailableNetworksForWalletUseCase.kt index 467ab832d1..aaf389d312 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/FilterAvailableNetworksForWalletUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/FilterAvailableNetworksForWalletUseCase.kt @@ -12,7 +12,7 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager class FilterAvailableNetworksForWalletUseCase( private val userWalletsListManager: UserWalletsListManager, private val userWalletsListRepository: UserWalletsListRepository, - private val useNewRepository: Boolean, + private val shouldUseNewRepository: Boolean, private val excludedBlockchains: ExcludedBlockchains, ) { @@ -32,13 +32,13 @@ class FilterAvailableNetworksForWalletUseCase( excludedBlockchains = excludedBlockchains, ) - return networks.filter { - val blockchain = Blockchain.fromNetworkId(it.networkId) + return networks.filter { network -> + val blockchain = Blockchain.fromNetworkId(network.networkId) supportedBlockchains.contains(blockchain) }.toSet() } - private fun getWallets() = if (useNewRepository) { + private fun getWallets() = if (shouldUseNewRepository) { userWalletsListRepository.requireUserWalletsSync() } else { userWalletsListManager.userWalletsSync diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt index 4b0f60f75a..af4f1fd430 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt @@ -174,4 +174,15 @@ sealed interface Account { } } } + + class Payment : Account { + override val accountId: AccountId + get() = TODO("Not yet implemented") + override val accountName: AccountName + get() = TODO("Not yet implemented") + + init { + error("Not yet implemented") + } + } } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt index aae35a95f1..2e0c0b37b7 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt @@ -107,6 +107,9 @@ data class TxInfo( @Serializable data class Operation(val name: String) : TransactionType + @Serializable + data object GaslessFee : TransactionType + @Serializable sealed interface YieldSupply : TransactionType { diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/news/DetailedArticle.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/DetailedArticle.kt index ea41bfe17f..94cc02be56 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/news/DetailedArticle.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/DetailedArticle.kt @@ -18,6 +18,7 @@ import kotlinx.serialization.Serializable * @param shortContent - short description * @param content - main text of the article * @param originalArticles - original articles, which were base to build detailed article. + * @param isLiked - flag indicating whether the news is liked or not */ @Serializable data class DetailedArticle( @@ -33,4 +34,5 @@ data class DetailedArticle( val shortContent: String, val content: String, val originalArticles: List, + val isLiked: Boolean, ) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/news/NewsError.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/NewsError.kt index 5055eacbbf..f92b62ca6f 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/news/NewsError.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/NewsError.kt @@ -1,19 +1,11 @@ package com.tangem.domain.models.news -import kotlinx.serialization.Serializable +sealed interface NewsError { -@Serializable -sealed class NewsError { - abstract val message: String? - abstract val code: Int? + data class HttpError( + val code: Int, + val message: String, + ) : NewsError - data class ArticleNotFound( - override val message: String?, - override val code: Int?, - ) : NewsError() - - data class Unknown( - override val message: String?, - override val code: Int?, - ) : NewsError() + data object NotHttpError : NewsError } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/news/OriginalArticle.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/OriginalArticle.kt index d087148b79..83c59e20c0 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/news/OriginalArticle.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/OriginalArticle.kt @@ -8,7 +8,7 @@ import kotlinx.serialization.Serializable [REDACTED_AUTHOR] * @param id - unique identifier of the article * @param title - article title - * @param sourceName - name of original article source + * @param source - object of source name and identifier * @param locale - language of the article * @param publishedAt - date of article publishing * @param url - link to source of original article @@ -18,9 +18,15 @@ import kotlinx.serialization.Serializable data class OriginalArticle( val id: Int, val title: String, - val sourceName: String, + val source: Source, val locale: String, val publishedAt: String, val url: String, val imageUrl: String?, +) + +@Serializable +data class Source( + val id: Int, + val name: String, ) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/news/TrendingNews.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/TrendingNews.kt index d3b0bbdf87..ec4a742686 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/news/TrendingNews.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/TrendingNews.kt @@ -12,5 +12,5 @@ sealed interface TrendingNews { data class Data(val articles: List) : TrendingNews @Serializable - data class Error(val throwable: NewsError) : TrendingNews + data class Error(val error: NewsError) : TrendingNews } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/BalanceItemExt.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/BalanceItemExt.kt new file mode 100644 index 0000000000..b71f5b9b52 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/BalanceItemExt.kt @@ -0,0 +1,27 @@ +package com.tangem.domain.models.staking + +import com.tangem.domain.models.staking.StakingEntryType.Companion.fromBalanceType + +fun BalanceItem.toStakingBalanceEntry(validatorName: String? = null): StakingBalanceEntry { + return StakingBalanceEntry( + id = groupId, + type = fromBalanceType(type), + amount = amount, + validator = validatorAddress?.let { + ValidatorInfo(address = it, name = validatorName) + }, + date = date, + actions = StakingEntryActions.StakeKit( + pendingActions = pendingActions, + pendingActionsConstraints = pendingActionsConstraints, + ), + isPending = isPending, + rawCurrencyId = rawCurrencyId, + ) +} + +fun List.toStakingBalanceEntries( + validatorNameResolver: (String?) -> String? = { null }, +): List { + return map { it.toStakingBalanceEntry(validatorNameResolver(it.validatorAddress)) } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PStakingAccount.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccount.kt similarity index 66% 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..a6d8489e14 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,34 +4,34 @@ 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, - val withdrawalTimestamp: Instant, + val withdrawalTimestamp: Instant?, val isClaimable: Boolean, ) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccountExt.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccountExt.kt new file mode 100644 index 0000000000..69db40bd72 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccountExt.kt @@ -0,0 +1,86 @@ +package com.tangem.domain.models.staking + +import java.math.BigDecimal + +fun P2PEthPoolStakingAccount.toStakingBalanceEntries(vaultName: String? = null): List { + return buildList { + if (stake.assets > BigDecimal.ZERO) { + add(createStakedEntry(vaultAddress, stake.assets, vaultName)) + } + exitQueue.requests.filter { !it.isClaimable }.forEach { add(createUnstakingEntry(vaultAddress, it, vaultName)) } + if (availableToWithdraw > BigDecimal.ZERO) { + add(createWithdrawableEntry(vaultAddress, availableToWithdraw, vaultName)) + } + if (stake.totalEarnedAssets > BigDecimal.ZERO) { + add(createRewardsEntry(vaultAddress, stake.totalEarnedAssets, vaultName)) + } + } +} + +private fun createStakedEntry(vaultAddress: String, amount: BigDecimal, vaultName: String?): StakingBalanceEntry { + return StakingBalanceEntry( + id = vaultAddress, + type = StakingEntryType.STAKED, + amount = amount, + validator = ValidatorInfo(address = vaultAddress, name = vaultName), + date = null, + actions = StakingEntryActions.P2PEthPool(ticket = null, estimatedWithdrawalDate = null, isClaimable = false), + isPending = false, + rawCurrencyId = null, + ) +} + +private fun createUnstakingEntry( + vaultAddress: String, + request: P2PEthPoolExitRequest, + vaultName: String?, +): StakingBalanceEntry { + return StakingBalanceEntry( + id = "${vaultAddress}_${request.ticket}", + type = StakingEntryType.UNSTAKING, + amount = request.totalAssets, + validator = ValidatorInfo(address = vaultAddress, name = vaultName), + date = request.withdrawalTimestamp, + actions = StakingEntryActions.P2PEthPool( + ticket = request.ticket, + estimatedWithdrawalDate = request.withdrawalTimestamp, + isClaimable = false, + ), + isPending = false, + rawCurrencyId = null, + ) +} + +private fun createWithdrawableEntry( + vaultAddress: String, + amount: BigDecimal, + vaultName: String?, +): StakingBalanceEntry { + return StakingBalanceEntry( + id = "${vaultAddress}_withdrawable", + type = StakingEntryType.WITHDRAWABLE, + amount = amount, + validator = ValidatorInfo(address = vaultAddress, name = vaultName), + date = null, + actions = StakingEntryActions.P2PEthPool( + ticket = null, + estimatedWithdrawalDate = null, + isClaimable = true, + ), + isPending = false, + rawCurrencyId = null, + ) +} + +private fun createRewardsEntry(vaultAddress: String, amount: BigDecimal, vaultName: String?): StakingBalanceEntry { + return StakingBalanceEntry( + id = "${vaultAddress}_rewards", + type = StakingEntryType.REWARDS, + amount = amount, + validator = ValidatorInfo(address = vaultAddress, name = vaultName), + date = null, + actions = StakingEntryActions.P2PEthPool(ticket = null, estimatedWithdrawalDate = null, isClaimable = false), + isPending = false, + rawCurrencyId = null, + ) +} \ No newline at end of file diff --git a/domain/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..11c22e2ae4 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt @@ -1,11 +1,12 @@ package com.tangem.domain.models.staking import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.serialization.SerializedBigDecimal import kotlinx.serialization.Serializable import java.math.BigDecimal /** - * Staking balance facade covering StakeKit and P2P balances + * Staking balance facade covering StakeKit and P2PEthPool balances */ @Serializable sealed interface StakingBalance { @@ -21,6 +22,9 @@ sealed interface StakingBalance { @Serializable sealed interface Data : StakingBalance { + /** Provider-agnostic list of balance entries for UI display */ + val entries: List + @Serializable data class StakeKit( override val stakingId: StakingID, @@ -28,45 +32,41 @@ sealed interface StakingBalance { val balance: YieldBalanceItem, ) : Data { - override val totalStaked: BigDecimal - get() = balance.items - .filter { it.type == BalanceType.STAKED } - .sumOf { it.amount } + override val totalStaked: SerializedBigDecimal = balance.items + .filter { it.type == BalanceType.STAKED } + .sumOf { it.amount } - override val totalRewards: BigDecimal - get() = balance.items - .filter { it.type == BalanceType.REWARDS } - .sumOf { it.amount } + override val totalRewards: SerializedBigDecimal = balance.items + .filter { it.type == BalanceType.REWARDS } + .sumOf { it.amount } - override val unstakingAmount: BigDecimal - get() = balance.items - .filter { it.type == BalanceType.UNSTAKING || it.type == BalanceType.UNLOCKING } - .sumOf { it.amount } + override val unstakingAmount: SerializedBigDecimal = balance.items + .filter { it.type == BalanceType.UNSTAKING || it.type == BalanceType.UNLOCKING } + .sumOf { it.amount } - override val withdrawableAmount: BigDecimal - get() = balance.items - .filter { it.type == BalanceType.UNSTAKED } - .sumOf { it.amount } + override val withdrawableAmount: SerializedBigDecimal = balance.items + .filter { it.type == BalanceType.UNSTAKED } + .sumOf { it.amount } + + override val entries: List = balance.items.toStakingBalanceEntries() } @Serializable - data class P2P( + data class P2PEthPool( override val stakingId: StakingID, override val source: StatusSource, - val account: P2PStakingAccount, + val accounts: List, ) : Data { - override val totalStaked: BigDecimal - get() = account.stake.assets + override val totalStaked: SerializedBigDecimal = accounts.sumOf { it.stake.assets } - override val totalRewards: BigDecimal - get() = account.stake.totalEarnedAssets + override val totalRewards: SerializedBigDecimal = accounts.sumOf { it.stake.totalEarnedAssets } - override val unstakingAmount: BigDecimal - get() = account.exitQueue.total + override val unstakingAmount: SerializedBigDecimal = accounts.sumOf { it.exitQueue.total } - override val withdrawableAmount: BigDecimal - get() = account.availableToWithdraw + override val withdrawableAmount: SerializedBigDecimal = accounts.sumOf { it.availableToWithdraw } + + override val entries: List = accounts.flatMap { it.toStakingBalanceEntries() } } } @@ -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/StakingBalanceEntry.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalanceEntry.kt new file mode 100644 index 0000000000..bd38711c65 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalanceEntry.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.models.staking + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class StakingBalanceEntry( + val id: String, + val type: StakingEntryType, + val amount: SerializedBigDecimal, + val validator: ValidatorInfo?, + val date: Instant?, + val actions: StakingEntryActions, + val isPending: Boolean, + val rawCurrencyId: String?, +) + +@Serializable +data class ValidatorInfo( + val address: String, + val name: String?, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryActions.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryActions.kt new file mode 100644 index 0000000000..da7f7c48e8 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryActions.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.models.staking + +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +sealed interface StakingEntryActions { + + @Serializable + data class StakeKit( + val pendingActions: List, + val pendingActionsConstraints: List, + ) : StakingEntryActions { + val hasPendingActions: Boolean get() = pendingActions.isNotEmpty() + } + + @Serializable + data class P2PEthPool( + val ticket: String?, + val estimatedWithdrawalDate: Instant?, + val isClaimable: Boolean, + ) : StakingEntryActions +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryType.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryType.kt new file mode 100644 index 0000000000..ec0f416e45 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryType.kt @@ -0,0 +1,31 @@ +package com.tangem.domain.models.staking + +import kotlinx.serialization.Serializable + +@Serializable +enum class StakingEntryType { + AVAILABLE, + STAKED, + PREPARING, + LOCKED, + UNSTAKING, + UNLOCKING, + WITHDRAWABLE, + REWARDS, + UNKNOWN, + ; + + companion object { + fun fromBalanceType(type: BalanceType): StakingEntryType = when (type) { + BalanceType.AVAILABLE -> AVAILABLE + BalanceType.STAKED -> STAKED + BalanceType.PREPARING -> PREPARING + BalanceType.LOCKED -> LOCKED + BalanceType.UNSTAKING -> UNSTAKING + BalanceType.UNLOCKING -> UNLOCKING + BalanceType.UNSTAKED -> WITHDRAWABLE // stakekit's UNSTAKED = ready to withdraw + BalanceType.REWARDS -> REWARDS + BalanceType.UNKNOWN -> UNKNOWN + } + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/YieldBalanceItem.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/YieldBalanceItem.kt index c8dbe3b3d9..198e8a00f7 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/YieldBalanceItem.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/YieldBalanceItem.kt @@ -111,6 +111,8 @@ sealed interface RewardBlockType { data object NoRewards : RewardBlockType data object Rewards : RewardBlockType data object RewardsRequirementsError : RewardBlockType + data object EthereumEarnedRewards : RewardBlockType + sealed interface RewardUnavailable : RewardBlockType { data object DefaultRewardUnavailable : RewardUnavailable data object SolanaRewardUnavailable : RewardUnavailable @@ -124,6 +126,7 @@ sealed interface RewardBlockType { get() = when (this) { is NoRewards, is RewardUnavailable, + is EthereumEarnedRewards, -> false is RewardsRequirementsError, is Rewards, diff --git a/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/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt b/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt index 5db343c0c4..4a5ac9acd8 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt @@ -10,6 +10,16 @@ import com.tangem.domain.models.wallet.UserWalletId */ interface NetworksCleaner { + /** + * Cleans up network-related data for the given [userWalletId] and [currency]. + * + * @param userWalletId The ID of the user wallet for which to clean up data. + * @param currency The cryptocurrency whose associated network data should be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency) { + invoke(userWalletId = userWalletId, currencies = listOf(currency)) + } + /** * Cleans up network-related data for the given [userWalletId] and list of [currencies]. * diff --git a/domain/news/src/main/java/com/tangem/domain/news/NewsErrorResolver.kt b/domain/news/src/main/java/com/tangem/domain/news/NewsErrorResolver.kt new file mode 100644 index 0000000000..d4ece74780 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/NewsErrorResolver.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.news + +import com.tangem.domain.models.news.NewsError + +interface NewsErrorResolver { + + fun resolve(throwable: Throwable?): NewsError +} \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt b/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt index f9f69c5b22..67423a773a 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt @@ -15,7 +15,7 @@ import kotlinx.serialization.Serializable @Serializable data class NewsListConfig( val language: String, - val snapshot: String, + val snapshot: String?, val tokenIds: List = emptyList(), val categoryIds: List = emptyList(), ) \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt index 3b9e06ce82..44266d3935 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt @@ -1,10 +1,13 @@ package com.tangem.domain.news.repository +import arrow.core.Either import com.tangem.domain.models.news.ArticleCategory import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.models.news.ShortArticle import com.tangem.domain.models.news.TrendingNews import com.tangem.domain.news.model.NewsListBatchFlow import com.tangem.domain.news.model.NewsListBatchingContext +import com.tangem.domain.news.model.NewsListConfig import kotlinx.coroutines.flow.Flow /** @@ -21,11 +24,11 @@ interface NewsRepository { fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow /** - * Returns detailed article by id with locale configuration. - * @param newsId news identification - * @param language current locale + * Returns flow of list of short article by config. + * + * @param config config for getting news list */ - suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle + fun getNews(config: NewsListConfig, limit: Int): Flow> /** * Observes cached detailed articles. @@ -35,7 +38,7 @@ interface NewsRepository { /** * Fetches and caches detailed articles for provided ids in parallel. */ - suspend fun fetchDetailedArticles(newsIds: Collection, language: String?) + suspend fun fetchDetailedArticles(newsIds: Collection, language: String?): Either, Unit> /** * Fetch list of trending news by limit and with correct locale and store it in runtime data store. @@ -50,13 +53,18 @@ interface NewsRepository { */ fun observeTrendingNews(): Flow - /** - * Updates viewed flag for provided trending articles. - */ - suspend fun updateTrendingNewsViewed(articleIds: Collection, viewed: Boolean) - /** * Returns available categories. */ suspend fun getCategories(): List + + /** + * Updates viewed flag for provided news articles (applies to both regular and trending news). + */ + suspend fun updateNewsViewed(articleIds: Collection, viewed: Boolean) + + /** + * Toggle news liked state + */ + suspend fun toggleNewsLiked(articleId: Int) } \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt index 35e1f8f0bb..aa8b308d94 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt @@ -1,5 +1,6 @@ package com.tangem.domain.news.usecase +import arrow.core.Either import com.tangem.domain.models.news.ArticleCategory import com.tangem.domain.news.repository.NewsRepository @@ -15,7 +16,7 @@ class GetNewsCategoriesUseCase( /** * Fetches categories from the repository. */ - suspend operator fun invoke(): List { - return repository.getCategories() + suspend operator fun invoke(): Either> = Either.catch { + repository.getCategories() } } \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt new file mode 100644 index 0000000000..adaa426bfd --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.news.usecase + +import arrow.core.Either +import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.news.model.NewsListConfig +import com.tangem.domain.news.repository.NewsRepository +import kotlinx.coroutines.flow.Flow + +class GetNewsUseCase(private val repository: NewsRepository) { + + fun getNews(limit: Int, newsListConfig: NewsListConfig): Either>> = + Either.catch { + repository.getNews( + config = newsListConfig, + limit = limit, + ) + } +} \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt index 6d76aaa9f6..113bf347aa 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt @@ -20,18 +20,4 @@ class ManageTrendingNewsUseCase(private val repository: NewsRepository) { .observeTrendingNews() .distinctUntilChanged() } - - /** - * Marks a single article as viewed/unviewed. - */ - suspend fun markAsViewed(articleId: Int, viewed: Boolean = true) { - repository.updateTrendingNewsViewed(listOf(articleId), viewed) - } - - /** - * Marks multiple articles at once (useful for bulk updates). - */ - suspend fun markAsViewed(articleIds: Collection, viewed: Boolean = true) { - repository.updateTrendingNewsViewed(articleIds, viewed) - } } \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/MarkArticleAsViewedUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/MarkArticleAsViewedUseCase.kt new file mode 100644 index 0000000000..523694c2d5 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/MarkArticleAsViewedUseCase.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.news.usecase + +import com.tangem.domain.news.repository.NewsRepository + +class MarkArticleAsViewedUseCase(private val repository: NewsRepository) { + + /** + * Marks a single article as viewed/unviewed. + */ + suspend fun markAsViewed(articleId: Int, viewed: Boolean = true) { + repository.updateNewsViewed(listOf(articleId), viewed) + } + + /** + * Marks multiple articles at once (useful for bulk updates). + */ + suspend fun markAsViewed(articleIds: Collection, viewed: Boolean = true) { + repository.updateNewsViewed(articleIds, viewed) + } +} \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt index ea0dc371d9..06d9678f90 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt @@ -1,5 +1,6 @@ package com.tangem.domain.news.usecase +import arrow.core.Either import com.tangem.domain.models.news.DetailedArticle import com.tangem.domain.news.repository.NewsRepository import kotlinx.coroutines.flow.Flow @@ -23,7 +24,6 @@ class ObserveNewsDetailsUseCase( /** * Prefetches the given article ids (can be called with current + next ids for pager preloading). */ - suspend fun prefetch(newsIds: Collection, language: String?) { + suspend fun prefetch(newsIds: Collection, language: String?): Either, Unit> = repository.fetchDetailedArticles(newsIds, language) - } } \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ToggleArticleLikedUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ToggleArticleLikedUseCase.kt new file mode 100644 index 0000000000..dfccde2c5b --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ToggleArticleLikedUseCase.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.news.usecase + +import arrow.core.Either +import com.tangem.domain.news.repository.NewsRepository + +class ToggleArticleLikedUseCase(private val repository: NewsRepository) { + + /** + * Toggle an article liked state. + */ + suspend fun toggleLiked(articleId: Int) = Either.catch { + repository.toggleNewsLiked(articleId) + } +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt index db4c13cacc..95b2fd53cb 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt @@ -34,15 +34,15 @@ class GetNFTCollectionsUseCase( @OptIn(ExperimentalCoroutinesApi::class) fun invokeForAccounts(userWalletId: UserWalletId): Flow { - fun Account.flowOfNFTCollections(): Flow>> { + fun Account.flowOfNFTCollections(): Flow>>? { val currencies = (this as? Account.CryptoPortfolio)?.cryptoCurrencies.orEmpty() - + if (currencies.isEmpty()) return null return nftCollections(userWalletId = userWalletId, cryptoCurrencies = currencies.toList()) .map { nfts -> this to nfts } } return singleAccountListSupplier(userWalletId) - .mapLatest { statusList -> statusList.accounts.map { it.flowOfNFTCollections() } } + .mapLatest { statusList -> statusList.accounts.mapNotNull { it.flowOfNFTCollections() } } .flatMapLatest { flows -> combine(flows) { WalletNFTCollections(it.toMap()) } } } @@ -53,6 +53,7 @@ class GetNFTCollectionsUseCase( val networks = cryptoCurrencies .map { cryptoCurrency -> cryptoCurrency.network } .distinct() + if (networks.isEmpty()) return flowOf(emptyList()) return nftRepository.observeCollections(userWalletId, networks) } } \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/ObserveAndClearNFTCacheIfNeedUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/ObserveAndClearNFTCacheIfNeedUseCase.kt deleted file mode 100644 index 86e2e2ac34..0000000000 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/ObserveAndClearNFTCacheIfNeedUseCase.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.tangem.domain.nft - -import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles -import com.tangem.domain.account.models.AccountList -import com.tangem.domain.account.supplier.SingleAccountListSupplier -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.nft.utils.NFTCleaner -import com.tangem.domain.tokens.repository.CurrenciesRepository -import kotlinx.coroutines.flow.* - -class ObserveAndClearNFTCacheIfNeedUseCase( - private val nftCleaner: NFTCleaner, - private val currenciesRepository: CurrenciesRepository, - private val accountsFeatureToggles: AccountsFeatureToggles, - private val singleAccountListSupplier: SingleAccountListSupplier, -) { - - operator fun invoke(userWalletId: UserWalletId): Flow> = getCryptoCurrencies(userWalletId) - .map { it.map(CryptoCurrency::network) } - .mapDiff { old, new -> - // calculate networks sets difference to determine which networks were removed - old.toSet() - new.toSet() - } - .distinctUntilChanged() - .onEach { removedNetworks -> - if (removedNetworks.isNotEmpty()) { - nftCleaner(userWalletId, removedNetworks) - } - } - - private fun getCryptoCurrencies(userWalletId: UserWalletId): Flow> { - return if (accountsFeatureToggles.isFeatureEnabled) { - singleAccountListSupplier(userWalletId).map(AccountList::flattenCurrencies) - } else { - currenciesRepository.getWalletCurrenciesUpdates(userWalletId) - } - } - - private fun Flow.mapDiff(diff: (old: T, new: T) -> R): Flow = flow { - var previous: T? = null - collect { current -> - val prev = previous - if (prev != null) { - emit(diff(prev, current)) - } - previous = current - } - } -} \ No newline at end of file diff --git a/domain/staking/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/detekt-baseline-debug.xml b/domain/staking/detekt-baseline-debug.xml index ced29d17bf..f52428665c 100644 --- a/domain/staking/detekt-baseline-debug.xml +++ b/domain/staking/detekt-baseline-debug.xml @@ -5,7 +5,6 @@ MultilineLambdaItParameter:FetchStakingYieldBalanceUseCase.kt$FetchStakingYieldBalanceUseCase${ when (it) { is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$it")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } return@either } MultilineLambdaItParameter:InvalidatePendingTransactionsUseCase.kt$InvalidatePendingTransactionsUseCase${ !it.isPending && action.amount < it.amount && it.type == BalanceType.STAKED && it.validatorAddress == action.validatorAddress } NamedArguments:GetConstructedStakingTransactionUseCase.kt$GetConstructedStakingTransactionUseCase$constructTransaction(networkId, fee, amount, transactionId) - NullableToStringCall:StakingApyFlowUseCase.kt$StakingApyFlowUseCase$${yield.token.coinGeckoId} UnnecessaryAbstractClass:MultiStakingBalanceSupplier.kt$MultiStakingBalanceSupplier$MultiStakingBalanceSupplier UnnecessaryAbstractClass:SingleStakingBalanceSupplier.kt$SingleStakingBalanceSupplier$SingleStakingBalanceSupplier UseEmptyCounterpart:StakingAnalyticsEvent.kt$StakingAnalyticsEvent$mapOf() diff --git a/domain/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..9d7b65a30e --- /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 for display (validator logo or vault icon) */ + val image: StakingTargetImage? + + /** 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: StakingTargetImage? = delegate.image?.let { StakingTargetImage.Url(it) } + 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 = StakingTargetImage.Local(StakingLocalImageType.P2P_VAULT) + 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/StakingTargetImage.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingTargetImage.kt new file mode 100644 index 0000000000..bc6a9eae07 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingTargetImage.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.staking.model + +sealed interface StakingTargetImage { + data class Url(val url: String) : StakingTargetImage + data class Local(val type: StakingLocalImageType) : StakingTargetImage +} + +enum class StakingLocalImageType { + P2P_VAULT, +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/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 deleted file mode 100644 index 491c28a775..0000000000 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.tangem.domain.staking.model.ethpool - -import com.tangem.domain.models.serialization.SerializedBigDecimal -import org.joda.time.Instant - -/** - * P2P.org account staking information - * Contains detailed balance and exit queue information - */ -data class P2PEthPoolAccount( - val delegatorAddress: String, - val vaultAddress: String, - val stake: P2PEthPoolStake, - val availableToUnstake: SerializedBigDecimal, - val availableToWithdraw: SerializedBigDecimal, - val exitQueue: P2PEthPoolExitQueue, -) - -/** - * Current stake information - */ -data class P2PEthPoolStake( - val assets: SerializedBigDecimal, - val totalEarnedAssets: SerializedBigDecimal, -) - -/** - * Exit queue information - */ -data class P2PEthPoolExitQueue( - val total: SerializedBigDecimal, - val requests: List, -) - -/** - * Individual exit request in the queue - */ -data class P2PEthPoolExitRequest( - val ticket: String, - val totalAssets: SerializedBigDecimal, - val timestamp: Instant, - val withdrawalTimestamp: Instant, - val isClaimable: Boolean, -) \ No newline at end of file diff --git a/domain/staking/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 deleted file mode 100644 index 70af95ac9a..0000000000 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.domain.staking.model.ethpool - -import com.tangem.domain.models.serialization.SerializedBigDecimal -import org.joda.time.DateTime - -/** - * P2P.org rewards history entry - * Historical reward information for account - */ -data class P2PEthPoolReward( - val date: DateTime, - val apy: SerializedBigDecimal, - val balance: SerializedBigDecimal, - val rewards: SerializedBigDecimal, -) \ No newline at end of file diff --git a/domain/staking/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 65% 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..27ea9df657 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,13 +1,13 @@ 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 + const val USE_TESTNET: Boolean = false val activeNetwork: P2PEthPoolNetwork get() = if (USE_TESTNET) P2PEthPoolNetwork.TESTNET else P2PEthPoolNetwork.MAINNET 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/analytics/StakingAnalyticsEvent.kt b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt index 37c678b5e6..cdf1a16bad 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt @@ -168,6 +168,11 @@ sealed class StakingAnalyticsEvent( event = "Button - Activate", params = mapOf(AnalyticsParam.TOKEN_PARAM to token), ) + + data class NotEnoughFee(val token: String) : StakingAnalyticsEvent( + event = "Notice - Not Enough Fee", + params = mapOf(AnalyticsParam.TOKEN_PARAM to token), + ) } enum class StakeScreenSource { diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/CooldownPeriod.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/CooldownPeriod.kt new file mode 100644 index 0000000000..bc66de0687 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/CooldownPeriod.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.staking.model + +sealed class CooldownPeriod { + + data class Fixed(val days: Int) : CooldownPeriod() + + data class Range(val minDays: Int, val maxDays: Int) : CooldownPeriod() +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt new file mode 100644 index 0000000000..7f779afa4e --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt @@ -0,0 +1,102 @@ +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, + private val 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 = calculateMaximumStakeAmount(), + ), + 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 cooldownPeriod: CooldownPeriod = CooldownPeriod.Range( + minDays = MIN_COOLDOWN_DAYS, + maxDays = MAX_COOLDOWN_DAYS, + ) + + override val rewardSchedule: RewardSchedule = RewardSchedule.DAY + + override val rewardClaiming: RewardClaiming = RewardClaiming.AUTO + + // Legal URLs + + override val legalUrls: StakingLegalUrls = StakingLegalUrls( + termsOfServiceUrl = TERMS_OF_SERVICE_URL, + privacyPolicyUrl = PRIVACY_POLICY_URL, + ) + + override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = token + + private fun calculateMaximumStakeAmount(): BigDecimal? { + return vaults + .mapNotNull { vault -> + val availableCapacity = vault.capacity - vault.totalAssets + if (availableCapacity > BigDecimal.ZERO) availableCapacity else null + } + .maxOrNull() + } + + companion object { + private const val MIN_COOLDOWN_DAYS = 1 + private const val MAX_COOLDOWN_DAYS = 4 + private val DEFAULT_MINIMUM_STAKE = BigDecimal("0.01") + + private const val TERMS_OF_SERVICE_URL = "https://www.p2p.org/terms-of-use" + private const val PRIVACY_POLICY_URL = "https://www.p2p.org/privacy-policy" + } +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt new file mode 100644 index 0000000000..185d52ea8b --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt @@ -0,0 +1,113 @@ +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 cooldownPeriod: CooldownPeriod? = yield.metadata.cooldownPeriod?.days?.let { + CooldownPeriod.Fixed(it) + } + + override val rewardSchedule: RewardSchedule = yield.metadata.rewardSchedule.toRewardSchedule() + + override val rewardClaiming: RewardClaiming = yield.metadata.rewardClaiming.toRewardClaiming() + + // Legal URLs + + override val legalUrls: StakingLegalUrls = StakingLegalUrls( + termsOfServiceUrl = TERMS_OF_SERVICE_URL, + privacyPolicyUrl = PRIVACY_POLICY_URL, + ) + + // Basic + + override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = + 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 + } + } + + companion object { + private const val TERMS_OF_SERVICE_URL = "https://docs.yield.xyz/docs/terms-of-use" + private const val PRIVACY_POLICY_URL = "https://docs.yield.xyz/docs/privacy-policy" + } +} \ No newline at end of file diff --git a/domain/staking/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..12895e0629 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt @@ -0,0 +1,61 @@ +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. + */ +sealed 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 cooldownPeriod: CooldownPeriod? + + val rewardSchedule: RewardSchedule + + val rewardClaiming: RewardClaiming + + // Legal URLs + + val legalUrls: StakingLegalUrls + + // 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/src/main/java/com/tangem/domain/staking/model/StakingLegalUrls.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingLegalUrls.kt new file mode 100644 index 0000000000..370e87a188 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingLegalUrls.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.staking.model + +data class StakingLegalUrls( + val termsOfServiceUrl: String, + val privacyPolicyUrl: String, +) \ No newline at end of file diff --git a/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..f0f34da571 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt @@ -1,14 +1,13 @@ package com.tangem.domain.staking.repositories import arrow.core.Either +import com.tangem.domain.models.staking.P2PEthPoolStakingAccount import com.tangem.domain.staking.model.StakingAvailability -import com.tangem.domain.staking.model.ethpool.P2PEthPoolAccount import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork -import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward 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 +16,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,15 +52,17 @@ 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 stakerPublicKey Staker's public key (note: API doc may have Bitcoin terminology) - * @param stakeTransactionHash Original stake transaction hash + * @param network P2PEthPool network (MAINNET or TESTNET) + * @param delegatorAddress User's wallet address + * @param vaultAddress Vault contract address + * @param amount Amount of ETH to unstake * @return Either error or unsigned transaction */ suspend fun createUnstakeTransaction( network: P2PEthPoolNetwork, - stakerPublicKey: String, - stakeTransactionHash: String, + delegatorAddress: String, + vaultAddress: String, + amount: String, ): Either /** @@ -69,19 +70,23 @@ interface P2PEthPoolRepository { * * Only works when funds are available (after exit queue wait period). * - * @param network P2P network (MAINNET or TESTNET) - * @param stakerAddress User's wallet address + * @param network P2PEthPool network (MAINNET or TESTNET) + * @param delegatorAddress User's wallet address + * @param vaultAddress Vault contract address + * @param amount Amount of ETH to withdraw * @return Either error or unsigned transaction with withdrawal tickets */ suspend fun createWithdrawTransaction( network: P2PEthPoolNetwork, - stakerAddress: String, + delegatorAddress: String, + vaultAddress: String, + amount: String, ): Either /** * 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 +100,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 @@ -104,36 +109,40 @@ interface P2PEthPoolRepository { network: P2PEthPoolNetwork, delegatorAddress: String, vaultAddress: String, - ): Either + ): Either /** - * Get rewards history for account and vault + * Get flow of cached vaults. * - * @param network P2P network (MAINNET or TESTNET) - * @param delegatorAddress User's wallet address - * @param vaultAddress Vault contract address - * @param period Optional period filter in days (30, 60, or 90) - * @return Either error or list of reward entries + * This returns vaults from the local cache/store. + * Call [fetchVaults] first to populate the cache from the network. + * + * @return Flow of cached vaults list */ - suspend fun getRewards( - network: P2PEthPoolNetwork, - delegatorAddress: String, - vaultAddress: String, - period: Int? = null, - ): Either> + fun getVaultsFlow(): Flow> /** - * Check P2P staking availability by finding public vault + * Get cached vaults synchronously from local store. * - * @return Flow of StakingAvailability - Available with StakingOption.P2P if public vault found, + * This returns vaults from the local cache/store without network call. + * Call [fetchVaults] first to populate the cache from the network. + * + * @return List of cached vaults (empty if cache is not populated) + */ + suspend fun getVaultsSync(): List + + /** + * Check P2PEthPool staking availability by finding public vault + * + * @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/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt b/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt index 84925b509b..9e1ce60e74 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt @@ -1,5 +1,6 @@ package com.tangem.domain.staking.utils +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId @@ -10,6 +11,34 @@ import com.tangem.domain.models.wallet.UserWalletId */ interface StakingCleaner { + /** + * Cleans up staking-related data for the given [userWalletId] and single [currency]. + * + * @param userWalletId The ID of the user wallet for which to clean up data. + * @param currency The cryptocurrency whose associated staking data should be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency) { + invoke(userWalletId = userWalletId, currencies = listOf(currency)) + } + + /** + * Cleans up staking-related data for the given [userWalletId] and list of [currencies]. + * + * @param userWalletId The ID of the user wallet for which to clean up data. + * @param currencies The list of cryptocurrencies whose associated staking data should be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, currencies: List) + + /** + * Cleans up staking-related data for the given [userWalletId] and single [stakingId]. + * + * @param userWalletId The ID of the user wallet for which to clean up data. + * @param stakingId The staking ID whose associated data should be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, stakingId: StakingID) { + invoke(userWalletId = userWalletId, stakingIds = setOf(stakingId)) + } + /** * Cleans up staking-related data for the given [userWalletId] and set of [stakingIds]. * diff --git a/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/detekt-baseline-debug.xml b/domain/tokens/detekt-baseline-debug.xml index 34d0f3eeb3..352d25c614 100644 --- a/domain/tokens/detekt-baseline-debug.xml +++ b/domain/tokens/detekt-baseline-debug.xml @@ -11,14 +11,12 @@ MultilineLambdaItParameter:FetchCurrencyStatusUseCase.kt$FetchCurrencyStatusUseCase${ when (it) { is StakingIdFactory.Error.UnableToGetAddress -> raise(IllegalStateException("$it")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } return@either } MultilineLambdaItParameter:GetBalanceNotEnoughForFeeWarningUseCase.kt$GetBalanceNotEnoughForFeeWarningUseCase${ it is CryptoCurrency.Token && it.contractAddress.equals(feePaidToken.contractAddress, ignoreCase = true) && it.network.derivationPath == tokenStatus.currency.network.derivationPath } MultilineLambdaItParameter:GetCryptoCurrencyActionsUseCase.kt$GetCryptoCurrencyActionsUseCase${ TokenActionsState( walletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus, states = it.toList(), ) } - MultilineLambdaItParameter:GetCryptoCurrencyActionsUseCase.kt$GetCryptoCurrencyActionsUseCase${ outdatedDataActionsFactory.create( userWallet = userWallet, cryptoCurrencyStatus = cryptoCurrencyStatus, stakingAvailability = it, ) } MultilineLambdaItParameter:GetCurrencyWarningsUseCase.kt$GetCurrencyWarningsUseCase${ if (isNeedToCreateAccountWithoutReserve(networkId = currencyStatus.currency.network.rawId)) { CryptoCurrencyWarning.TopUpWithoutReserve } else { CryptoCurrencyWarning.SomeNetworksNoAccount( amountToCreateAccount = it.amountToCreateAccount, amountCurrency = currencyStatus.currency, ) } } MultilineLambdaItParameter:GetCurrencyWarningsUseCase.kt$GetCurrencyWarningsUseCase${ it is CryptoCurrency.Token && it.contractAddress.equals(feePaidToken.contractAddress, ignoreCase = true) && it.network.derivationPath == tokenStatus.currency.network.derivationPath } MultilineLambdaItParameter:GetWalletTotalBalanceUseCase.kt$GetWalletTotalBalanceUseCase${ Timber.e("failed to load balances with error: $it") TotalFiatBalance.Failed } MultilineLambdaItParameter:PriceChangeCalculator.kt$PriceChangeCalculator${ val weight = it.value.fiatAmount.orZero().divide(balance, 2, RoundingMode.HALF_UP) val priceChange = it.value.priceChange.orZero() weight * priceChange } MultilineLambdaItParameter:WalletBalanceFetcher.kt$WalletBalanceFetcher${ val stakingId = stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it) if (stakingId.isLeft { it is StakingIdFactory.Error.UnableToGetAddress }) { Timber.e("Unable to get staking ID for user wallet $userWalletId and currency ${it.id}") } stakingId } NamedArguments:ApplyTokenListSortingUseCase.kt$ApplyTokenListSortingUseCase$saveTokens(userWalletId, currencies, isGrouped, isSortedByBalance) - NamedArguments:GetCurrencyWarningsUseCase.kt$GetCurrencyWarningsUseCase$combine( getCoinRelatedWarnings( userWalletId = userWalletId, networkId = currency.network.id, currencyId = currency.id, derivationPath = derivationPath, isSingleWalletWithTokens = isSingleWalletWithTokens, ), flowOf(currencyChecksRepository.getRentInfoWarning(userWalletId, currencyStatus)), flowOf(currencyChecksRepository.getExistentialDeposit(userWalletId, currency.network)), flowOf(currencyChecksRepository.getFeeResourceAmount(userWalletId, currency.network)), ) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeFeeResource -> setOfNotNull( maybeRentWarning, maybeEdWarning?.let { getExistentialDepositWarning(currency, it) }, maybeFeeResource?.let { getFeeResourceWarning(it) }, * coinRelatedWarnings.toTypedArray(), getNetworkUnavailableWarning(currencyStatus), getNetworkNoAccountWarning(currencyStatus), getBeaconChainShutdownWarning(rawId = currency.network.id.rawId), getAssetRequirementsWarning(userWalletId = userWalletId, currency = currency), getMigrationFromMaticToPolWarning(currency), ) } NoNameShadowing:WalletBalanceFetcher.kt$WalletBalanceFetcher${ it is StakingIdFactory.Error.UnableToGetAddress } NullableBooleanCheck:GetCurrencyCheckUseCase.kt$GetCurrencyCheckUseCase$recipientAddress?.let { currencyChecksRepository.checkIfAccountFunded( userWalletId, network, recipientAddress, ) } ?: false SuspendFunWithFlowReturnType:BaseCurrencyStatusOperations.kt$BaseCurrencyStatusOperations$suspend diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt index 26ed8b9867..102ad31f39 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt @@ -7,6 +7,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext import java.math.BigDecimal @@ -27,26 +28,34 @@ class GetBalanceNotEnoughForFeeWarningUseCase( private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val dispatchers: CoroutineDispatcherProvider, + private val currencyChecksRepository: CurrencyChecksRepository, ) { suspend operator fun invoke( fee: BigDecimal, userWalletId: UserWalletId, tokenStatus: CryptoCurrencyStatus, - coinStatus: CryptoCurrencyStatus, + feeStatus: CryptoCurrencyStatus, ): Either = Either.catch { withContext(dispatchers.io) { val feePaidCurrency = currenciesRepository.getFeePaidCurrency(userWalletId, tokenStatus.currency.network) - val coinBalance = coinStatus.value.amount ?: BigDecimal.ZERO + val feeTokenBalance = feeStatus.value.amount ?: BigDecimal.ZERO val isFeePaidByCoin = tokenStatus.currency is CryptoCurrency.Token val isFeePaidByToken = feePaidCurrency is FeePaidCurrency.Token && tokenStatus.currency.id != feePaidCurrency.tokenId + val isFeePaidByGaslessToken = + currencyChecksRepository.isNetworkSupportedForGaslessTx(feeStatus.currency.network) && + feeStatus.currency is CryptoCurrency.Token + val warning = when { - feePaidCurrency is FeePaidCurrency.Coin && isFeePaidByCoin && fee > coinBalance -> { + feePaidCurrency is FeePaidCurrency.Coin && + isFeePaidByCoin && + fee > feeTokenBalance && + !isFeePaidByGaslessToken -> { CryptoCurrencyWarning.BalanceNotEnoughForFee( tokenCurrency = tokenStatus.currency, - coinCurrency = coinStatus.currency, + coinCurrency = feeStatus.currency, ) } feePaidCurrency is FeePaidCurrency.Token && isFeePaidByToken && fee > feePaidCurrency.balance -> { diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt index 6ca1aa64ce..15315030c7 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt @@ -29,6 +29,7 @@ class GetCurrencyWarningsUseCase( private val currencyChecksRepository: CurrencyChecksRepository, private val currencyStatusOperations: BaseCurrencyStatusOperations, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, + ) { suspend operator fun invoke( @@ -56,7 +57,7 @@ class GetCurrencyWarningsUseCase( maybeRentWarning, maybeEdWarning?.let { getExistentialDepositWarning(currency, it) }, maybeFeeResource?.let { getFeeResourceWarning(it) }, - * coinRelatedWarnings.toTypedArray(), + *coinRelatedWarnings.toTypedArray(), getNetworkUnavailableWarning(currencyStatus), getNetworkNoAccountWarning(currencyStatus), getBeaconChainShutdownWarning(rawId = currency.network.id.rawId), @@ -114,11 +115,14 @@ class GetCurrencyWarningsUseCase( ): CryptoCurrencyWarning? { val feePaidCurrency = currenciesRepository.getFeePaidCurrency(userWalletId, tokenStatus.currency.network) val isNetworkFeeZero = currenciesRepository.isNetworkFeeZero(userWalletId, tokenStatus.currency.network) + val isNetworkSupportGasless = + currencyChecksRepository.isNetworkSupportedForGaslessTx(coinStatus.currency.network) return when { feePaidCurrency is FeePaidCurrency.Coin && !tokenStatus.value.amount.isZero() && coinStatus.value.amount.isZero() && - !isNetworkFeeZero -> { + !isNetworkFeeZero && + !isNetworkSupportGasless -> { CryptoCurrencyWarning.BalanceNotEnoughForFee( tokenCurrency = tokenStatus.currency, coinCurrency = coinStatus.currency, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt index 2338f7f243..2ff85923ae 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt @@ -1,12 +1,11 @@ package com.tangem.domain.tokens import arrow.core.Either +import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.withContext /** * Use case for checking if currency amount can be subtracted. @@ -14,17 +13,24 @@ import kotlinx.coroutines.withContext */ class IsAmountSubtractAvailableUseCase( private val currenciesRepository: CurrenciesRepository, - private val dispatchers: CoroutineDispatcherProvider, ) { - suspend operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency): Either = - Either.catch { - withContext(dispatchers.io) { - when (val feeCurrency = currenciesRepository.getFeePaidCurrency(userWalletId, currency.network)) { - is FeePaidCurrency.Coin -> currency is CryptoCurrency.Coin - is FeePaidCurrency.SameCurrency -> true - is FeePaidCurrency.Token -> currency.id == feeCurrency.tokenId - is FeePaidCurrency.FeeResource -> false - } - } + suspend operator fun invoke( + userWalletId: UserWalletId, + currency: CryptoCurrency, + maybeGaslessFee: Pair? = null, + ): Either = Either.catch { + val maybeTokenCurrency = currency as? CryptoCurrency.Token + if (maybeGaslessFee != null && + maybeGaslessFee.second is Fee.Ethereum.TokenCurrency && + maybeGaslessFee.first.contractAddress.equals(maybeTokenCurrency?.contractAddress, true) + ) { + return@catch true } + when (val feeCurrency = currenciesRepository.getFeePaidCurrency(userWalletId, currency.network)) { + is FeePaidCurrency.Coin -> currency is CryptoCurrency.Coin + is FeePaidCurrency.SameCurrency -> true + is FeePaidCurrency.Token -> currency.id == feeCurrency.tokenId + is FeePaidCurrency.FeeResource -> false + } + } } \ No newline at end of file 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..a229a4fd7c 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactory.kt @@ -267,8 +267,7 @@ object CryptoCurrencyStatusFactory { null } } - is StakingBalance.Data.P2P -> { - // TODO p2p + is StakingBalance.Data.P2PEthPool -> { val isCurrentAddressStaking = stakingBalance.stakingId.address == address.defaultAddress.value if (isCurrentAddressStaking) stakingBalance else null } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrencyChecksRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrencyChecksRepository.kt index 41266e0b39..86f9ee6d7e 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrencyChecksRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrencyChecksRepository.kt @@ -61,4 +61,7 @@ interface CurrencyChecksRepository { currencyStatus: CryptoCurrencyStatus?, balanceAfterTransaction: BigDecimal, ): CryptoCurrencyWarning.Rent? + + /** Checks if the network supports gasless transactions */ + fun isNetworkSupportedForGaslessTx(network: Network): Boolean } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt index 84fa8cedeb..f8a1d282a2 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt @@ -428,10 +428,10 @@ class CryptoCurrencyStatusFactoryTest { source = StatusSource.ACTUAL, balance = YieldBalanceItem( items = listOf( - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value }, - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns "unknown" }, ), @@ -525,10 +525,10 @@ class CryptoCurrencyStatusFactoryTest { source = StatusSource.ACTUAL, balance = YieldBalanceItem( items = listOf( - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value }, - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns "unknown" }, ), @@ -614,10 +614,10 @@ class CryptoCurrencyStatusFactoryTest { source = StatusSource.ACTUAL, balance = YieldBalanceItem( items = listOf( - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value }, - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns "unknown" }, ), diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt index 7f08ae5dbc..197949310f 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt @@ -589,11 +589,11 @@ class TotalFiatBalanceCalculatorTest { private fun createStakeKitBalance(amount: BigDecimal, balanceType: BalanceType): StakingBalance.Data.StakeKit { return StakingBalance.Data.StakeKit( - stakingId = mockk(), + stakingId = mockk(relaxed = true), source = StatusSource.ACTUAL, balance = YieldBalanceItem( items = listOf( - mockk { + mockk(relaxed = true) { every { this@mockk.amount } returns amount every { this@mockk.type } returns balanceType }, diff --git a/domain/transaction/build.gradle.kts b/domain/transaction/build.gradle.kts index 50f4bce71d..8488e0c17b 100644 --- a/domain/transaction/build.gradle.kts +++ b/domain/transaction/build.gradle.kts @@ -36,4 +36,9 @@ dependencies { implementation(projects.domain.demo) implementation(projects.domain.card) api(projects.domain.networks) + + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(projects.common.test) + testImplementation(projects.test.core) + testImplementation(projects.test.mock) } \ No newline at end of file diff --git a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt index ef18d2916a..3c8dfbdcfb 100644 --- a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt +++ b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt @@ -9,4 +9,14 @@ sealed class GetFeeError { data object KaspaZeroUtxo : BlockchainErrors() data object SuiOneCoinRequired : BlockchainErrors() } + + /** + * Gasless transaction related errors, model logic uses this errors types, don't remove or change them + */ + sealed class GaslessError : GetFeeError() { + data object NetworkIsNotSupported : GaslessError() + data object NoSupportedTokensFound : GaslessError() + data object NotEnoughFunds : GaslessError() + data class DataError(val cause: Throwable?) : GaslessError() + } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/Extensions.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/Extensions.kt new file mode 100644 index 0000000000..a899479a7b --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/Extensions.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.transaction + +import arrow.core.raise.Raise +import com.tangem.domain.transaction.error.GetFeeError + +fun Raise.raiseIllegalStateError(error: String): Nothing { + raise(GetFeeError.DataError(IllegalStateException(error))) +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt new file mode 100644 index 0000000000..2f7b061892 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt @@ -0,0 +1,68 @@ +package com.tangem.domain.transaction + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessSignedTransactionResult +import com.tangem.domain.transaction.models.GaslessTransactionData +import java.math.BigInteger + +interface GaslessTransactionRepository { + + suspend fun getSupportedTokens(network: Network): Set + + suspend fun getTokenFeeReceiverAddress(): String + + /** + * Sends gasless transaction to the gasless service for signing and returns the signed result. + * + * Flow: + * 1. User signs the gasless transaction data locally with their private key + * 2. This method submits the transaction + user signature to the gasless service + * 3. Service validates the signature and transaction data + * 4. Service adds its own signature for fee delegation (pays gas in tokens) + * 5. Service constructs the final EIP-1559 transaction with all signatures + * 6. Service returns the fully signed transaction ready for broadcasting + * + * The gasless service acts as a relayer that: + * - Pays network gas fees on behalf of the user + * - Receives payment in the specified token from the user's wallet + * - Ensures atomic execution (either both transfers succeed or both fail) + * + * @param gaslessTransactionData domain model containing: + * - transaction: target contract call data (to, value, data) + * - fee: token payment configuration (feeToken, maxTokenFee, etc.) + * - nonce: user's contract nonce to prevent replay attacks + * @param signature user's ECDSA signature of the gasless transaction in hex format (0x...) + * Signs keccak256 hash of the transaction data + * @param userAddress user's Ethereum address (EOA or contract wallet) + * @param network blockchain network (Ethereum, Polygon, BSC, etc.) + * Used to determine chainId for the request + * @param eip7702Auth optional EIP-7702 authorization for EOA delegation to smart contract + * Required only when user's EOA needs to temporarily act as contract wallet + * Contains signature authorizing delegation to entry point contract + * @return [GaslessSignedTransactionResult] containing: + * - signedTransaction: complete RLP-encoded transaction ready to broadcast + * - gasLimit: actual gas limit allocated by the service + * - maxFeePerGas: maximum fee per gas (base + priority) in wei + * - maxPriorityFeePerGas: tip for validators in wei (EIP-1559) + * @throws IllegalStateException if network is not supported or chainId cannot be determined + * @throws Exception if service returns error or network request fails + */ + suspend fun signGaslessTransaction( + gaslessTransactionData: GaslessTransactionData, + signature: String, + userAddress: String, + network: Network, + eip7702Auth: Eip7702Authorization? = null, + ): GaslessSignedTransactionResult + + /** + * Hardcoded value as baseGas + */ + fun getBaseGasForTransaction(): BigInteger + + fun getChainIdForNetwork(network: Network): Int + + suspend fun getGaslessFeeAddresses(): Set +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/Eip7702Authorization.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/Eip7702Authorization.kt new file mode 100644 index 0000000000..88e835c1cd --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/Eip7702Authorization.kt @@ -0,0 +1,36 @@ +package com.tangem.domain.transaction.models + +import java.math.BigInteger + +/** + * EIP-7702 authorization for account abstraction. + * Used for delegating EOA (Externally Owned Account) control to a smart contract. + * + * EIP-7702 allows EOAs to temporarily act as smart contract wallets by delegating + * their authority to a contract address for a specific transaction. + * + * @property chainId blockchain network chain ID + * @property address contract address to delegate authority to (entry point contract) + * @property nonce authorization nonce to prevent replay attacks + * @property yParity recovery ID for signature (0 or 1) + * @property r ECDSA signature component R + * @property s ECDSA signature component S + * + * @see EIP-7702 Specification + */ +data class Eip7702Authorization( + val chainId: Int, + val address: String, + val nonce: BigInteger, + val yParity: Int, + val r: String, + val s: String, +) { + init { + require(chainId > 0) { "Chain ID must be positive" } + require(address.isNotBlank()) { "Address must not be blank" } + require(yParity in 0..1) { "yParity must be 0 or 1" } + require(r.isNotBlank()) { "Signature component R must not be blank" } + require(s.isNotBlank()) { "Signature component S must not be blank" } + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessSignedTransactionResult.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessSignedTransactionResult.kt new file mode 100644 index 0000000000..0bdb72570c --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessSignedTransactionResult.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.transaction.models + +/** + * Result of gasless transaction signing from the gasless service. + * Contains the fully signed transaction ready for broadcasting and gas parameters. + * + * After the user signs the gasless transaction data locally, the service: + * 1. Validates the signature + * 2. Adds its own signature for fee delegation + * 3. Constructs the final transaction + * 4. Returns this signed transaction with gas parameters + * + * @property txHash sent tx hash + */ +data class GaslessSignedTransactionResult( + val txHash: String, +) { + init { + require(txHash.isNotBlank()) { "Tx hash must not be blank" } + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt new file mode 100644 index 0000000000..ea6e275fa0 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt @@ -0,0 +1,67 @@ +package com.tangem.domain.transaction.models + +import java.math.BigInteger + +/** + * Domain model for gasless transaction. + * Represents complete transaction data with fee delegation metadata. + */ +data class GaslessTransactionData( + /** Transaction details */ + val transaction: Transaction, + /** Fee payment configuration */ + val fee: Fee, + /** Nonce from user's contract */ + val nonce: BigInteger, +) { + + /** + * Core transaction data. + * + * @property to destination address + * @property value transaction value in wei (currently always 0 for gasless) + * @property data encoded transaction data (contract call) + */ + data class Transaction( + val to: String, + val value: BigInteger, + val data: ByteArray, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Transaction + + if (to != other.to) return false + if (value != other.value) return false + if (!data.contentEquals(other.data)) return false + + return true + } + + override fun hashCode(): Int { + var result = to.hashCode() + result = 31 * result + value.hashCode() + result = 31 * result + data.contentHashCode() + return result + } + } + + /** + * Fee configuration for gasless transaction. + * + * @property feeToken token address used for fee payment + * @property maxTokenFee maximum fee amount in fee token units + * @property coinPriceInToken price of native coin in fee token units + * @property feeTransferGasLimit gas limit for fee token transfer + * @property baseGas base gas cost (currently constant BASE_GAS, may vary in future) + */ + data class Fee( + val feeToken: String, + val maxTokenFee: BigInteger, + val coinPriceInToken: BigInteger, + val feeTransferGasLimit: BigInteger, + val baseGas: BigInteger, + ) +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt new file mode 100644 index 0000000000..581292f419 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.transaction.models + +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.models.currency.CryptoCurrency + +data class TransactionFeeExtended( + val transactionFee: TransactionFee, + val feeTokenId: CryptoCurrency.ID, +) \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt new file mode 100644 index 0000000000..4d2d4e9a0d --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -0,0 +1,352 @@ +package com.tangem.domain.transaction.usecase.gasless + +import arrow.core.Either +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils +import com.tangem.blockchain.blockchains.ethereum.gasless.EthereumGaslessDataProvider +import com.tangem.blockchain.blockchains.ethereum.models.EIP7702AuthorizationData +import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.extensions.formatHex +import com.tangem.blockchain.extensions.normalizeByteArray +import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData +import com.tangem.common.CompletionResult +import com.tangem.common.extensions.toDecompressedPublicKey +import com.tangem.common.extensions.toHexString +import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins +import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessTransactionData +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.domain.walletmanager.WalletManagersFacade +import java.math.BigInteger + +class CreateAndSendGaslessTransactionUseCase( + private val walletManagersFacade: WalletManagersFacade, + private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val gaslessTransactionRepository: GaslessTransactionRepository, + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner, +) { + + suspend operator fun invoke( + userWallet: UserWallet, + transactionData: TransactionData, + fee: TransactionFeeExtended, + ): Either = either { + catch( + block = { + val uncompiledTxData = validateTransactionData(transactionData) + val context = prepareGaslessContext(userWallet, uncompiledTxData, fee) + val signedData = signGaslessTransactionByUser(userWallet, context, uncompiledTxData) + signAndSendTransactionOnBackend(context, signedData, uncompiledTxData) + }, + catch = { + raise(SendTransactionError.DataError(it.message)) + }, + ) + } + + /** + * Validates and casts transaction data to Uncompiled type. + */ + private fun validateTransactionData(transactionData: TransactionData): TransactionData.Uncompiled { + return transactionData as? TransactionData.Uncompiled + ?: error("Uncompiled transaction data required") + } + + /** + * Prepares all necessary context for gasless transaction. + * Includes: wallet manager, gasless provider, token status, nonce, transaction data. + */ + private suspend fun prepareGaslessContext( + userWallet: UserWallet, + transactionData: TransactionData.Uncompiled, + fee: TransactionFeeExtended, + ): GaslessContext { + val tokenForFeeStatus = getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + fee.feeTokenId, + ).getOrNull() ?: error("Token for fee not found") + + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWallet.walletId, + tokenForFeeStatus.currency.network, + ) ?: error("WalletManager not found for network ${tokenForFeeStatus.currency.network.id}") + + val gaslessDataProvider = walletManager as? EthereumGaslessDataProvider ?: error( + "WalletManager for network ${tokenForFeeStatus.currency.network.id} " + + "does not support gasless transactions", + ) + + val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress) + + val gaslessTransactionData = createGaslessTransactionData( + transactionData = transactionData, + txFee = fee, + tokenFeeStatus = tokenForFeeStatus, + nonce = gaslessContractNonce, + ) + + val chainId = gaslessTransactionRepository.getChainIdForNetwork(tokenForFeeStatus.currency.network) + + return GaslessContext( + walletManager = walletManager, + gaslessDataProvider = gaslessDataProvider, + tokenForFeeStatus = tokenForFeeStatus, + gaslessTransactionData = gaslessTransactionData, + chainId = chainId, + ) + } + + /** + * Gets contract nonce with fallback to zero on failure. + */ + private suspend fun getContractNonce( + gaslessDataProvider: EthereumGaslessDataProvider, + userAddress: String, + ): BigInteger { + return when (val nonceResult = gaslessDataProvider.getGaslessContractNonce(userAddress)) { + is Result.Failure -> BigInteger.ZERO + is Result.Success -> nonceResult.data + } + } + + /** + * Signs gasless transaction and EIP-7702 authorization. + * Returns prepared signatures and authorization data. + */ + private suspend fun signGaslessTransactionByUser( + userWallet: UserWallet, + context: GaslessContext, + transactionData: TransactionData.Uncompiled, + ): SignedGaslessData { + val eip712Data = Eip712TypedDataBuilder.build( + gaslessTransaction = context.gaslessTransactionData, + chainId = context.chainId, + verifyingContract = transactionData.sourceAddress, + ) + + val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data) + val eip7702Data = getEIP7702DataForGasless(context.gaslessDataProvider) + + val signedHashes = signHashes( + userWallet = userWallet, + walletManager = context.walletManager, + hashesToOperate = HashesToOperate( + eip712Hash = eip712HashToSign, + eip7702Hash = eip7702Data.data, + ), + ) + + val decompressedPublicKey = context.walletManager + .wallet.publicKey.blockchainKey + .toDecompressedPublicKey() + + val preparedEip712Hash = UnmarshalHelper.unmarshalSignatureExtended( + signature = signedHashes.eip712Hash, + hash = eip712HashToSign, + publicKey = decompressedPublicKey, + ).asRSVLegacyEVM().toHexString().formatHex().lowercase() + + val extendedEip7702Data = UnmarshalHelper.unmarshalSignatureExtended( + signature = signedHashes.eip7702Hash, + hash = eip7702Data.data, + publicKey = decompressedPublicKey, + ) + + val eip7702Auth = Eip7702Authorization( + chainId = context.chainId, + address = eip7702Data.executorAddress, + nonce = eip7702Data.nonce, + yParity = extendedEip7702Data.recId, + r = extendedEip7702Data.r.toFormattedHex(bytes = 32), + s = extendedEip7702Data.s.toFormattedHex(bytes = 32), + ) + + return SignedGaslessData( + eip712Signature = preparedEip712Hash, + eip7702Auth = eip7702Auth, + ) + } + + /** + * Sends gasless transaction to the service. + */ + private suspend fun signAndSendTransactionOnBackend( + context: GaslessContext, + signedData: SignedGaslessData, + transactionData: TransactionData.Uncompiled, + ): String { + return gaslessTransactionRepository.signGaslessTransaction( + network = context.tokenForFeeStatus.currency.network, + gaslessTransactionData = context.gaslessTransactionData, + signature = signedData.eip712Signature, + userAddress = transactionData.sourceAddress, + eip7702Auth = signedData.eip7702Auth, + ).txHash + } + + private suspend fun signHashes( + userWallet: UserWallet, + walletManager: WalletManager, + hashesToOperate: HashesToOperate, + ): HashesToOperate { + val signer = getSigner(userWallet) + val hashesToSign = listOf(hashesToOperate.eip712Hash, hashesToOperate.eip7702Hash) + + return when (val signerResult = signer.sign(hashesToSign, walletManager.wallet.publicKey)) { + is CompletionResult.Failure -> error("Signing failed: ${signerResult.error.message ?: "sdk error"}") + is CompletionResult.Success -> { + val signatures = signerResult.data + require(signatures.size == 2) { "Expected 2 signatures, got ${signatures.size}" } + HashesToOperate( + eip712Hash = signatures[0], + eip7702Hash = signatures[1], + ) + } + } + } + + private fun getSigner(userWallet: UserWallet): TransactionSigner { + return when (userWallet) { + is UserWallet.Cold -> { + val card = userWallet.scanResponse.card + val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins + + cardSdkConfigRepository.getCommonSigner( + cardId = card.cardId.takeIf { isCardNotBackedUp }, + twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), + ) + } + is UserWallet.Hot -> getHotWalletSigner(userWallet) + } + } + + private fun createGaslessTransactionData( + transactionData: TransactionData.Uncompiled, + txFee: TransactionFeeExtended, + tokenFeeStatus: CryptoCurrencyStatus, + nonce: BigInteger, + ): GaslessTransactionData { + val transaction = buildTransaction(transactionData) + val fee = buildFee(txFee, tokenFeeStatus) + + return GaslessTransactionData( + transaction = transaction, + fee = fee, + nonce = nonce, + ) + } + + private fun buildTransaction(transactionData: TransactionData.Uncompiled): GaslessTransactionData.Transaction { + val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData + ?: error("Ethereum call data is required") + + // Native amount is always zero in gasless transactions for now + // we don't support gasless transfers of native currency + val nativeAmount = BigInteger.ZERO + + return GaslessTransactionData.Transaction( + to = getDestinationAddress(transactionData), + value = nativeAmount, + data = callData.data, + ) + } + + private fun buildFee( + txFee: TransactionFeeExtended, + tokenFeeStatus: CryptoCurrencyStatus, + ): GaslessTransactionData.Fee { + val tokenForFee = tokenFeeStatus.currency as? CryptoCurrency.Token + ?: error("Fee currency must be a token") + + val feeInTokenCurrency = txFee.transactionFee.normal as? Fee.Ethereum.TokenCurrency + ?: error("Fee must be in token currency") + + return GaslessTransactionData.Fee( + feeToken = tokenForFee.contractAddress, + maxTokenFee = feeInTokenCurrency.gasLimit, + coinPriceInToken = feeInTokenCurrency.coinPriceInToken, + feeTransferGasLimit = feeInTokenCurrency.feeTransferGasLimit, + baseGas = feeInTokenCurrency.baseGas, + ) + } + + private suspend fun getEIP7702DataForGasless( + gaslessDataProvider: EthereumGaslessDataProvider, + ): EIP7702AuthorizationData { + return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData()) { + is Result.Failure -> throw dataResult.error + is Result.Success -> dataResult.data + } + } + + private fun getDestinationAddress(txData: TransactionData.Uncompiled): String { + val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData + val contractAddress = txData.contractAddress + return if (ethereumCallData is EthereumYieldSupplySendCallData) { + ethereumCallData.destinationAddress + } else { + contractAddress ?: error("supports only Token transaction with contract address") + } + } + + /** + * Context containing all prepared data for gasless transaction. + */ + private data class GaslessContext( + val walletManager: WalletManager, + val gaslessDataProvider: EthereumGaslessDataProvider, + val tokenForFeeStatus: CryptoCurrencyStatus, + val gaslessTransactionData: GaslessTransactionData, + val chainId: Int, + ) + + /** + * Signed data ready for submission. + */ + private data class SignedGaslessData( + val eip712Signature: String, + val eip7702Auth: Eip7702Authorization, + ) + + private data class HashesToOperate( + val eip712Hash: ByteArray, + val eip7702Hash: ByteArray, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HashesToOperate + + if (!eip712Hash.contentEquals(other.eip712Hash)) return false + if (!eip7702Hash.contentEquals(other.eip7702Hash)) return false + + return true + } + + override fun hashCode(): Int { + var result = eip712Hash.contentHashCode() + result = 31 * result + eip7702Hash.contentHashCode() + return result + } + } + + private companion object { + fun BigInteger.toFormattedHex(bytes: Int): String { + return toByteArray().normalizeByteArray(bytes).toHexString().formatHex() + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt new file mode 100644 index 0000000000..6164e008db --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt @@ -0,0 +1,124 @@ +package com.tangem.domain.transaction.usecase.gasless + +import com.tangem.common.extensions.toHexString +import com.tangem.domain.transaction.models.GaslessTransactionData +import org.json.JSONArray +import org.json.JSONObject + +/** + * Builder for creating EIP-712 typed data JSON for gasless transaction signing. + * + * The EIP-712 standard allows users to sign typed, structured data instead of raw bytes. + * This provides better UX as wallets can show users exactly what they're signing. + * + * Example usage: + * ```kotlin + * val typedDataJson = Eip712TypedDataBuilder.build( + * gaslessTransaction = gaslessTransactionData, + * chainId = 1, + * verifyingContract = "0x1234..." + * ) + * val signature = wallet.signTypedData(typedDataJson) + * ``` + */ +object Eip712TypedDataBuilder { + + private const val DOMAIN_NAME = "Tangem7702GaslessExecutor" + private const val DOMAIN_VERSION = "1" + private const val PRIMARY_TYPE = "GaslessTransaction" + + /** + * Builds EIP-712 typed data JSON for gasless transaction. + * + * @param gaslessTransaction domain model with transaction and fee data + * @param chainId blockchain network chain ID + * @param verifyingContract address of the deployed gasless executor contract + * @return JSON string ready for EIP-712 signing + */ + fun build(gaslessTransaction: GaslessTransactionData, chainId: Int, verifyingContract: String): String { + val typedData = JSONObject().apply { + put("types", buildTypes()) + put("primaryType", PRIMARY_TYPE) + put("domain", buildDomain(chainId, verifyingContract)) + put("message", buildMessage(gaslessTransaction)) + } + return typedData.toString() + } + + /** + * Builds the type definitions for all structures. + * This schema is fixed and defines the structure of the data being signed. + */ + @Suppress("NestedScopeFunctions") + private fun buildTypes(): JSONObject { + return JSONObject().apply { + put("EIP712Domain", JSONArray().apply { + put(typeProperty("name", "string")) + put(typeProperty("version", "string")) + put(typeProperty("chainId", "uint256")) + put(typeProperty("verifyingContract", "address")) + }) + put("Transaction", JSONArray().apply { + put(typeProperty("to", "address")) + put(typeProperty("value", "uint256")) + put(typeProperty("data", "bytes")) + }) + put("Fee", JSONArray().apply { + put(typeProperty("feeToken", "address")) + put(typeProperty("maxTokenFee", "uint256")) + put(typeProperty("coinPriceInToken", "uint256")) + put(typeProperty("feeTransferGasLimit", "uint256")) + put(typeProperty("baseGas", "uint256")) + }) + put("GaslessTransaction", JSONArray().apply { + put(typeProperty("transaction", "Transaction")) + put(typeProperty("fee", "Fee")) + put(typeProperty("nonce", "uint256")) + }) + } + } + + /** + * Creates a type property JSON object. + */ + private fun typeProperty(name: String, type: String): JSONObject { + return JSONObject().apply { + put("name", name) + put("type", type) + } + } + + /** + * Builds the domain separator. + */ + private fun buildDomain(chainId: Int, verifyingContract: String): JSONObject { + return JSONObject().apply { + put("name", DOMAIN_NAME) + put("version", DOMAIN_VERSION) + put("chainId", chainId) + put("verifyingContract", verifyingContract) + } + } + + /** + * Builds the message data from gasless transaction. + */ + @Suppress("NestedScopeFunctions") + private fun buildMessage(gaslessTransaction: GaslessTransactionData): JSONObject { + return JSONObject().apply { + put("transaction", JSONObject().apply { + put("to", gaslessTransaction.transaction.to) + put("value", gaslessTransaction.transaction.value.toString()) + put("data", gaslessTransaction.transaction.data.toHexString()) + }) + put("fee", JSONObject().apply { + put("feeToken", gaslessTransaction.fee.feeToken) + put("maxTokenFee", gaslessTransaction.fee.maxTokenFee.toString()) + put("coinPriceInToken", gaslessTransaction.fee.coinPriceInToken.toString()) + put("feeTransferGasLimit", gaslessTransaction.fee.feeTransferGasLimit.toString()) + put("baseGas", gaslessTransaction.fee.baseGas.toString()) + }) + put("nonce", gaslessTransaction.nonce.toString()) + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt new file mode 100644 index 0000000000..b43a368305 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt @@ -0,0 +1,186 @@ +package com.tangem.domain.transaction.usecase.gasless + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.GetFeeError.GaslessError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.domain.transaction.raiseIllegalStateError +import com.tangem.domain.transaction.usecase.EstimateFeeUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade +import java.math.BigDecimal + +@Suppress("LongParameterList") +class EstimateFeeForGaslessTxUseCase( + private val walletManagersFacade: WalletManagersFacade, + private val demoConfig: DemoConfig, + private val gaslessTransactionRepository: GaslessTransactionRepository, + private val currenciesRepository: CurrenciesRepository, + private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val estimateFeeUseCase: EstimateFeeUseCase, + private val currencyChecksRepository: CurrencyChecksRepository, +) { + + private val tokenFeeCalculator = TokenFeeCalculator( + walletManagersFacade = walletManagersFacade, + gaslessTransactionRepository = gaslessTransactionRepository, + demoConfig = demoConfig, + ) + + suspend operator fun invoke( + userWallet: UserWallet, + amount: BigDecimal, + sendingTokenCurrencyStatus: CryptoCurrencyStatus, + ): Either { + return either { + catch( + block = { + val network = sendingTokenCurrencyStatus.currency.network + val nativeCurrency = currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = network.id, + derivationPath = network.derivationPath, + ) + + if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) { + estimateFeeUseCase.invoke( + userWallet = userWallet, + amount = amount, + cryptoCurrencyStatus = sendingTokenCurrencyStatus, + ).fold( + ifLeft = { raise(it) }, + ifRight = { fee -> + return@either TransactionFeeExtended( + transactionFee = fee, + feeTokenId = nativeCurrency.id, + ) + }, + ) + } + + val walletManager = prepareWalletManager(userWallet, network) + + val initialFee = tokenFeeCalculator.estimateInitialFee( + userWallet = userWallet, + amount = amount, + txTokenCurrencyStatus = sendingTokenCurrencyStatus, + ).bind() + + selectFeePaymentStrategy( + userWallet = userWallet, + walletManager = walletManager, + nativeCurrency = nativeCurrency, + network = network, + initialFee = initialFee, + ) + }, + catch = { + raise(GaslessError.DataError(it)) + }, + ) + } + } + + @Suppress("NullableToStringCall") + private suspend fun Raise.prepareWalletManager( + userWallet: UserWallet, + network: Network, + ): EthereumWalletManager { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = network, + ) + val ethereumWalletManager = walletManager as? EthereumWalletManager + ?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported") + return ethereumWalletManager + } + + private suspend fun Raise.selectFeePaymentStrategy( + userWallet: UserWallet, + walletManager: EthereumWalletManager, + nativeCurrency: CryptoCurrency, + network: Network, + initialFee: TransactionFee, + ): TransactionFeeExtended { + val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError) + + val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + ).getOrNull() ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + + val networkCurrenciesStatuses = userCurrenciesStatuses.filter { + it.currency.network.id == network.id + } + + val nativeCurrencyStatus = networkCurrenciesStatuses.find { + it.currency.id == nativeCurrency.id + } ?: raiseIllegalStateError("native currency not found for network ${network.id}") + + val nativeBalance = nativeCurrencyStatus.value.amount ?: BigDecimal.ZERO + return if (nativeBalance >= feeValue) { + TransactionFeeExtended(transactionFee = initialFee, feeTokenId = nativeCurrencyStatus.currency.id) + } else { + findTokensToPayFee( + walletManager = walletManager, + initialTxFee = initialFee, + nativeCurrencyStatus = nativeCurrencyStatus, + networkCurrenciesStatuses = networkCurrenciesStatuses, + ) + } + } + + @Suppress("NullableToStringCall") + private suspend fun Raise.findTokensToPayFee( + walletManager: EthereumWalletManager, + initialTxFee: TransactionFee, + nativeCurrencyStatus: CryptoCurrencyStatus, + networkCurrenciesStatuses: List, + ): TransactionFeeExtended { + val initialFee = initialTxFee.normal as? Fee.Ethereum + ?: raiseIllegalStateError( + error = "only Fee.Ethereum supported, but was ${initialTxFee.normal::class.qualifiedName}", + ) + + val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens( + network = nativeCurrencyStatus.currency.network, + ).mapNotNull { currency -> + (currency as? CryptoCurrency.Token)?.contractAddress + }.toSet() + + val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses + .filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token } + .sortedByDescending { it.value.amount } + .filter { status -> + val token = status.currency as? CryptoCurrency.Token ?: return@filter false + token.contractAddress.lowercase() in supportedGaslessTokens + } + + /** + * Selects token with highest balance to maximize chances of successful fee payment. + * Returns null if no suitable tokens found. + */ + val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull() + ?: raise(GaslessError.NoSupportedTokensFound) + + return tokenFeeCalculator.calculateTokenFee( + walletManager = walletManager, + tokenForPayFeeStatus = tokenForPayFeeStatus, + nativeCurrencyStatus = nativeCurrencyStatus, + initialFee = initialFee, + ).bind() + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt new file mode 100644 index 0000000000..567aa6a486 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt @@ -0,0 +1,109 @@ +package com.tangem.domain.transaction.usecase.gasless + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.GetFeeError.GaslessError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.domain.transaction.raiseIllegalStateError +import com.tangem.domain.walletmanager.WalletManagersFacade +import java.math.BigDecimal + +class EstimateFeeForTokenUseCase( + private val gaslessTransactionRepository: GaslessTransactionRepository, + private val walletManagersFacade: WalletManagersFacade, + private val demoConfig: DemoConfig, + private val currenciesRepository: CurrenciesRepository, + private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val currencyChecksRepository: CurrencyChecksRepository, +) { + + private val tokenFeeCalculator = TokenFeeCalculator( + walletManagersFacade = walletManagersFacade, + gaslessTransactionRepository = gaslessTransactionRepository, + demoConfig = demoConfig, + ) + + suspend operator fun invoke( + userWallet: UserWallet, + feeTokenCurrencyStatus: CryptoCurrencyStatus, + sendingTokenCurrencyStatus: CryptoCurrencyStatus, + amount: BigDecimal, + ): Either { + return either { + catch( + block = { + val token = feeTokenCurrencyStatus.currency + if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) { + raise(GaslessError.NetworkIsNotSupported) + } + + val initialTxFee = tokenFeeCalculator.estimateInitialFee( + userWallet = userWallet, + amount = amount, + txTokenCurrencyStatus = sendingTokenCurrencyStatus, + ).bind() + + val initialFeeEth = initialTxFee.normal as? Fee.Ethereum + ?: raiseIllegalStateError( + error = "only Fee.Ethereum supported, but was different", + ) + + val nativeCurrency = currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = token.network.id, + derivationPath = token.network.derivationPath, + ) + + val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + ).getOrNull()?.filter { + it.currency.network.id == token.network.id + } ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + + val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find { + it.currency.id == nativeCurrency.id + } ?: raiseIllegalStateError("native currency not found for network ${token.network.id}") + + val walletManager = prepareWalletManager(userWallet, token.network) + + tokenFeeCalculator.calculateTokenFee( + walletManager = walletManager, + tokenForPayFeeStatus = feeTokenCurrencyStatus, + nativeCurrencyStatus = nativeCurrencyStatus, + initialFee = initialFeeEth, + ).bind() + }, + catch = { + raise(GaslessError.DataError(it)) + }, + ) + } + } + + @Suppress("NullableToStringCall") + private suspend fun Raise.prepareWalletManager( + userWallet: UserWallet, + network: Network, + ): EthereumWalletManager { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = network, + ) + val ethereumWalletManager = walletManager as? EthereumWalletManager + ?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported") + return ethereumWalletManager + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt new file mode 100644 index 0000000000..0d2c825756 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -0,0 +1,94 @@ +package com.tangem.domain.transaction.usecase.gasless + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.raiseIllegalStateError + +class GetAvailableFeeTokensUseCase( + private val gaslessTransactionRepository: GaslessTransactionRepository, + private val currenciesRepository: CurrenciesRepository, + private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val currencyChecksRepository: CurrencyChecksRepository, +) { + + /** + * Retrieves available tokens for gasless fee payment. + * + * @return List where first element is always native currency (for fallback) + */ + suspend operator fun invoke( + userWallet: UserWallet, + network: Network, + ): Either> { + return either { + catch( + block = { + val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + ).getOrNull() + ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + + val nativeCurrencyStatus = getNativeCurrencyStatus(userWallet, network, userCurrenciesStatuses) + + if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) { + return@either listOf(nativeCurrencyStatus) + } + + val gaslessTokens = getGaslessTokens(network, userCurrenciesStatuses) + buildList { + add(nativeCurrencyStatus) + addAll(gaslessTokens) + } + }, + catch = { + raise(GetFeeError.GaslessError.DataError(it)) + }, + ) + } + } + + private suspend fun Raise.getNativeCurrencyStatus( + userWallet: UserWallet, + network: Network, + userCurrenciesStatuses: List, + ): CryptoCurrencyStatus { + val nativeCurrency = currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = network.id, + derivationPath = network.derivationPath, + ) + return userCurrenciesStatuses.find { + it.currency.id == nativeCurrency.id + } ?: raiseIllegalStateError("no native currency found") + } + + private suspend fun getGaslessTokens( + network: Network, + userCurrenciesStatuses: List, + ): List { + val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(network) + .mapNotNull { + (it as? CryptoCurrency.Token)?.contractAddress?.lowercase() + }.toSet() + return userCurrenciesStatuses + .asSequence() + .filter { it.value.yieldSupplyStatus == null } + .filter { it.currency.network.id == network.id } + .filter { currencyStatus -> + val token = currencyStatus.currency + token is CryptoCurrency.Token && supportedGaslessTokens.contains(token.contractAddress.lowercase()) + } + .toList() + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt new file mode 100644 index 0000000000..1bb6b4074c --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt @@ -0,0 +1,183 @@ +package com.tangem.domain.transaction.usecase.gasless + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.GetFeeError.GaslessError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.domain.transaction.raiseIllegalStateError +import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade +import java.math.BigDecimal + +@Suppress("LongParameterList") +class GetFeeForGaslessUseCase( + private val walletManagersFacade: WalletManagersFacade, + private val demoConfig: DemoConfig, + private val gaslessTransactionRepository: GaslessTransactionRepository, + private val currenciesRepository: CurrenciesRepository, + private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val getFeeUseCase: GetFeeUseCase, + private val currencyChecksRepository: CurrencyChecksRepository, +) { + + private val tokenFeeCalculator = TokenFeeCalculator( + walletManagersFacade = walletManagersFacade, + gaslessTransactionRepository = gaslessTransactionRepository, + demoConfig = demoConfig, + ) + + suspend operator fun invoke( + userWallet: UserWallet, + network: Network, + transactionData: TransactionData, + ): Either { + return either { + catch( + block = { + val nativeCurrency = currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = network.id, + derivationPath = network.derivationPath, + ) + + if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) { + getFeeUseCase.invoke(userWallet, network, transactionData).fold( + ifLeft = { raise(it) }, + ifRight = { fee -> + return@either TransactionFeeExtended( + transactionFee = fee, + feeTokenId = nativeCurrency.id, + ) + }, + ) + } + + val walletManager = prepareWalletManager(userWallet, network) + + val initialFee = tokenFeeCalculator.calculateInitialFee( + userWallet = userWallet, + network = network, + walletManager = walletManager, + transactionData = transactionData, + ).bind() + + selectFeePaymentStrategy( + userWallet = userWallet, + walletManager = walletManager, + nativeCurrency = nativeCurrency, + network = network, + initialFee = initialFee, + ) + }, + catch = { + raise(GaslessError.DataError(it)) + }, + ) + } + } + + @Suppress("NullableToStringCall") + private suspend fun Raise.prepareWalletManager( + userWallet: UserWallet, + network: Network, + ): EthereumWalletManager { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = network, + ) + val ethereumWalletManager = walletManager as? EthereumWalletManager + ?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported") + return ethereumWalletManager + } + + private suspend fun Raise.selectFeePaymentStrategy( + userWallet: UserWallet, + walletManager: EthereumWalletManager, + nativeCurrency: CryptoCurrency, + network: Network, + initialFee: TransactionFee, + ): TransactionFeeExtended { + val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError) + + val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + ).getOrNull() ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + + val networkCurrenciesStatuses = userCurrenciesStatuses.filter { + it.currency.network.id == network.id + } + + val nativeCurrencyStatus = networkCurrenciesStatuses.find { + it.currency.id == nativeCurrency.id + } ?: raiseIllegalStateError("native currency not found for network ${network.id}") + + val nativeBalance = nativeCurrencyStatus.value.amount ?: BigDecimal.ZERO + return if (nativeBalance >= feeValue) { + TransactionFeeExtended(transactionFee = initialFee, feeTokenId = nativeCurrencyStatus.currency.id) + } else { + findTokensToPayFee( + walletManager = walletManager, + initialTxFee = initialFee, + nativeCurrencyStatus = nativeCurrencyStatus, + networkCurrenciesStatuses = networkCurrenciesStatuses, + ) + } + } + + @Suppress("NullableToStringCall") + private suspend fun Raise.findTokensToPayFee( + walletManager: EthereumWalletManager, + initialTxFee: TransactionFee, + nativeCurrencyStatus: CryptoCurrencyStatus, + networkCurrenciesStatuses: List, + ): TransactionFeeExtended { + val initialFee = initialTxFee.normal as? Fee.Ethereum + ?: raiseIllegalStateError( + error = "only Fee.Ethereum supported, but was ${initialTxFee.normal::class.qualifiedName}", + ) + + val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens( + network = nativeCurrencyStatus.currency.network, + ).mapNotNull { currency -> + (currency as? CryptoCurrency.Token)?.contractAddress + }.toSet() + + val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses + .filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token } + .sortedByDescending { it.value.amount } + .filter { status -> + val token = status.currency as? CryptoCurrency.Token ?: return@filter false + token.contractAddress.lowercase() in supportedGaslessTokens + } + + /** + * Selects token with highest balance to maximize chances of successful fee payment. + * Returns null if no suitable tokens found. + */ + val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull() + ?: raise(GaslessError.NoSupportedTokensFound) + + return tokenFeeCalculator.calculateTokenFee( + walletManager = walletManager, + tokenForPayFeeStatus = tokenForPayFeeStatus, + nativeCurrencyStatus = nativeCurrencyStatus, + initialFee = initialFee, + ).bind() + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt new file mode 100644 index 0000000000..f20c162b52 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt @@ -0,0 +1,112 @@ +package com.tangem.domain.transaction.usecase.gasless + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.GetFeeError.GaslessError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.domain.transaction.raiseIllegalStateError +import com.tangem.domain.walletmanager.WalletManagersFacade + +class GetFeeForTokenUseCase( + private val gaslessTransactionRepository: GaslessTransactionRepository, + private val walletManagersFacade: WalletManagersFacade, + private val demoConfig: DemoConfig, + private val currenciesRepository: CurrenciesRepository, + private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val currencyChecksRepository: CurrencyChecksRepository, +) { + + private val tokenFeeCalculator = TokenFeeCalculator( + walletManagersFacade = walletManagersFacade, + gaslessTransactionRepository = gaslessTransactionRepository, + demoConfig = demoConfig, + ) + + suspend operator fun invoke( + userWallet: UserWallet, + token: CryptoCurrency, + transactionData: TransactionData, + ): Either { + return either { + catch( + block = { + if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) { + raise(GaslessError.NetworkIsNotSupported) + } + + val walletManager = prepareWalletManager(userWallet, token.network) + + val initialTxFee = tokenFeeCalculator.calculateInitialFee( + userWallet = userWallet, + network = token.network, + walletManager = walletManager, + transactionData = transactionData, + ).bind() + + val initialFeeEth = initialTxFee.normal as? Fee.Ethereum + ?: raiseIllegalStateError( + error = "only Fee.Ethereum supported, but was different", + ) + + val nativeCurrency = currenciesRepository.getNetworkCoin( + userWalletId = userWallet.walletId, + networkId = token.network.id, + derivationPath = token.network.derivationPath, + ) + + val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWallet.walletId, + ).getOrNull()?.filter { + it.currency.network.id == token.network.id + } ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}") + + val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find { + it.currency.id == nativeCurrency.id + } ?: raiseIllegalStateError("native currency not found for network ${token.network.id}") + + val tokenCurrencyStatus = userCurrenciesStatusesByNetwork.find { + it.currency.id == token.id + } ?: raiseIllegalStateError("token currency not found for network ${token.network.id}") + + tokenFeeCalculator.calculateTokenFee( + walletManager = walletManager, + tokenForPayFeeStatus = tokenCurrencyStatus, + nativeCurrencyStatus = nativeCurrencyStatus, + initialFee = initialFeeEth, + ).bind() + }, + catch = { + raise(GaslessError.DataError(it)) + }, + ) + } + } + + @Suppress("NullableToStringCall") + private suspend fun Raise.prepareWalletManager( + userWallet: UserWallet, + network: Network, + ): EthereumWalletManager { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = network, + ) + val ethereumWalletManager = walletManager as? EthereumWalletManager + ?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported") + return ethereumWalletManager + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/IsGaslessFeeSupportedForNetwork.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/IsGaslessFeeSupportedForNetwork.kt new file mode 100644 index 0000000000..18a61d0319 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/IsGaslessFeeSupportedForNetwork.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.transaction.usecase.gasless + +import com.tangem.domain.models.network.Network +import com.tangem.domain.tokens.repository.CurrencyChecksRepository + +/** + * Use case to check if gasless fee is supported for a given network. + */ +class IsGaslessFeeSupportedForNetwork( + private val currencyChecksRepository: CurrencyChecksRepository, +) { + + operator fun invoke(network: Network): Boolean { + return currencyChecksRepository.isNetworkSupportedForGaslessTx(network) + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt new file mode 100644 index 0000000000..41e65be9d6 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt @@ -0,0 +1,233 @@ +package com.tangem.domain.transaction.usecase.gasless + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.either +import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchain.extensions.Result +import com.tangem.domain.demo.DemoTransactionSender +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.GetFeeError.GaslessError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.domain.transaction.raiseIllegalStateError +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.extensions.isZero +import java.math.BigDecimal +import java.math.BigInteger +import java.math.RoundingMode + +internal class TokenFeeCalculator( + private val walletManagersFacade: WalletManagersFacade, + private val gaslessTransactionRepository: GaslessTransactionRepository, + private val demoConfig: DemoConfig, +) { + + suspend fun calculateInitialFee( + userWallet: UserWallet, + network: Network, + walletManager: EthereumWalletManager, + transactionData: TransactionData, + ): Either { + return either { + val transactionSender = if (userWallet is UserWallet.Cold && + demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) + ) { + demoTransactionSender(userWallet, network) + } else { + walletManager + } + + val maybeFee = when (val result = transactionSender.getFee(transactionData = transactionData)) { + is Result.Success -> result.data + is Result.Failure -> raise(GaslessError.DataError(result.error)) + } + maybeFee + } + } + + suspend fun estimateInitialFee( + userWallet: UserWallet, + amount: BigDecimal, + txTokenCurrencyStatus: CryptoCurrencyStatus, + ): Either { + return either { + val network = txTokenCurrencyStatus.currency.network + val amountData = amount.convertToSdkAmount(txTokenCurrencyStatus) + val result = if (userWallet is UserWallet.Cold && + demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) + ) { + demoTransactionSender(userWallet, network).estimateFee( + amount = amountData, + destination = "", + ) + } else { + walletManagersFacade.estimateFee( + amount = amountData, + userWalletId = userWallet.walletId, + network = network, + ) + } + + when (result) { + is Result.Success -> result.data + is Result.Failure -> raise(GaslessError.DataError(result.error)) + null -> raise(GetFeeError.UnknownError) + } + } + } + + suspend fun calculateTokenFee( + walletManager: EthereumWalletManager, + tokenForPayFeeStatus: CryptoCurrencyStatus, + nativeCurrencyStatus: CryptoCurrencyStatus, + initialFee: Fee.Ethereum, + ): Either { + return either { + // fast finish to skip calculations if no funds in token + if (tokenForPayFeeStatus.value.amount?.isZero() == true) { + raise(GaslessError.NotEnoughFunds) + } + + val tokenForPayFee = tokenForPayFeeStatus.currency as? CryptoCurrency.Token + ?: raiseIllegalStateError("only tokens are supported") + + val transferFeeToContractAmount = createTokenAmount( + token = tokenForPayFee, + value = BigDecimal(FEE_TRANSFER_AMOUNT), + ) + val feeDestination = gaslessTransactionRepository.getTokenFeeReceiverAddress() + val feeTransferGasLimitResult = walletManager.getGasLimit( + amount = transferFeeToContractAmount, + destination = feeDestination, + callData = TransferERC20TokenCallData( + destination = feeDestination, + amount = transferFeeToContractAmount, + ), + ) + + val feeTransferGasLimit = when (feeTransferGasLimitResult) { + is Result.Failure -> raise(GaslessError.DataError(feeTransferGasLimitResult.error)) + is Result.Success -> feeTransferGasLimitResult.data + }.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT) + + val baseGas = gaslessTransactionRepository.getBaseGasForTransaction() + + val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas + + val maxFeePerGas = when (initialFee) { + is Fee.Ethereum.EIP1559 -> initialFee.maxFeePerGas + is Fee.Ethereum.Legacy -> initialFee.gasPrice + is Fee.Ethereum.TokenCurrency -> raiseIllegalStateError("initialFee could only be native") + } + + val feeInNativeCurrency = maxTokenFeeGas + .multiply(maxFeePerGas) + .multiply(GAS_PRICE_MULTIPLIER.toBigInteger()) + + val nativeFiatRate = nativeCurrencyStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null") + val tokenFiatRate = tokenForPayFeeStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null") + + val coinPriceInTokenInBigDecimal = nativeFiatRate.divide( + tokenFiatRate, + maxOf(nativeCurrencyStatus.currency.decimals, tokenForPayFee.decimals), + RoundingMode.UP, + ) + + val coinPriceInTokenBigInt = coinPriceInTokenInBigDecimal + .movePointRight(tokenForPayFee.decimals) + .increaseByPercent(PERCENT_TO_INCREASE_TOKEN_PRICE) + .toBigInteger() + + val feeInTokenCurrency = coinPriceInTokenInBigDecimal.multiply( + feeInNativeCurrency + .toBigDecimal() + .movePointLeft(nativeCurrencyStatus.currency.decimals), + ) + + val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO + if (tokenBalance < feeInTokenCurrency) { + raise(GaslessError.NotEnoughFunds) + } + + val amount = createTokenAmount(tokenForPayFee, feeInTokenCurrency) + + val fee = Fee.Ethereum.TokenCurrency( + amount = amount, + gasLimit = maxTokenFeeGas, + coinPriceInToken = coinPriceInTokenBigInt, + feeTransferGasLimit = feeTransferGasLimit, + baseGas = baseGas, + ) + TransactionFeeExtended( + transactionFee = TransactionFee.Single(normal = fee), + feeTokenId = tokenForPayFee.id, + ) + } + } + + private fun createTokenAmount(token: CryptoCurrency.Token, value: BigDecimal): Amount = Amount( + token = Token( + symbol = token.symbol, + contractAddress = token.contractAddress, + decimals = token.decimals, + ), + value = value, + ) + + private suspend fun Raise.demoTransactionSender( + userWallet: UserWallet, + network: Network, + ): DemoTransactionSender { + return DemoTransactionSender( + walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network) + ?: raiseIllegalStateError("WalletManager is null"), + ) + } + + private companion object { + /** Amount in token units for fee transfer */ + const val FEE_TRANSFER_AMOUNT = 0.01 // calculate using decimals + /** Gas price safety multiplier for fee calculation */ + const val GAS_PRICE_MULTIPLIER = 2 + const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1 + const val PERCENT_TO_INCREASE_TRANSFER_GASLIMIT = 10 + + /** + * Increases BigDecimal value by specified percentage. + * + * @param percent percentage to increase by (e.g., 1 for 1%, 10 for 10%) + * @return value increased by specified percentage (value * (1 + percent/100)) + * + * Example: + * ``` + * BigDecimal("100").increaseByPercent(1) // 101 + * BigDecimal("100").increaseByPercent(10) // 110 + * BigDecimal("100").increaseByPercent(50) // 150 + * ``` + */ + private fun BigDecimal.increaseByPercent(percent: Int): BigDecimal { + require(percent >= 0) { "Percent must be non-negative" } + val multiplier = BigDecimal.ONE.add(BigDecimal(percent).divide(BigDecimal("100"))) + return this.multiply(multiplier) + } + + private fun BigInteger.increaseByPercent(percent: Int): BigInteger { + require(percent >= 0) { "Percent must be non-negative" } + val multiplier = BigDecimal.ONE.add(BigDecimal(percent).divide(BigDecimal("100"))) + return BigDecimal(this).multiply(multiplier).toBigInteger() + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt new file mode 100644 index 0000000000..3c60d6efea --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt @@ -0,0 +1,474 @@ +package com.tangem.domain.transaction.usecase.gasless + +import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchain.extensions.Result +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.walletmanager.WalletManagersFacade +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.math.BigDecimal +import java.math.BigInteger + +/** + * Unit tests for [TokenFeeCalculator]. + * Tests cover fee calculation logic, error handling, and edge cases. + */ +class TokenFeeCalculatorTest { + + private lateinit var walletManagersFacade: WalletManagersFacade + private lateinit var gaslessTransactionRepository: GaslessTransactionRepository + private lateinit var demoConfig: DemoConfig + private lateinit var tokenFeeCalculator: TokenFeeCalculator + + private lateinit var mockWalletManager: EthereumWalletManager + private lateinit var mockNetwork: Network + private lateinit var mockUserWallet: UserWallet + private lateinit var mockUserWalletId: UserWalletId + private lateinit var mockTransactionData: TransactionData + + @Before + fun setup() { + walletManagersFacade = mockk() + gaslessTransactionRepository = mockk() + demoConfig = mockk() + + tokenFeeCalculator = TokenFeeCalculator( + walletManagersFacade = walletManagersFacade, + gaslessTransactionRepository = gaslessTransactionRepository, + demoConfig = demoConfig, + ) + + mockWalletManager = mockk() + mockNetwork = mockk() + mockUserWallet = mockk() + mockUserWalletId = mockk() + mockTransactionData = mockk() + + + // Default mock behavior + every { demoConfig.isDemoCardId(any()) } returns false + every { mockUserWallet.walletId } returns mockUserWalletId + } + + // ===== calculateInitialFee Tests ===== + + @Test + fun `calculateInitialFee should return success when getFee succeeds`() = runTest { + // Given + val expectedFee = createMockTransactionFee() + coEvery { mockWalletManager.getFee(mockTransactionData) } returns Result.Success(expectedFee) + + // When + val result = tokenFeeCalculator.calculateInitialFee( + userWallet = mockUserWallet, + network = mockNetwork, + walletManager = mockWalletManager, + transactionData = mockTransactionData, + ) + + // Then + assertTrue(result.isRight()) + result.onRight { fee -> + assertEquals(expectedFee, fee) + } + coVerify { mockWalletManager.getFee(mockTransactionData) } + } + + @Test + fun `calculateInitialFee should return error when getFee fails`() = runTest { + // Given + val failure = Result.Failure(BlockchainSdkError.Ethereum.Api(1, "Failed to get fee")) + coEvery { mockWalletManager.getFee(mockTransactionData) } returns failure + + // When + val result = tokenFeeCalculator.calculateInitialFee( + userWallet = mockUserWallet, + network = mockNetwork, + walletManager = mockWalletManager, + transactionData = mockTransactionData, + ) + + // Then + assertTrue(result.isLeft()) + result.onLeft { error -> + assertTrue(error is GetFeeError.GaslessError) + } + } + + // ===== estimateInitialFee Tests ===== + + @Test + fun `estimateInitialFee should return success when estimation succeeds`() = runTest { + // Given + val amount = BigDecimal("100") + val tokenStatus = createMockTokenStatus() + val expectedFee = createMockTransactionFee() + + coEvery { walletManagersFacade.estimateFee(any(), any(), any()) } returns Result.Success(expectedFee) + + // When + val result = tokenFeeCalculator.estimateInitialFee( + userWallet = mockUserWallet, + amount = amount, + txTokenCurrencyStatus = tokenStatus, + ) + + // Then + assertTrue(result.isRight()) + result.onRight { fee -> + assertEquals(expectedFee, fee) + } + } + + @Test + fun `estimateInitialFee should return error when estimation fails`() = runTest { + // Given + val amount = BigDecimal("100") + val tokenStatus = createMockTokenStatus() + val failure = Result.Failure(BlockchainSdkError.NPError("Estimation failed")) + + coEvery { + walletManagersFacade.estimateFee(any(), any(), any()) + } returns failure + + // When + val result = tokenFeeCalculator.estimateInitialFee( + userWallet = mockUserWallet, + amount = amount, + txTokenCurrencyStatus = tokenStatus, + ) + + // Then + assertTrue(result.isLeft()) + } + + @Test + fun `estimateInitialFee should return error when result is null`() = runTest { + // Given + val amount = BigDecimal("100") + val tokenStatus = createMockTokenStatus() + + coEvery { walletManagersFacade.estimateFee(any(), any(), any()) } returns null + + // When + val result = tokenFeeCalculator.estimateInitialFee( + userWallet = mockUserWallet, + amount = amount, + txTokenCurrencyStatus = tokenStatus, + ) + + // Then + assertTrue(result.isLeft()) + result.onLeft { error -> + assertTrue(error is GetFeeError.UnknownError) + } + } + + // ===== calculateTokenFee Tests ===== + + @Test + fun `calculateTokenFee should calculate correct fee for token payment`() = runTest { + // Given + val tokenStatus = createMockTokenStatus( + balance = BigDecimal("1000"), + fiatRate = BigDecimal("1"), // 1 USDC = 1 USD + ) + val nativeStatus = createMockNativeCurrencyStatus( + fiatRate = BigDecimal("2000"), // 1 ETH = 2000 USD + ) + val initialFee = createMockEIP1559Fee() + + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isRight()) + result.onRight { feeExtended -> + assertNotNull(feeExtended) + assertEquals(tokenStatus.currency.id, feeExtended.feeTokenId) + assertTrue(feeExtended.transactionFee is TransactionFee.Single) + } + } + + @Test + fun `calculateTokenFee should return error when token balance is insufficient`() = runTest { + // Given + val tokenStatus = createMockTokenStatus( + balance = BigDecimal("0.001"), // Very small balance + fiatRate = BigDecimal("1"), + ) + val nativeStatus = createMockNativeCurrencyStatus( + fiatRate = BigDecimal("2000"), + ) + val initialFee = createMockEIP1559Fee() + + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isLeft()) + result.onLeft { error -> + assertTrue(error is GetFeeError.GaslessError.NotEnoughFunds) + } + } + + @Test + fun `calculateTokenFee should return error when fiatRate is null`() = runTest { + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + // Given + val tokenStatus = createMockTokenStatus( + balance = BigDecimal("1000"), + fiatRate = null, // No fiat rate + ) + val nativeStatus = createMockNativeCurrencyStatus( + fiatRate = BigDecimal("2000"), + ) + val initialFee = createMockEIP1559Fee() + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isLeft()) + } + + @Test + fun `calculateTokenFee should return error when getGasLimit fails`() = runTest { + // Given + val tokenStatus = createMockTokenStatus() + val nativeStatus = createMockNativeCurrencyStatus() + val initialFee = createMockEIP1559Fee() + + val failure = Result.Failure(BlockchainSdkError.NPError("Gas limit fetch failed")) + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns failure + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isLeft()) + result.onLeft { error -> + assertTrue(error is GetFeeError.GaslessError.DataError) + } + } + + @Test + fun `calculateTokenFee should handle Legacy fee type correctly`() = runTest { + // Given + val tokenStatus = createMockTokenStatus() + val nativeStatus = createMockNativeCurrencyStatus() + val initialFee = Fee.Ethereum.Legacy( + amount = mockk(relaxed = true), + gasLimit = BigInteger("100000"), + gasPrice = BigInteger("50000000000"), // 50 Gwei + ) + + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isRight()) + } + + @Test + fun `calculateTokenFee should reject TokenCurrency as initial fee`() = runTest { + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + + // Given + val tokenStatus = createMockTokenStatus() + val nativeStatus = createMockNativeCurrencyStatus() + val initialFee = Fee.Ethereum.TokenCurrency( + amount = mockk(relaxed = true), + gasLimit = BigInteger("100000"), + coinPriceInToken = BigInteger("1000"), + feeTransferGasLimit = BigInteger("60000"), + baseGas = BigInteger("21000"), + ) + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isLeft()) + } + + @Test + fun `calculateTokenFee should apply 1 percent increase to token price`() = runTest { + // Expected + + val expectedAmount = Amount( + value = BigDecimal("37.400000000000000000000000000000000000"), + token = Token( + name = "USDC", + symbol = "USDC", + contractAddress = "0xUSDC", + decimals = 6, + ) + ) + val expectedGasLimit = "187000".toBigInteger() + val expectedCoinPriceInToken = BigInteger("2020000000") // 2000 * 1.01 * 10^6 + val expectedFeeTransferLimit = "66000".toBigInteger() + val expectedBaseGas = "21000".toBigInteger() + + // Given + val tokenStatus = createMockTokenStatus( + balance = BigDecimal("1000"), + fiatRate = BigDecimal("1"), + decimals = 6, + ) + val nativeStatus = createMockNativeCurrencyStatus( + fiatRate = BigDecimal("2000"), + decimals = 18, + ) + val initialFee = createMockEIP1559Fee() + + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isRight()) + result.onRight { feeExtended -> + val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency + + assertEquals(expectedAmount, fee.amount) + assertEquals(expectedGasLimit, fee.gasLimit) + assertEquals(expectedCoinPriceInToken, fee.coinPriceInToken) + assertEquals(expectedFeeTransferLimit, fee.feeTransferGasLimit) + assertEquals(expectedBaseGas, fee.baseGas) + } + } + + // ===== Helper Methods ===== + + private fun createMockTransactionFee(): TransactionFee { + val fee = Fee.Ethereum.EIP1559( + amount = mockk(relaxed = true), + gasLimit = BigInteger("100000"), + maxFeePerGas = BigInteger("50000000000"), + priorityFee = BigInteger("1000000000"), + ) + return TransactionFee.Single(normal = fee) + } + + private fun createMockEIP1559Fee(): Fee.Ethereum.EIP1559 { + return Fee.Ethereum.EIP1559( + amount = mockk(relaxed = true), + gasLimit = BigInteger("100000"), + maxFeePerGas = BigInteger("50000000000"), // 50 Gwei + priorityFee = BigInteger("1000000000"), // 1 Gwei + ) + } + + private fun createMockTokenStatus( + balance: BigDecimal = BigDecimal("1000"), + fiatRate: BigDecimal? = BigDecimal("1"), + decimals: Int = 6, + ): CryptoCurrencyStatus { + val token = mockk(relaxed = true) + every { token.symbol } returns "USDC" + every { token.contractAddress } returns "0xUSDC" + every { token.decimals } returns decimals + every { token.network } returns mockNetwork + every { token.id } returns mockk(relaxed = true) + + val status = mockk() + every { status.currency } returns token + every { status.value.amount } returns balance + every { status.value.fiatRate } returns fiatRate + every { status.value.yieldSupplyStatus } returns null + + return status + } + + private fun createMockNativeCurrencyStatus( + fiatRate: BigDecimal? = BigDecimal("2000"), + decimals: Int = 18, + ): CryptoCurrencyStatus { + val coin = mockk(relaxed = true) + every { coin.symbol } returns "ETH" + every { coin.decimals } returns decimals + every { coin.network } returns mockNetwork + + val status = mockk() + every { status.currency } returns coin + every { status.value.fiatRate } returns fiatRate + every { status.value.yieldSupplyStatus } returns null + + return status + } +} diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetExplorerTransactionUrlUseCase.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetExplorerTransactionUrlUseCase.kt index dcbec88d76..a9a2f27737 100644 --- a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetExplorerTransactionUrlUseCase.kt +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetExplorerTransactionUrlUseCase.kt @@ -14,6 +14,10 @@ class GetExplorerTransactionUrlUseCase( return either { catch( block = { + if (txHash.isEmpty()) { + raise(TxStatusError.EmptyUrlError) + } + repository.getTxExploreUrl(txHash, networkId).ifEmpty { raise(TxStatusError.EmptyUrlError) } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayEligibilityManager.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayEligibilityManager.kt index cc26e4832a..fcb41b6984 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayEligibilityManager.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayEligibilityManager.kt @@ -6,4 +6,5 @@ interface TangemPayEligibilityManager { suspend fun getEligibleWallets(shouldExcludePaeraCustomers: Boolean): List suspend fun getTangemPayAvailability(): Boolean + fun reset() } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 234366b079..baac6c4f75 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -15,11 +15,26 @@ data class MainScreenCustomerInfo( ) data class CustomerInfo( + val customerId: String?, val productInstance: ProductInstance?, - val isKycApproved: Boolean, + val kycStatus: KycStatus, val cardInfo: CardInfo?, ) { + enum class KycStatus { + /** Initial state */ + INIT, + + /** Performing the check */ + PENDING, + + /** SumSub approved */ + APPROVED, + + /** The check failed, documents rejected */ + REJECTED, + } + data class ProductInstance( val id: String, val cardId: String, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt index 6cf5c28a6f..33ddcff4c8 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt @@ -128,7 +128,7 @@ class TangemPayMainScreenCustomerInfoUseCase( } .map { customerInfo -> Timber.tag(TAG).i("customerInfo") - if (customerInfo.cardInfo == null && customerInfo.isKycApproved) { + if (customerInfo.cardInfo == null && customerInfo.kycStatus == CustomerInfo.KycStatus.APPROVED) { // If order id wasn't saved -> start order creation and get customer info onboardingRepository.createOrder(userWalletId) } @@ -151,7 +151,12 @@ class TangemPayMainScreenCustomerInfoUseCase( OrderStatus.NEW, OrderStatus.PROCESSING, -> MainScreenCustomerInfo( - info = CustomerInfo(productInstance = null, isKycApproved = true, cardInfo = null), + info = CustomerInfo( + customerId = null, + productInstance = null, + kycStatus = CustomerInfo.KycStatus.APPROVED, + cardInfo = null, + ), orderStatus = orderStatus, ).right() diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt index 2f944349f5..e4b6af4d70 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt @@ -52,4 +52,128 @@ sealed class TangemPayAnalyticsEvents( categoryName = "Visa Screen", event = "Button - Visa Swap", ) + + class ChooseWalletPopup : TangemPayAnalyticsEvents( + categoryName = "Visa Onboarding", + event = "Choose Wallet Popup", + ) + + class CardSettingsClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Card Settings", + ) + + class TermsAndLimitsClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Terms And Limits", + ) + + class FreezeCardClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Freeze Card", + ) + + class FreezeCardConfirmShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Popup - Freeze Confirmation", + ) + + class FreezeCardConfirmClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Freeze Confirmation On Popup", + ) + + class UnfreezeCardClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Unfreeze Card", + ) + + class UnfreezeCardConfirmShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Popup - Unfreeze Confirmation", + ) + + class UnfreezeCardConfirmClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Unfreeze Confirmation On Popup", + ) + + class PinCodeClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - PIN Code", + ) + + class ChangePinScreenShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Screen - Change PIN", + ) + + class ChangePinSubmitClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Set PIN On Change PIN Screen", + ) + + class ChangePinSuccessShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Message - PIN Setup Success", + ) + + class CurrentPinShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Popup - Current PIN", + ) + + class ChangePinOnCurrentPinClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Change PIN On Current PIN", + ) + + class ViewCardDetailsClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - View Card Details", + ) + + class CopyCardNumberClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Copy Card Number", + ) + + class CopyCardExpiryClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Copy Card Expiry", + ) + + class CopyCardCVVClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Copy CVV", + ) + + class AddToWalletClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Add Card To Wallet", + ) + + class WithdrawClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Visa Withdraw", + ) + + class GoToSupportOnBetaBannerClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Go To Support On Beta Banner", + ) + + class TransactionInListClicked(type: String, status: String) : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Clicked On Transaction In List", + params = mapOf( + "type" to type, + "status" to status, + ), + ) + + class SupportOnTransactionPopupClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Support On Transaction Popup", + ) } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt index 27ca88f239..e03d928530 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt @@ -296,6 +296,6 @@ sealed class WcAnalyticAccountEvents( ) companion object { - const val WC_CATEGORY_ACCOUNT_NAME = "WalletConnect - Account" + const val WC_CATEGORY_ACCOUNT_NAME = "WalletConnect / Account" } } \ No newline at end of file diff --git a/domain/wallets/detekt-baseline-debug.xml b/domain/wallets/detekt-baseline-debug.xml index b55688a97f..cbda6c3779 100644 --- a/domain/wallets/detekt-baseline-debug.xml +++ b/domain/wallets/detekt-baseline-debug.xml @@ -28,7 +28,6 @@ MultilineLambdaItParameter:UpdateWalletUseCase.kt$UpdateWalletUseCase${ when (it) { is SaveWalletError.DataError -> DataError( IllegalStateException("Failed to update wallet: ${it.messageId}"), ) is SaveWalletError.WalletAlreadySaved -> UpdateWalletError.NameAlreadyExists } } NestedScopeFunctions:ColdUserWalletBuilder.kt$ColdUserWalletBuilder$let { UserWallet.Cold( walletId = it, name = generateWalletNameUseCase( card = card, productType = productType, isStartToCoin = cardTypesResolver.isStart2Coin(), ), cardsInWallet = backupCardsIds.plus(card.cardId), scanResponse = this, isMultiCurrency = cardTypesResolver.isMultiwalletAllowed(), hasBackupError = hasBackupError, ) } NoNameShadowing:SaveWalletUseCase.kt$SaveWalletUseCase$userWallet - NullableToStringCall:GenerateBuyTangemCardLinkUseCase.kt$GenerateBuyTangemCardLinkUseCase$$id NullableToStringCall:UpdateWalletUseCase.kt$UpdateWalletUseCase$${it.messageId} ObjectExtendsThrowable:UserWalletsListError.kt$UserWalletsListError$AllKeysInvalidated : UserWalletsListError ObjectExtendsThrowable:UserWalletsListError.kt$UserWalletsListError$BiometricsAuthenticationDisabled : UserWalletsListError diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/extension/UserWalletExtensions.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/extension/UserWalletExtensions.kt index 1047bb4f03..693877d47c 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/extension/UserWalletExtensions.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/extension/UserWalletExtensions.kt @@ -36,7 +36,7 @@ val UserWallet.isAccountsSupported is UserWallet.Hot -> true is UserWallet.Cold -> { with(this.cardTypesResolver) { - isMultiwalletAllowed() && !isWhiteWallet() + isMultiwalletAllowed() && scanResponse.card.settings.isHDWalletAllowed } } } \ No newline at end of file diff --git a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldSupplyEnterStatus.kt b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldSupplyEnterStatus.kt deleted file mode 100644 index 4ecdb12732..0000000000 --- a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldSupplyEnterStatus.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.domain.yield.supply.models - -enum class YieldSupplyEnterStatus { - Enter, - Exit, -} \ No newline at end of file diff --git a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldSupplyPendingStatus.kt b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldSupplyPendingStatus.kt new file mode 100644 index 0000000000..ec24a8c5d7 --- /dev/null +++ b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldSupplyPendingStatus.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.yield.supply.models + +sealed class YieldSupplyPendingStatus(open val txIds: List) { + data class Enter(override val txIds: List) : YieldSupplyPendingStatus(txIds) + data class Exit(override val txIds: List) : YieldSupplyPendingStatus(txIds) +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/FeeExtensions.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/FeeExtensions.kt index 6df54ae127..07ce01ecb4 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/FeeExtensions.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/FeeExtensions.kt @@ -41,6 +41,7 @@ fun Fee.increaseGasLimitBy(percent: BigInteger): Fee { value = increasedGasLimit.toBigDecimal().multiply(increasedGasPrice).movePointLeft(this.amount.decimals), ) return when (this) { + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") is Fee.Ethereum.EIP1559 -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) is Fee.Ethereum.Legacy -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) } diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt index 9a73322ca8..c8841c32c5 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt @@ -1,10 +1,9 @@ package com.tangem.domain.yield.supply import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.yield.supply.models.YieldMarketToken -import com.tangem.domain.yield.supply.models.YieldSupplyEnterStatus +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData import kotlinx.coroutines.flow.Flow @@ -72,14 +71,23 @@ interface YieldSupplyRepository { * * @param userWalletId the wallet the action was performed with * @param cryptoCurrency the currency or token the action relates to - * @param yieldSupplyEnterStatus the last action intent: [YieldSupplyEnterStatus.Enter] or [YieldSupplyEnterStatus.Exit] + * @param yieldSupplyPendingStatus the last action intent: [YieldSupplyPendingStatus.Enter] or [YieldSupplyPendingStatus.Exit] */ - suspend fun saveTokenProtocolStatus( + suspend fun saveTokenProtocolPendingStatus( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, - yieldSupplyEnterStatus: YieldSupplyEnterStatus?, + yieldSupplyPendingStatus: YieldSupplyPendingStatus?, ) + /** + * Retrieve the hashes of pending (unconfirmed) transactions for the given wallet and currency. + * + * @param userWalletId the wallet to query + * @param cryptoCurrency the currency containing network information + * @return list of transaction hashes that are still unconfirmed, or an empty list if none exist + */ + suspend fun getPendingTxHashes(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): List + /** * Get the last saved user‑initiated yield protocol action for the given wallet and currency, if any. * @@ -91,23 +99,10 @@ interface YieldSupplyRepository { * @param cryptoCurrency the currency or token to query * @return the last action intent or null if nothing has been recorded */ - fun getTokenProtocolStatus(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): YieldSupplyEnterStatus? - - /** - * Get the pending status of the yield protocol action for the given wallet and currency, if any. - * - * Used to determine whether the UI should display an intermediate "processing" state - * until the protocol status retrieved from backend reflects the change. The value is - * transient (in‑memory only) and is not persisted across app restarts. - * - * @param userWalletId the wallet to query - * @param cryptoCurrencyStatus the currency status to query - * @return the pending action intent or null if nothing has been recorded - */ - suspend fun getTokenPendingStatus( + fun getTokenProtocolPendingStatus( userWalletId: UserWalletId, - cryptoCurrencyStatus: CryptoCurrencyStatus, - ): YieldSupplyEnterStatus? + cryptoCurrency: CryptoCurrency, + ): YieldSupplyPendingStatus? fun getShouldShowYieldPromoBanner(): Flow diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCase.kt index 35de1341bb..271e08be2a 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCase.kt @@ -4,21 +4,36 @@ import arrow.core.Either import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.yield.supply.YieldSupplyRepository -import com.tangem.domain.yield.supply.models.YieldSupplyEnterStatus +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus class YieldSupplyEnterStatusUseCase( private val yieldSupplyRepository: YieldSupplyRepository, ) { - operator fun invoke( + suspend operator fun invoke( userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, - ): Either { + ): Either { return Either.catch { - yieldSupplyRepository.getTokenProtocolStatus( + val status = yieldSupplyRepository.getTokenProtocolPendingStatus( userWalletId, cryptoCurrencyStatus.currency, ) + val pendingTxHashes = yieldSupplyRepository + .getPendingTxHashes(userWalletId, cryptoCurrencyStatus.currency) + .toSet() + val hasPendingTx = status?.txIds?.any { it in pendingTxHashes } == true + + if (hasPendingTx) { + status + } else { + yieldSupplyRepository.saveTokenProtocolPendingStatus( + userWalletId, + cryptoCurrencyStatus.currency, + null, + ) + null + } } } } \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTracker.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTracker.kt new file mode 100644 index 0000000000..2b2068de6c --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTracker.kt @@ -0,0 +1,147 @@ +package com.tangem.domain.yield.supply.usecase + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.yield.supply.YieldSupplyRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap + +/** + * Periodically checks pending Yield Supply transaction hashes for a given set of wallet/currency pairs. + * + * - Call [addPending] right after sending a transaction to start tracking its hashes. + * - The checker runs every [CHECK_INTERVAL_MS] and queries repository for the current pending tx hashes. + * - If none of the stored hashes are pending anymore for a tracked entry, it triggers a single-network status refresh + * via [singleNetworkStatusFetcher] and removes the entry from tracking. + * - The periodic job stops automatically when there is nothing left to track. + */ +class YieldSupplyPendingTracker( + private val yieldSupplyRepository: YieldSupplyRepository, + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + private val coroutineScope: CoroutineScope, +) { + + private data class TrackedKey( + val userWalletId: UserWalletId, + val cryptoCurrencyId: CryptoCurrency.ID, + ) + + private data class TrackedEntry( + val cryptoCurrency: CryptoCurrency, + val txIds: Set, + val attempts: Int = 0, + ) + + private val trackedEntries = ConcurrentHashMap() + private val mutex = Mutex() + private var checkingJob: Job? = null + + /** + * Add tx ids to track for a wallet/currency pair. Starts periodic checking if needed. + */ + suspend fun addPending(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, txIds: List) { + val key = TrackedKey(userWalletId, cryptoCurrency.id) + trackedEntries.compute(key) { _, existing -> + if (existing == null) { + TrackedEntry( + cryptoCurrency = cryptoCurrency, + txIds = txIds.toSet(), + ) + } else { + existing.copy(txIds = existing.txIds + txIds) + } + } + + startAutomaticCheckingIfNeeded() + } + + private suspend fun startAutomaticCheckingIfNeeded() { + mutex.withLock { + if (checkingJob?.isActive == true) return + + checkingJob = coroutineScope.launch { + while (isActive) { + try { + checkAllTracked() + } catch (ex: Exception) { + Timber.e(ex) + } + delay(CHECK_INTERVAL_MS) + } + } + } + } + + private suspend fun stopAutomaticCheckingIfEmpty() { + if (trackedEntries.isEmpty()) { + mutex.withLock { + if (trackedEntries.isEmpty()) { + checkingJob?.cancel() + checkingJob = null + } + } + } + } + + private suspend fun checkAllTracked() { + val keysSnapshot = trackedEntries.keys().toList() + + if (keysSnapshot.isEmpty()) { + stopAutomaticCheckingIfEmpty() + return + } + + val networksToFetch = mutableSetOf() + + for (key in keysSnapshot) { + val entry = trackedEntries[key] ?: continue + if (entry.txIds.isEmpty()) { + trackedEntries.remove(key) + continue + } + + val pendingTxHashes = + yieldSupplyRepository.getPendingTxHashes( + userWalletId = key.userWalletId, + cryptoCurrency = entry.cryptoCurrency, + ) + .toSet() + + val hasStillPending = entry.txIds.any { it in pendingTxHashes } + + if (hasStillPending) { + trackedEntries.computeIfPresent(key) { _, current -> + val newAttempts = current.attempts + 1 + if (newAttempts >= MAX_ATTEMPTS) null else current.copy(attempts = newAttempts) + } + } else { + trackedEntries.remove(key) + } + networksToFetch.add( + SingleNetworkStatusFetcher.Params( + userWalletId = key.userWalletId, + network = entry.cryptoCurrency.network, + ), + ) + } + + for (params in networksToFetch) { + singleNetworkStatusFetcher(params) + } + + stopAutomaticCheckingIfEmpty() + } + + private companion object { + private const val CHECK_INTERVAL_MS = 10_000L + private const val MAX_ATTEMPTS = 6 + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt new file mode 100644 index 0000000000..3929c947ab --- /dev/null +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt @@ -0,0 +1,242 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.YieldSupplyRepository +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +class YieldSupplyEnterStatusUseCaseTest { + + private val yieldSupplyRepository: YieldSupplyRepository = mockk() + + private lateinit var useCase: YieldSupplyEnterStatusUseCase + + private val userWalletId = UserWalletId("abcdef012345") + + @BeforeEach + fun setUp() { + useCase = YieldSupplyEnterStatusUseCase( + yieldSupplyRepository = yieldSupplyRepository, + ) + } + + @Test + fun `GIVEN pending tx exists in status WHEN invoke THEN returns status`() = runTest { + val token = createToken() + val cryptoStatus = createStatus(token) + val pendingTxHash = "0xabc123" + val status = YieldSupplyPendingStatus.Enter(txIds = listOf(pendingTxHash)) + + coEvery { + yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) + } returns status + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency) + } returns listOf(pendingTxHash) + + val result = useCase(userWalletId, cryptoStatus) + + assertThat(result.isRight()).isTrue() + val value = (result as Either.Right).value + assertThat(value).isEqualTo(status) + } + + @Test + fun `GIVEN no pending tx matches status WHEN invoke THEN clears status and returns null`() = runTest { + val token = createToken() + val cryptoStatus = createStatus(token) + val status = YieldSupplyPendingStatus.Enter(txIds = listOf("0xabc123")) + + coEvery { + yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) + } returns status + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency) + } returns listOf("0xdifferent") + coEvery { + yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null) + } returns Unit + + val result = useCase(userWalletId, cryptoStatus) + + assertThat(result.isRight()).isTrue() + val value = (result as Either.Right).value + assertThat(value).isNull() + coVerify { yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null) } + } + + @Test + fun `GIVEN status is null WHEN invoke THEN returns null`() = runTest { + val token = createToken() + val cryptoStatus = createStatus(token) + + coEvery { + yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) + } returns null + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency) + } returns emptyList() + coEvery { + yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null) + } returns Unit + + val result = useCase(userWalletId, cryptoStatus) + + assertThat(result.isRight()).isTrue() + val value = (result as Either.Right).value + assertThat(value).isNull() + } + + @Test + fun `GIVEN empty pending tx list WHEN invoke THEN clears status and returns null`() = runTest { + val token = createToken() + val cryptoStatus = createStatus(token) + val status = YieldSupplyPendingStatus.Enter(txIds = listOf("0xabc123")) + + coEvery { + yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) + } returns status + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency) + } returns emptyList() + coEvery { + yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null) + } returns Unit + + val result = useCase(userWalletId, cryptoStatus) + + assertThat(result.isRight()).isTrue() + val value = (result as Either.Right).value + assertThat(value).isNull() + coVerify { yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null) } + } + + @Test + fun `GIVEN repository throws exception WHEN invoke THEN returns left with error`() = runTest { + val token = createToken() + val cryptoStatus = createStatus(token) + val exception = RuntimeException("Network error") + + coEvery { + yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) + } throws exception + + val result = useCase(userWalletId, cryptoStatus) + + assertThat(result.isLeft()).isTrue() + val error = (result as Either.Left).value + assertThat(error).isEqualTo(exception) + } + + @Test + fun `GIVEN exit status with pending tx WHEN invoke THEN returns status`() = runTest { + val token = createToken() + val cryptoStatus = createStatus(token) + val pendingTxHash = "0xexit456" + val status = YieldSupplyPendingStatus.Exit(txIds = listOf(pendingTxHash)) + + coEvery { + yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) + } returns status + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency) + } returns listOf(pendingTxHash) + + val result = useCase(userWalletId, cryptoStatus) + + assertThat(result.isRight()).isTrue() + val value = (result as Either.Right).value + assertThat(value).isEqualTo(status) + } + + @Test + fun `GIVEN multiple tx ids with one matching WHEN invoke THEN returns status`() = runTest { + val token = createToken() + val cryptoStatus = createStatus(token) + val matchingTxHash = "0xmatch" + val status = YieldSupplyPendingStatus.Enter(txIds = listOf("0xfirst", matchingTxHash, "0xlast")) + + coEvery { + yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token) + } returns status + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency) + } returns listOf(matchingTxHash) + + val result = useCase(userWalletId, cryptoStatus) + + assertThat(result.isRight()).isTrue() + val value = (result as Either.Right).value + assertThat(value).isEqualTo(status) + } + + private fun createToken(): CryptoCurrency.Token { + val rawNetworkId = "ethereum" + val derivationPath = Network.DerivationPath.None + val network = Network( + id = Network.ID(value = rawNetworkId, derivationPath = derivationPath), + backendId = rawNetworkId, + name = rawNetworkId, + currencySymbol = rawNetworkId.take(3).uppercase(), + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId), + ), + network = network, + name = "TEST_TOKEN", + symbol = "TTK", + decimals = 18, + iconUrl = null, + isCustom = false, + contractAddress = "0xToken", + ) + } + + private fun createStatus(token: CryptoCurrency.Token): CryptoCurrencyStatus { + return CryptoCurrencyStatus( + currency = token, + value = CryptoCurrencyStatus.Custom( + amount = BigDecimal.ZERO, + fiatAmount = BigDecimal.ZERO, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x0000000000000000000000000000000000000000", + type = NetworkAddress.Address.Type.Primary, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + ), + ) + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt new file mode 100644 index 0000000000..986b720a55 --- /dev/null +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt @@ -0,0 +1,308 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.yield.supply.YieldSupplyRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class YieldSupplyPendingTrackerTest { + + private val yieldSupplyRepository: YieldSupplyRepository = mockk() + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher = mockk() + + private lateinit var testScope: TestScope + private lateinit var useCase: YieldSupplyPendingTracker + + private val userWalletId = UserWalletId("00") + + @BeforeEach + fun setUp() { + testScope = TestScope(StandardTestDispatcher()) + useCase = YieldSupplyPendingTracker( + yieldSupplyRepository = yieldSupplyRepository, + singleNetworkStatusFetcher = singleNetworkStatusFetcher, + coroutineScope = testScope, + ) + } + + @Test + fun `GIVEN new tx ids WHEN addPending THEN stores entry for tracking`() = runTest { + val token = createToken() + val txIds = listOf("0xabc123", "0xdef456") + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } returns txIds + coEvery { + singleNetworkStatusFetcher.invoke(any()) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId, token, txIds) + + testScope.advanceTimeBy(10_001L) + + coVerify { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } + } + + @Test + fun `GIVEN existing entry WHEN addPending THEN merges tx ids`() = runTest { + val token = createToken() + val firstTxIds = listOf("0xfirst") + val secondTxIds = listOf("0xsecond") + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } returns firstTxIds + secondTxIds + coEvery { + singleNetworkStatusFetcher.invoke(any()) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId, token, firstTxIds) + useCase.addPending(userWalletId, token, secondTxIds) + + testScope.advanceTimeBy(10_001L) + + coVerify { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } + } + + @Test + fun `GIVEN tx still pending WHEN addPending THEN triggers network status refresh`() = runTest { + val token = createToken() + val txIds = listOf("0xpending") + val paramsSlot = slot() + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } returns txIds + coEvery { + singleNetworkStatusFetcher.invoke(capture(paramsSlot)) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId, token, txIds) + + testScope.advanceTimeBy(10_001L) + + coVerify { + singleNetworkStatusFetcher.invoke(any()) + } + assertThat(paramsSlot.captured.userWalletId).isEqualTo(userWalletId) + assertThat(paramsSlot.captured.network).isEqualTo(token.network) + } + + @Test + fun `GIVEN tx no longer pending WHEN addPending THEN removes entry from tracking and refreshes network`() = runTest { + val token = createToken() + val txIds = listOf("0xconfirmed") + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } returns emptyList() + coEvery { + singleNetworkStatusFetcher.invoke(any()) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId, token, txIds) + + testScope.advanceTimeBy(10_001L) + + coVerify(exactly = 1) { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } + coVerify(exactly = 1) { + singleNetworkStatusFetcher.invoke(any()) + } + } + + @Test + fun `GIVEN max attempts reached WHEN addPending THEN stops tracking entry`() = runTest { + val token = createToken() + val txIds = listOf("0xstuck") + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } returns txIds + coEvery { + singleNetworkStatusFetcher.invoke(any()) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId, token, txIds) + + testScope.advanceTimeBy(10_001L * 6) + + coVerify(exactly = 6) { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } + + testScope.advanceTimeBy(10_001L) + + coVerify(exactly = 6) { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } + } + + @Test + fun `GIVEN multiple wallets WHEN addPending THEN tracks each separately`() = runTest { + val token = createToken() + val userWalletId1 = UserWalletId("00") + val userWalletId2 = UserWalletId("01") + val txIds1 = listOf("0xtx1") + val txIds2 = listOf("0xtx2") + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId1, token) + } returns txIds1 + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId2, token) + } returns txIds2 + coEvery { + singleNetworkStatusFetcher.invoke(any()) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId1, token, txIds1) + useCase.addPending(userWalletId2, token, txIds2) + + testScope.advanceTimeBy(10_001L) + + coVerify { + yieldSupplyRepository.getPendingTxHashes(userWalletId1, token) + yieldSupplyRepository.getPendingTxHashes(userWalletId2, token) + } + } + + @Test + fun `GIVEN multiple wallets with same network WHEN checkAllTracked THEN fetches network status once per unique pair`() = runTest { + val token = createToken() + val userWalletId1 = UserWalletId("00") + val userWalletId2 = UserWalletId("01") + val txIds1 = listOf("0xtx1") + val txIds2 = listOf("0xtx2") + val capturedParams = mutableListOf() + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId1, token) + } returns txIds1 + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId2, token) + } returns txIds2 + coEvery { + singleNetworkStatusFetcher.invoke(capture(capturedParams)) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId1, token, txIds1) + useCase.addPending(userWalletId2, token, txIds2) + + testScope.advanceTimeBy(1000) + + assertThat(capturedParams).hasSize(2) + assertThat(capturedParams.map { it.userWalletId }.toSet()) + .containsExactly(userWalletId1, userWalletId2) + assertThat(capturedParams.map { it.network }.toSet()) + .containsExactly(token.network) + } + + @Test + fun `GIVEN same wallet with different currencies WHEN addPending THEN tracks each currency separately`() = runTest { + val token1 = createToken(networkId = "ethereum", contractAddress = "0xToken1") + val token2 = createToken(networkId = "polygon", contractAddress = "0xToken2") + val txIds1 = listOf("0xtx1") + val txIds2 = listOf("0xtx2") + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token1) + } returns txIds1 + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token2) + } returns txIds2 + coEvery { + singleNetworkStatusFetcher.invoke(any()) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId, token1, txIds1) + useCase.addPending(userWalletId, token2, txIds2) + + testScope.advanceTimeBy(10_001L) + + coVerify { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token1) + yieldSupplyRepository.getPendingTxHashes(userWalletId, token2) + } + } + + @Test + fun `GIVEN partial tx match WHEN addPending THEN still triggers refresh`() = runTest { + val token = createToken() + val trackedTxIds = listOf("0xtracked1", "0xtracked2") + val pendingTxIds = listOf("0xtracked1") + val paramsSlot = slot() + + coEvery { + yieldSupplyRepository.getPendingTxHashes(userWalletId, token) + } returns pendingTxIds + coEvery { + singleNetworkStatusFetcher.invoke(capture(paramsSlot)) + } returns Either.Right(Unit) + + useCase.addPending(userWalletId, token, trackedTxIds) + + testScope.advanceTimeBy(10_001L) + + coVerify { + singleNetworkStatusFetcher.invoke(any()) + } + assertThat(paramsSlot.captured.userWalletId).isEqualTo(userWalletId) + } + + private fun createToken( + networkId: String = "ethereum", + contractAddress: String = "0xToken", + ): CryptoCurrency.Token { + val derivationPath = Network.DerivationPath.None + val network = Network( + id = Network.ID(value = networkId, derivationPath = derivationPath), + backendId = networkId, + name = networkId, + currencySymbol = networkId.take(3).uppercase(), + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(networkId), + suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress), + ), + network = network, + name = "TEST_TOKEN", + symbol = "TTK", + decimals = 18, + iconUrl = null, + isCustom = false, + contractAddress = contractAddress, + ) + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/analytics/AccountSettingsAnalyticEvents.kt b/features/account/impl/src/main/java/com/tangem/features/account/analytics/AccountSettingsAnalyticEvents.kt index dc8afe9786..02d6dd5ae5 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/analytics/AccountSettingsAnalyticEvents.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/analytics/AccountSettingsAnalyticEvents.kt @@ -84,6 +84,7 @@ sealed class AccountSettingsAnalyticEvents( event = "Account Error", params = buildMap { put("Error", error) + put("Source", source.value) }, ) diff --git a/features/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..d0ef646fb1 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt @@ -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 @@ -125,11 +124,8 @@ internal class ArchivedAccountListModel @Inject constructor( messageSender.send( DialogMessage( - title = resourceReference(R.string.common_something_went_wrong), - message = resourceReference( - id = R.string.account_recover_limit_dialog_description, - formatArgs = wrappedList(AccountList.MAX_ACCOUNTS_COUNT.toString()), - ), + title = resourceReference(R.string.account_recover_limit_dialog_title), + message = resourceReference(R.string.account_archived_recover_error_message), firstActionBuilder = { firstAction }, ), ) @@ -139,7 +135,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/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt index 8720ae4650..e0783453da 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt @@ -314,7 +314,7 @@ internal class AccountCreateEditModel @Inject constructor( private fun showAccountNameExist() { val dialogMessage = DialogMessage( - title = resourceReference(R.string.common_something_went_wrong), + title = resourceReference(R.string.account_form_name_already_exist_error_title), message = resourceReference(R.string.account_form_name_already_exist_error_description), ) messageSender.send(dialogMessage) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt index a3e921cf2d..b9944a8996 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUMBuilder.kt @@ -88,6 +88,7 @@ internal class AccountCreateEditUMBuilder( val Account.portfolioIcon: CryptoPortfolioIcon get() = when (this) { is Account.CryptoPortfolio -> this.icon + is Account.Payment -> TODO("[REDACTED_JIRA]") } fun AccountCreateEditUM.updateColorSelect(color: CryptoPortfolioIcon.Color): AccountCreateEditUM { diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt index c1bdf53342..fad8eeb69f 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt @@ -120,15 +120,21 @@ internal class AccountDetailsModel @Inject constructor( error = error.tag, ) analyticsEventHandler.send(event) - val titleRes = R.string.common_something_went_wrong - val messageRes = when (error) { + val titleRes: Int + val messageRes: Int + when (error) { is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountListRequirementsNotMet, is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountNotFound, is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountsNotCreated, is ArchiveCryptoPortfolioUseCase.Error.DataOperationFailed, - -> R.string.account_generic_error_dialog_message - is ArchiveCryptoPortfolioUseCase.Error.ActiveReferralStatus, - -> R.string.account_could_not_archive_referral_program_message + -> { + titleRes = R.string.common_something_went_wrong + messageRes = R.string.account_generic_error_dialog_message + } + is ArchiveCryptoPortfolioUseCase.Error.ActiveReferralStatus -> { + titleRes = R.string.account_could_not_archive_referral_program_title + messageRes = R.string.account_could_not_archive_referral_program_message + } } val dialogMessage = DialogMessage( @@ -147,6 +153,7 @@ internal class AccountDetailsModel @Inject constructor( isLoading = false, ) } + is Account.Payment -> TODO("[REDACTED_JIRA]") } val isMultiCurrency = getUserWalletUseCase(account.accountId.userWalletId).getOrNull() ?.isMultiCurrency == true diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt index 0a0dd71c89..d1a90f171f 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt @@ -23,10 +23,7 @@ import com.tangem.features.wallet.utils.UserWalletsFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -65,13 +62,18 @@ internal class UserWalletListModel @Inject constructor( ) init { - combine( - flow = userWalletsFetcher.userWallets, - flow2 = shouldSaveUserWalletsUseCase(), - flow3 = isWalletSavingInProgress, - ) { userWallets, shouldSaveUserWallets, isWalletSavingInProgress -> - updateState(userWallets, shouldSaveUserWallets, isWalletSavingInProgress) - }.launchIn(modelScope) + modelScope.launch { + val userWalletsFlow = userWalletsFetcher.userWallets.stateIn(this) + state.update { value -> value.copy(userWallets = userWalletsFlow.value) } + + combine( + flow = userWalletsFlow, + flow2 = shouldSaveUserWalletsUseCase(), + flow3 = isWalletSavingInProgress, + ) { userWallets, shouldSaveUserWallets, isWalletSavingInProgress -> + updateState(userWallets, shouldSaveUserWallets, isWalletSavingInProgress) + }.collect() + } } private fun updateState( diff --git a/features/feed/api/build.gradle.kts b/features/feed/api/build.gradle.kts index 73aa93625c..890d5aefad 100644 --- a/features/feed/api/build.gradle.kts +++ b/features/feed/api/build.gradle.kts @@ -13,6 +13,9 @@ android { dependencies { implementation(deps.compose.foundation) + /* Project - API */ + api(projects.features.account.api) + /* Project - Core */ implementation(projects.core.decompose) implementation(projects.core.ui) diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioComponent.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioComponent.kt new file mode 100644 index 0000000000..902f38339d --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioComponent.kt @@ -0,0 +1,21 @@ +package com.tangem.features.feed.components.market.details.portfolio.add + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.currency.CryptoCurrency + +interface AddToPortfolioComponent : ComposableBottomSheetComponent { + + data class Params( + val addToPortfolioManager: AddToPortfolioManager, + val callback: Callback, + val shouldSkipTokenActionsScreen: Boolean = false, + ) + + interface Callback { + fun onDismiss() + fun onSuccess(addedToken: CryptoCurrency) + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioManager.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioManager.kt new file mode 100644 index 0000000000..8d4e35e9ce --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioManager.kt @@ -0,0 +1,43 @@ +package com.tangem.features.feed.components.market.details.portfolio.add + +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.account.PortfolioFetcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.Serializable + +interface AddToPortfolioManager { + + val token: TokenMarketParams + val analyticsParams: AnalyticsParams? + val portfolioFetcher: PortfolioFetcher + + val state: StateFlow + + val allAvailableNetworks: Flow> + fun setTokenNetworks(networks: List) + + sealed interface State { + data object Init : State + data class AvailableToAdd( + val availableToAddData: AvailableToAddData, + ) : State + + data object NothingToAdd : State + } + + @Serializable + data class AnalyticsParams( + val source: String, + ) + + interface Factory { + fun create( + scope: CoroutineScope, + token: TokenMarketParams, + analyticsParams: AnalyticsParams?, + ): AddToPortfolioManager + } +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt new file mode 100644 index 0000000000..9d221287dd --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt @@ -0,0 +1,58 @@ +package com.tangem.features.feed.components.market.details.portfolio.add + +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.Serializable + +data class AvailableToAddData( + val availableToAddWallets: Map, +) { + val isAvailableToAdd: Boolean + get() = availableToAddWallets.isNotEmpty() + val isSinglePortfolio: Boolean + get() = availableToAddWallets.size == 1 && availableToAddWallets.values.first().accounts.size == 1 +} + +data class AvailableToAddWallet( + val userWallet: UserWallet, + val accounts: List, + val availableNetworks: Set, + val availableToAddAccounts: Map, +) + +@Serializable +data class AvailableToAddAccount( + val account: AccountStatus, + val availableNetworks: Set, + val addedNetworks: Set, +) { + val isSingleNetwork: Boolean + get() = availableNetworks.size == 1 + + val availableToAddNetworks: Set = availableNetworks + .filter { available -> addedNetworks.none { added -> added.backendId == available.networkId } } + .toSet() + + val addedMarketNetworks: Set = availableNetworks + .filter { available -> addedNetworks.any { added -> added.backendId == available.networkId } } + .toSet() +} + +@Serializable +data class SelectedPortfolio( + val userWallet: UserWallet, + val account: AvailableToAddAccount, + val isAccountMode: Boolean, + val isAvailableMorePortfolio: Boolean, +) + +data class SelectedNetwork( + val selectedNetwork: TokenMarketInfo.Network, + val cryptoCurrency: CryptoCurrency, + val isAvailableMoreNetwork: Boolean, +) \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt index 43d68d3d11..f609996618 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt @@ -6,10 +6,12 @@ import androidx.compose.runtime.State import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableContentComponent @Stable -interface FeedEntryComponent { +interface FeedEntryComponent : ComposableContentComponent { @Composable fun BottomSheetContent( @@ -18,7 +20,7 @@ interface FeedEntryComponent { modifier: Modifier, ) - interface Factory { - fun create(context: AppComponentContext): FeedEntryComponent + interface Factory : ComponentFactory { + fun create(context: AppComponentContext, entryRoute: FeedEntryRoute?): FeedEntryComponent } } \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt new file mode 100644 index 0000000000..7c9ffb7c0a --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt @@ -0,0 +1,30 @@ +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 + + @Serializable + data class NewsDetail(val articleId: Int, val preselectedArticlesId: List) : FeedEntryRoute +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/NewsDetailsDeepLinkHandler.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/NewsDetailsDeepLinkHandler.kt new file mode 100644 index 0000000000..f6ce381120 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/NewsDetailsDeepLinkHandler.kt @@ -0,0 +1,11 @@ +package com.tangem.features.feed.entry.deeplink + +import android.net.Uri +import kotlinx.coroutines.CoroutineScope + +interface NewsDetailsDeepLinkHandler { + + interface Factory { + fun create(coroutineScope: CoroutineScope, deeplinkUri: Uri): NewsDetailsDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts index 9d6659788e..e16f11c113 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. @@ -88,6 +90,7 @@ dependencies { implementation(projects.core.analytics.models) implementation(projects.core.navigation) implementation(projects.core.utils) + implementation(projects.core.datasource) /* Common */ implementation(projects.common.ui) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index dbcfde3de2..0be2031e7d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -1,30 +1,36 @@ 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.models.AnalyticsParam import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState -import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.news.model.NewsListConfig import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent +import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent +import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent import com.tangem.features.feed.entry.components.FeedEntryComponent +import com.tangem.features.feed.entry.components.FeedEntryRoute +import com.tangem.features.feed.model.FeedEntryModel import com.tangem.features.feed.model.feed.FeedModelClickIntents -import com.tangem.features.feed.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,9 +38,12 @@ import dagger.assisted.AssistedInject @Stable internal class DefaultFeedEntryComponent @AssistedInject constructor( @Assisted context: AppComponentContext, + @Assisted entryRoute: FeedEntryRoute?, private val feedEntryChildFactory: FeedEntryChildFactory, ) : FeedEntryComponent, AppComponentContext by context { + private val model: FeedEntryModel = getOrCreateModel() + private val stackNavigation = StackNavigation() private val innerRouter = InnerRouter( @@ -43,7 +52,12 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( ) private val clickIntents = object : FeedEntryClickIntents { - override fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency) { + override fun onMarketItemClick( + token: TokenMarketParams, + appCurrency: AppCurrency, + source: String, + newsId: Int?, + ) { innerRouter.push( route = FeedEntryChildFactory.Child.TokenDetails( params = DefaultMarketsTokenDetailsComponent.Params( @@ -52,19 +66,66 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( shouldShowPortfolio = true, analyticsParams = DefaultMarketsTokenDetailsComponent.AnalyticsParams( blockchain = null, - source = "Market", + newsId = newsId, + source = source, ), + onBackClicked = { onChildBack() }, + onArticleClick = { articleId, preselectedArticlesId -> + onArticleClick( + articleId = articleId, + preselectedArticlesId = preselectedArticlesId, + screenSource = AnalyticsParam.ScreensSources.Markets, + ) + }, ), ), ) } - 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 = token, + appCurrency = currency, + source = AnalyticsParam.ScreensSources.Market.value, + ) + }, + preselectedSortType = sortBy ?: SortByTypeUM.Rating, + shouldAlwaysShowSearchBar = sortBy == null, + ), + ), + ) } - override fun onArticleClick(articleId: Int) { - innerRouter.push(FeedEntryChildFactory.Child.NewsDetails) + override fun onArticleClick( + articleId: Int, + preselectedArticlesId: List, + screenSource: AnalyticsParam.ScreensSources, + paginationConfig: NewsListConfig?, + ) { + innerRouter.push( + FeedEntryChildFactory.Child.NewsDetails( + params = DefaultNewsDetailsComponent.Params( + screenSource = screenSource.value, + articleId = articleId, + onBackClicked = { onChildBack() }, + preselectedArticlesId = preselectedArticlesId, + paginationConfig = paginationConfig, + onTokenClick = { token, currency -> + onMarketItemClick( + token = token, + appCurrency = currency, + source = AnalyticsParam.ScreensSources.NewsPage.value, + newsId = articleId, + ) + }, + ), + ), + ) } override fun onOpenAllNews() { @@ -72,23 +133,25 @@ 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, + onBackClicked = { onChildBack() }, + ) + }, + ) @Composable override fun BottomSheetContent( @@ -96,27 +159,117 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier, ) { - val stackState by stack.subscribeAsState() - - BackHandler(enabled = bottomSheetState.value == BottomSheetState.EXPANDED) { + val bsState by bottomSheetState + val stackStack = stack.subscribeAsState() + BackHandler( + enabled = bottomSheetState.value == BottomSheetState.EXPANDED && + stackStack.value.active.configuration !is FeedEntryChildFactory.Child.Feed, + ) { onChildBack() } - EntryBottomSheetContent( - stackState = stackState, + LaunchedEffect(bsState) { + model.containerBottomSheetState.value = bsState + } + + EntryContent( + bottomSheetState = bottomSheetState, + stackState = stackStack, onHeaderSizeChange = onHeaderSizeChange, + isOpenedInBottomSheet = true, ) } + @Composable + override fun Content(modifier: Modifier) { + val background = TangemTheme.colors.background.tertiary + CompositionLocalProvider( + LocalMainBottomSheetColor provides remember(background) { mutableStateOf(background) }, + ) { + 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() }, + onArticleClick = { articleId, preselectedArticlesId -> + clickIntents.onArticleClick( + articleId = articleId, + preselectedArticlesId = preselectedArticlesId, + screenSource = AnalyticsParam.ScreensSources.Token, + paginationConfig = null, + ) + }, + ), + ) + FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList( + DefaultMarketsTokenListComponent.Params( + onBackClicked = { router.pop() }, + onTokenClick = { token, currency -> + clickIntents.onMarketItemClick( + token = token, + appCurrency = currency, + source = AnalyticsParam.ScreensSources.Market.value, + ) + }, + preselectedSortType = SortByTypeUM.Rating, + shouldAlwaysShowSearchBar = false, + ), + ) + is FeedEntryRoute.NewsDetail -> FeedEntryChildFactory.Child.NewsDetails( + DefaultNewsDetailsComponent.Params( + screenSource = AnalyticsParam.ScreensSources.NewsLink.value, + articleId = entryRoute.articleId, + onBackClicked = { router.pop() }, + preselectedArticlesId = entryRoute.preselectedArticlesId, + onTokenClick = { token, currency -> + clickIntents.onMarketItemClick( + token = token, + appCurrency = currency, + source = AnalyticsParam.ScreensSources.NewsPage.value, + newsId = entryRoute.articleId, + ) + }, + ), + ) + 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..0168a3c88a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -1,18 +1,26 @@ package com.tangem.features.feed.components import androidx.compose.runtime.Immutable +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.navigation.Route -import com.tangem.core.ui.decompose.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.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent import com.tangem.features.feed.components.news.list.DefaultNewsListComponent import kotlinx.serialization.Serializable import javax.inject.Inject -internal class FeedEntryChildFactory @Inject constructor() { +internal class FeedEntryChildFactory @Inject constructor( + private val analyticsEventHandler: AnalyticsEventHandler, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val portfolioComponentFactory: MarketsPortfolioComponent.Factory, +) { @Serializable @Immutable @@ -24,7 +32,7 @@ internal class FeedEntryChildFactory @Inject constructor() { @Serializable @Immutable - data object TokenList : Child + data class TokenList(val params: DefaultMarketsTokenListComponent.Params) : Child @Serializable @Immutable @@ -36,40 +44,51 @@ internal class FeedEntryChildFactory @Inject constructor() { @Serializable @Immutable - data object NewsDetails : Child + data class NewsDetails(val params: DefaultNewsDetailsComponent.Params) : Child } fun createChild( child: Child, appComponentContext: AppComponentContext, feedEntryClickIntents: FeedEntryClickIntents, - ): ComposableModularContentComponent { + onBackClicked: () -> Unit, + ): ComposableModularBottomSheetContentComponent { return when (child) { is Child.TokenDetails -> { DefaultMarketsTokenDetailsComponent( appComponentContext = appComponentContext, params = child.params, + analyticsEventHandler = analyticsEventHandler, + accountsFeatureToggles = accountsFeatureToggles, + portfolioComponentFactory = portfolioComponentFactory, ) } is Child.TokenList -> { DefaultMarketsTokenListComponent( appComponentContext = appComponentContext, - onTokenClick = { token, appCurrency -> - feedEntryClickIntents.onMarketItemClick( - token, - appCurrency, - ) - }, + params = child.params, ) } - Child.NewsDetails -> { + is Child.NewsDetails -> { DefaultNewsDetailsComponent( appComponentContext = appComponentContext, + params = child.params, ) } Child.NewsList -> { DefaultNewsListComponent( appComponentContext = appComponentContext, + params = DefaultNewsListComponent.Params( + onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig -> + feedEntryClickIntents.onArticleClick( + articleId = currentArticle, + preselectedArticlesId = prefetchedArticles, + screenSource = AnalyticsParam.ScreensSources.NewsList, + paginationConfig = paginationConfig, + ) + }, + onBackClick = onBackClicked, + ), ) } Child.Feed -> { 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..f7073e2ca2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt @@ -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,21 @@ 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( + isSearchBarClickable = bottomSheetState.value == BottomSheetState.EXPANDED, + feedListSearchBar = state.feedListSearchBar, + ) } @Composable - override fun Content(modifier: Modifier) { + override fun Content(bottomSheetState: State, modifier: Modifier) { LifecycleStartEffect(Unit) { feedComponentModel.isVisibleOnScreen.value = true onStopOrDispose { @@ -42,8 +47,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..2a561c6ba5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt @@ -1,28 +1,128 @@ 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.context.child +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel +import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent +import com.tangem.features.feed.model.market.details.state.TokenNetworksState +import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent +import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable internal class DefaultMarketsTokenDetailsComponent( appComponentContext: AppComponentContext, val params: Params, -) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + analyticsEventHandler: AnalyticsEventHandler, + private val accountsFeatureToggles: AccountsFeatureToggles, + portfolioComponentFactory: MarketsPortfolioComponent.Factory, +) : 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) + + private val portfolioComponent: MarketsPortfolioComponent? = if (updatedParams.shouldShowPortfolio) { + portfolioComponentFactory.create( + context = child("my_portfolio"), + params = MarketsPortfolioComponent.Params( + updatedParams.token, + analyticsParams = analyticsParams?.source?.let { MarketsPortfolioComponent.AnalyticsParams(it) }, + ), + ) + } else { + null + } + + init { + + componentScope.launch(dispatchers.default) { + model.networksState.collectLatest { state -> + when (state) { + is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(state.networks) + TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable() + else -> {} + } + } + } + + // === Analytics === + if (analyticsParams != null) { + analyticsEventHandler.send( + MarketDetailsAnalyticsEvent.EventBuilder( + token = params.token, + ).screenOpened( + blockchain = analyticsParams.blockchain, + source = analyticsParams.source, + newsId = analyticsParams.newsId, + ), + ) + } } @Composable - override fun Content(modifier: Modifier) { + override fun Title(bottomSheetState: State) { + val state by model.state.collectAsStateWithLifecycle() + val background = LocalMainBottomSheetColor.current.value + MarketsTokenDetailsTopBar( + onBackClick = { params.onBackClicked() }, + isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED, + shouldShowPriceSubtitle = state.shouldShowPriceSubtitle, + tokenName = state.tokenName, + tokenPrice = state.priceText, + backgroundColor = background, + ) } @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 = portfolioComponent?.let { component -> + { blockModifier -> + component.Content(blockModifier) + } + }, + ) } @Serializable @@ -31,11 +131,14 @@ internal class DefaultMarketsTokenDetailsComponent( val appCurrency: AppCurrency, val shouldShowPortfolio: Boolean, val analyticsParams: AnalyticsParams?, + val onBackClicked: () -> Unit, + val onArticleClick: (articleId: Int, preselectedArticlesId: List) -> Unit, ) @Serializable data class AnalyticsParams( val blockchain: String?, val source: String, + val newsId: Int? = null, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/analytics/MarketTokenAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/analytics/MarketTokenAnalyticsEvent.kt new file mode 100644 index 0000000000..255e31fb5b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/analytics/MarketTokenAnalyticsEvent.kt @@ -0,0 +1,47 @@ +package com.tangem.features.feed.components.market.details.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE +import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR +import com.tangem.core.analytics.models.OneTimePerSessionEvent + +internal sealed class MarketTokenAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "CoinPage", event = event, params = params) { + + data class TokenNewsViewed( + private val token: String, + ) : MarketTokenAnalyticsEvent( + event = "Token News Viewed", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + ), + ), OneTimePerSessionEvent { + override val oneTimeEventId: String = event + token + } + + data class TokenNewsCarouselScrolled( + private val token: String, + ) : MarketTokenAnalyticsEvent( + event = "Token News Carousel Scrolled", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + ), + ), OneTimePerSessionEvent { + override val oneTimeEventId: String = event + token + } + + data class TokenNewsLoadError( + private val code: Int?, + private val message: String, + ) : MarketTokenAnalyticsEvent( + event = "Token News Load Error", + params = mapOf( + ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(), + ERROR_MESSAGE to message, + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt new file mode 100644 index 0000000000..b762aa9727 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt @@ -0,0 +1,55 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.addtoken.AddTokenContent +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenModel +import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow + +internal class AddTokenComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : AppComponentContext by context, ComposableContentComponent { + + private val model: AddTokenModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state = model.uiState.collectAsStateWithLifecycle() + val um = state.value ?: return + AddTokenContent( + modifier = modifier, + state = um, + ) + } + + data class Params( + val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + val selectedPortfolio: Flow, + val selectedNetwork: Flow, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onChangeNetworkClick() + fun onChangePortfolioClick() + fun onTokenAdded(status: CryptoCurrencyStatus) + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): AddTokenComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt new file mode 100644 index 0000000000..71c5a6dc55 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt @@ -0,0 +1,45 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.ChooseNetworkModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.ChooseNetworkContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class ChooseNetworkComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : AppComponentContext by context, ComposableContentComponent { + + private val model: ChooseNetworkModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + ChooseNetworkContent(state) + } + + data class Params( + val selectedPortfolio: SelectedPortfolio, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onNetworkSelected(network: TokenMarketInfo.Network) + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): ChooseNetworkComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt new file mode 100644 index 0000000000..edd7f01ef5 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt @@ -0,0 +1,212 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.backStack +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes +import com.tangem.features.feed.impl.R +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAddToPortfolioComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: AddToPortfolioComponent.Params, + portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, + addTokenComponentFactory: AddTokenComponent.Factory, + tokenActionsComponentFactory: TokenActionsComponent.Factory, + private val chooseNetworkComponentFactory: ChooseNetworkComponent.Factory, +) : AppComponentContext by context, AddToPortfolioComponent { + + private val model: AddToPortfolioModel = getOrCreateModel(params) + + private val portfolioSelectorComponent: PortfolioSelectorComponent = portfolioSelectorComponentFactory.create( + context = child("portfolioSelectorComponent"), + params = PortfolioSelectorComponent.Params( + portfolioFetcher = model.portfolioFetcher, + controller = model.portfolioSelectorController, + ), + ) + + private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create( + context = child("addTokenComponent"), + params = AddTokenComponent.Params( + eventBuilder = model.eventBuilder, + callbacks = model, + selectedPortfolio = model.selectedPortfolio, + selectedNetwork = model.selectedNetwork, + ), + ) + + private val tokenActionsComponent: TokenActionsComponent = tokenActionsComponentFactory.create( + context = child("tokenActionsComponent"), + params = TokenActionsComponent.Params( + eventBuilder = model.eventBuilder, + callbacks = model, + data = model.tokenActionsData, + ), + ) + + private val childStack = childStack( + key = "addToPortfolioStack", + handleBackButton = true, + source = model.navigation, + serializer = AddToPortfolioRoutes.serializer(), + initialStack = { model.currentStack }, + childFactory = ::contentChild, + ) + + private fun onBack() { + if (childStack.backStack.isNotEmpty()) model.navigation.pop() else dismiss() + } + + override fun dismiss() { + params.callback.onDismiss() + } + + @Composable + override fun BottomSheet() { + val stack by childStack.subscribeAsState() + val contentStack = remember { mutableStateOf(stack) } + val currentRoute = stack.active.configuration + val isNotEmpty = currentRoute != AddToPortfolioRoutes.Empty + if (isNotEmpty) { + contentStack.value = stack + } + + TangemModalBottomSheet( + scrollableContent = false, + onBack = ::onBack, + config = TangemBottomSheetConfig( + isShown = isNotEmpty, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors.background.tertiary, + title = { state -> + AnimatedContent(targetState = contentStack.value) { stack -> + BottomSheetTitle( + stack = stack, + onBackClick = ::onBack, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + content = { state -> + AnimatedContent(targetState = contentStack.value) { stack -> + val paddingModifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ) + val isScrollableContent = when (stack.active.configuration) { + AddToPortfolioRoutes.PortfolioSelector -> false + AddToPortfolioRoutes.AddToken, + AddToPortfolioRoutes.Empty, + is AddToPortfolioRoutes.NetworkSelector, + AddToPortfolioRoutes.TokenActions, + -> true + } + if (isScrollableContent) { + Column( + modifier = paddingModifier.verticalScroll(rememberScrollState()), + ) { + stack.active.instance.Content(modifier = Modifier) + } + } else { + stack.active.instance.Content(modifier = paddingModifier) + } + } + }, + ) + } + + @Composable + private fun BottomSheetTitle( + stack: ChildStack, + onBackClick: (() -> Unit), + modifier: Modifier = Modifier, + ) { + val title: TextReference = when (stack.active.configuration) { + AddToPortfolioRoutes.AddToken -> resourceReference(R.string.common_add_token) + AddToPortfolioRoutes.Empty -> TextReference.EMPTY + is AddToPortfolioRoutes.NetworkSelector -> resourceReference(R.string.common_choose_network) + AddToPortfolioRoutes.TokenActions -> resourceReference(R.string.common_get_token) + AddToPortfolioRoutes.PortfolioSelector -> (stack.active.instance as PortfolioSelectorComponent) + .title.collectAsStateWithLifecycle().value + } + val startIconRes: Int? + val endIconRes: Int? + if (stack.backStack.isNotEmpty()) { + startIconRes = R.drawable.ic_back_24 + endIconRes = null + } else { + startIconRes = null + endIconRes = R.drawable.ic_close_24 + } + TangemModalBottomSheetTitle( + modifier = modifier, + title = title, + startIconRes = startIconRes, + endIconRes = endIconRes, + onStartClick = onBackClick, + onEndClick = onBackClick, + ) + } + + private fun contentChild( + config: AddToPortfolioRoutes, + componentContext: ComponentContext, + ): ComposableContentComponent = when (config) { + AddToPortfolioRoutes.AddToken -> addTokenComponent + AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent + AddToPortfolioRoutes.TokenActions -> tokenActionsComponent + AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY + is AddToPortfolioRoutes.NetworkSelector -> chooseNetworkComponentFactory.create( + context = childByContext(componentContext), + params = ChooseNetworkComponent.Params( + selectedPortfolio = config.selectedPortfolio, + callbacks = model, + ), + ) + } + + @AssistedFactory + interface Factory : AddToPortfolioComponent.Factory { + override fun create( + context: AppComponentContext, + params: AddToPortfolioComponent.Params, + ): DefaultAddToPortfolioComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt new file mode 100644 index 0000000000..9498c4041f --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt @@ -0,0 +1,79 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.TokenActionsModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.TokenActionsContent +import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.tokenreceive.TokenReceiveComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow + +internal class TokenActionsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, +) : AppComponentContext by context, ComposableContentComponent { + + private val model: TokenActionsModel = getOrCreateModel(params) + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = TokenReceiveConfig.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val state = model.uiState.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + val tokenActionsUM = state.value ?: return + TokenActionsContent( + modifier = modifier, + state = tokenActionsUM, + ) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: TokenReceiveConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create( + context = childByContext(componentContext), + params = TokenReceiveComponent.Params( + config = config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + + data class Params( + val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + val data: Flow, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onLaterClick() + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): TokenActionsComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt new file mode 100644 index 0000000000..49d3820aa2 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt @@ -0,0 +1,120 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.converter + +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase +import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddAccount +import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddData +import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddWallet +import javax.inject.Inject + +internal class AvailableToAddDataConverter @Inject constructor( + private val filterAvailableNetworksForWalletUseCase: FilterAvailableNetworksForWalletUseCase, + private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, +) { + + suspend fun convert( + balances: Map, + availableNetworks: Set, + marketParams: TokenMarketParams, + ): AvailableToAddData { + suspend fun AccountStatus.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount? { + val currencies = availableNetworks + .mapNotNull { network -> + createCryptoCurrency( + userWallet = wallet, + network = network, + marketParams = marketParams, + account = this.account, + ) + } + + if (currencies.isEmpty()) return null + + val addedNetworks = getAccountCurrencyStatusUseCase.invokeSync(wallet.walletId, currencies) + .fold( + ifEmpty = { emptySet() }, + ifSome = { map -> + map.values.flatMapTo(hashSetOf()) { statuses -> + statuses.map { it.currency.network } + } + }, + ) + + return AvailableToAddAccount( + account = this, + availableNetworks = availableNetworks, + addedNetworks = addedNetworks, + ) + } + + suspend fun getAvailableToAddWallet( + entry: Map.Entry, + ): AvailableToAddWallet { + val (_, balance) = entry + val wallet = balance.userWallet + val filteredNetworks = wallet.filteredAvailableNetworks(availableNetworks) + val accounts = balance.accountsBalance.accountStatuses + val availableToAddAccounts: Map = accounts + .mapNotNull { accountStatus -> + val availableToAddAccount = accountStatus.getAvailableToAddAccount(wallet) ?: return@mapNotNull null + accountStatus.account.accountId to availableToAddAccount + } + .filter { (_, account) -> account.availableToAddNetworks.isNotEmpty() } + .toMap() + return AvailableToAddWallet( + userWallet = wallet, + accounts = accounts, + availableNetworks = filteredNetworks, + availableToAddAccounts = availableToAddAccounts, + ) + } + + val availableToAddWallets: Map = balances + .map { entry -> + val (walletId, _) = entry + val availableToAddWallet = getAvailableToAddWallet(entry) + walletId to availableToAddWallet + } + .filter { (_, wallet) -> wallet.availableToAddAccounts.isNotEmpty() } + .toMap() + + return AvailableToAddData( + availableToAddWallets = availableToAddWallets, + ) + } + + private fun UserWallet.filteredAvailableNetworks(networks: Set) = + filterAvailableNetworksForWalletUseCase( + userWalletId = this.walletId, + networks = networks, + ) + + private suspend fun createCryptoCurrency( + userWallet: UserWallet, + network: TokenMarketInfo.Network, + marketParams: TokenMarketParams, + account: Account, + ): CryptoCurrency? { + val derivationIndex = when (account) { + is Account.CryptoPortfolio -> account.derivationIndex + is Account.Payment -> TODO("[REDACTED_JIRA]") + } + return getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = marketParams, + network = network, + accountIndex = derivationIndex, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt new file mode 100644 index 0000000000..b522e1b6e3 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt @@ -0,0 +1,21 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.di + +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.DefaultAddToPortfolioManager +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface AddToPortfolioComponentModule { + + @Binds + fun bindAddToPortfolioComponent(factory: DefaultAddToPortfolioComponent.Factory): AddToPortfolioComponent.Factory + + @Binds + fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt new file mode 100644 index 0000000000..b60d84f099 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt @@ -0,0 +1,38 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.ChooseNetworkModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.TokenActionsModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface AddToPortfolioModelModule { + + @Binds + @IntoMap + @ClassKey(AddTokenModel::class) + fun addTokenModel(model: AddTokenModel): Model + + @Binds + @IntoMap + @ClassKey(AddToPortfolioModel::class) + fun addToPortfolioModel(model: AddToPortfolioModel): Model + + @Binds + @IntoMap + @ClassKey(TokenActionsModel::class) + fun tokenActionsModel(model: TokenActionsModel): Model + + @Binds + @IntoMap + @ClassKey(ChooseNetworkModel::class) + fun chooseNetworkModel(model: ChooseNetworkModel): Model +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt new file mode 100644 index 0000000000..4e71735618 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt @@ -0,0 +1,396 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.popToFirst +import com.arkivanov.decompose.router.stack.pushNew +import com.arkivanov.decompose.router.stack.replaceAll +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 +import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddAccount +import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddData +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent +import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.model.PortfolioTokenUMConverter.Companion.toQuickActions +import com.tangem.features.feed.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +private const val TOKEN_ACTIONS_DELAY = 500L + +@ModelScoped +@Suppress("LongParameterList") +internal class AddToPortfolioModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val callbackDelegate: AddToPortfolioCallbackDelegate, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, + private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, + private val messageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, + val portfolioSelectorController: PortfolioSelectorController, +) : Model(), + ChooseNetworkComponent.Callbacks by callbackDelegate, + TokenActionsComponent.Callbacks by callbackDelegate, + AddTokenComponent.Callbacks by callbackDelegate { + + private val params = paramsContainer.require() + val navigation = StackNavigation() + var currentStack = listOf(AddToPortfolioRoutes.Empty) + + /* Flows that hold state and provide it to child models */ + val selectedNetwork: MutableSharedFlow = replayMutableSharedFlow() + val selectedPortfolio: MutableSharedFlow = replayMutableSharedFlow() + val tokenActionsData: MutableSharedFlow = replayMutableSharedFlow() + + private val addToPortfolioManager = params.addToPortfolioManager + val portfolioFetcher = addToPortfolioManager.portfolioFetcher + val eventBuilder = PortfolioAnalyticsEvent.EventBuilder( + token = addToPortfolioManager.token, + source = addToPortfolioManager.analyticsParams?.source, + ) + + val featureData: Flow = combineFeatureData() + + init { + navigation.subscribe { currentStack = it.transformer.invoke(currentStack) } + startAddToPortfolioFlow() + } + + private fun replayMutableSharedFlow() = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @Suppress("LongMethod") + private fun startAddToPortfolioFlow() { + channelFlow { + fun finishFlow() { + params.callback.onDismiss() + channel.close() + } + + fun finishSuccessFlow(addedToken: CryptoCurrencyStatus) { + params.callback.onSuccess(addedToken.currency) + channel.close() + } + + val featureDataFlow: StateFlow = featureData + .filterIsInstance() + .map { it.availableToAddData } + .distinctUntilChanged() + .stateIn(this) + val isAccountMode = portfolioSelectorController.isAccountModeSync() + + // use snapshot data, looks like we don’t need to remap at runtime + val data = featureDataFlow.value + + // you must control it via [AddToPortfolioManager.state] + if (!data.isAvailableToAdd) { + finishFlow() + return@channelFlow + } + + // init data flows, emits on user/code selection, updates state holder + val firstSelectedPortfolio = setupPortfolioFlow(data) + .onEach { selectedPortfolio.emit(it) } + val firstSelectedNetwork = setupNetworkFlow(firstSelectedPortfolio) + .onEach { selectedNetwork.emit(it) } + + val isSinglePortfolio = data.isSinglePortfolio + if (isSinglePortfolio) { + val accountId = data.availableToAddWallets.values.first() + .availableToAddAccounts.values.first() + .account.account.accountId + // force select a portfolio, triggers [selectedPortfolio] + portfolioSelectorController.selectAccount(accountId) + } else { + logAccountSelector(isAccountMode) + navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector) + } + + val firstPartOfNavigation: Job = firstSelectedPortfolio + .onEach { portfolio -> + val isSingleAvailableNetwork = portfolio.account.isSingleNetwork + when { + // force select a network, triggers [selectedNetwork] + isSingleAvailableNetwork -> { + val singleNetwork = portfolio.account.availableToAddNetworks.first() + callbackDelegate.onNetworkSelected(singleNetwork) + } + // it's important to control root screen, UI depends on it(close/arrow icon) + isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio)) + else -> navigation.pushNew(routeToNetworkSelector(portfolio)) + } + } + .launchIn(this) + + // main flow that combine all require data + val allRequireForAdd = combine( + flow = firstSelectedNetwork, + flow2 = firstSelectedPortfolio, + transform = { a, b -> a to b }, + ) + + // suspend until all required data is selected + allRequireForAdd.first() + // line of navigation to AddToken screen is finished; cancel the job, select a new root screen + firstPartOfNavigation.cancel() + navigation.replaceAll(AddToPortfolioRoutes.AddToken) + + var middleNavigationJob: Job? = null + // handle actions from AddToken screen + callbackDelegate.onChangeNetworkClick.receiveAsFlow() + .onEach { + middleNavigationJob?.cancel() + middleNavigationJob = changeNetworkNavigationFlow() + .launchIn(this) + val route = routeToNetworkSelector(selectedPortfolio.first()) + navigation.pushNew(route) + } + .launchIn(this) + // handle actions from AddToken screen + callbackDelegate.onChangePortfolioClick.receiveAsFlow() + .onEach { + middleNavigationJob?.cancel() + setSelectedAccountToSelectorController() + middleNavigationJob = changePortfolioNavigationFlow(data).launchIn(this) + logAccountSelector(isAccountMode) + navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector) + } + .launchIn(this) + + // suspend until token is added + val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() + middleNavigationJob?.cancel() + val selectedPortfolio = selectedPortfolio.first() + + messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) + + if (params.shouldSkipTokenActionsScreen) { + finishSuccessFlow(addedToken) + } else { + setupTokenActionsFlow(selectedPortfolio, addedToken) + .onEach { cryptoCurrencyData -> + tokenActionsData.emit(cryptoCurrencyData) + navigation.replaceAll(AddToPortfolioRoutes.TokenActions) + } + .onEmpty { finishFlow() } + .launchIn(this) + } + + callbackDelegate.onLaterClick.receiveAsFlow().first() + finishFlow() + } + .catch { throwable -> + Timber.e(throwable) + params.callback.onDismiss() + } + .launchIn(modelScope) + } + + private fun logAccountSelector(isAccountMode: Boolean) { + if (isAccountMode) { + analyticsEventHandler.send(eventBuilder.popupToChooseAccount()) + } + } + + private fun changeNetworkNavigationFlow(): Flow { + return setupNetworkFlow(selectedPortfolio) + .onEach { newNetwork -> + selectedNetwork.emit(newNetwork) + navigation.popToFirst() + } + } + + private fun setSelectedAccountToSelectorController() { + modelScope.launch(dispatchers.default) { + val selectedAccount = selectedPortfolio + .first() + .account + .account + .account + .accountId + portfolioSelectorController.selectAccount(selectedAccount) + } + } + + private fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow { + val changedPortfolio = setupPortfolioFlow(data) + .drop(1) + .onEach { portfolio -> navigation.pushNew(routeToNetworkSelector(portfolio)) } + val changedNetwork = setupNetworkFlow(changedPortfolio) + return combine( + flow = changedPortfolio, + flow2 = changedNetwork, + transform = { newPortfolio, newNetwork -> + selectedPortfolio.tryEmit(newPortfolio) + selectedNetwork.tryEmit(newNetwork) + navigation.popToFirst() + }, + ) + } + + private fun setupTokenActionsFlow( + selectedPortfolio: SelectedPortfolio, + addedToken: CryptoCurrencyStatus, + ): Flow { + val timeFlow = channelFlow { + val timerJob = launch { delay(TOKEN_ACTIONS_DELAY) } + getCryptoCurrencyActionsUseCase( + currency = addedToken.currency, + accountId = selectedPortfolio.account.account.account.accountId, + ).onEach { state -> + val requestedQuickActions = toQuickActions(state.states) + when { + requestedQuickActions.isNotEmpty() -> { + timerJob.cancel() + send(state) + } + // wait any requestedQuickActions while timer active + timerJob.isActive -> Unit + else -> close() + } + }.collect() + } + return timeFlow.map { actionsState -> + PortfolioData.CryptoCurrencyData( + userWallet = selectedPortfolio.userWallet, + status = actionsState.cryptoCurrencyStatus, + actions = actionsState.states, + ) + } + } + + private fun setupPortfolioFlow(data: AvailableToAddData): Flow = combine( + flow = portfolioSelectorController.isAccountMode, + flow2 = portfolioSelectorController.selectedAccount, + transform = { isAccountMode, selectedAccountId -> + selectedAccountId ?: return@combine null + val availableToAddWallets = + data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null + val availableToAddAccount = + availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null + if (!isAccountMode) analyticsEventHandler.send(eventBuilder.addToPortfolioWalletChanged()) + SelectedPortfolio( + isAccountMode = isAccountMode, + userWallet = availableToAddWallets.userWallet, + account = availableToAddAccount, + isAvailableMorePortfolio = !data.isSinglePortfolio, + ) + }, + ) + .filterNotNull() + + private fun setupNetworkFlow(selectedPortfolioFlow: Flow): Flow = combine( + flow = selectedPortfolioFlow, + flow2 = callbackDelegate.onNetworkSelected.receiveAsFlow(), + transform = transform@{ selectedPortfolio, selectedNetwork -> + SelectedNetwork( + cryptoCurrency = createCryptoCurrency( + userWallet = selectedPortfolio.userWallet, + network = selectedNetwork, + account = selectedPortfolio.account, + ) ?: return@transform null, + selectedNetwork = selectedNetwork, + isAvailableMoreNetwork = !selectedPortfolio.account.isSingleNetwork, + ) + }, + ) + .filterNotNull() + + private suspend fun createCryptoCurrency( + userWallet: UserWallet, + network: TokenMarketInfo.Network, + account: AvailableToAddAccount, + ): CryptoCurrency? { + val accountIndex = when (val accountStatus = account.account) { + is AccountStatus.CryptoPortfolio -> accountStatus.account.derivationIndex + } + return getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = addToPortfolioManager.token, + network = network, + accountIndex = accountIndex, + ) + } + + private fun routeToNetworkSelector(portfolio: SelectedPortfolio): AddToPortfolioRoutes.NetworkSelector { + return AddToPortfolioRoutes.NetworkSelector(selectedPortfolio = portfolio) + } + + private fun combineFeatureData() = addToPortfolioManager.state.onEach { state -> + when (state) { + is AddToPortfolioManager.State.AvailableToAdd -> + portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus -> + val availableWallet = state.availableToAddData.availableToAddWallets[userWallet.walletId] + ?: return@isEnabled false + val availableAccount = + availableWallet.availableToAddAccounts[accountStatus.account.accountId] + return@isEnabled availableAccount != null + } + AddToPortfolioManager.State.Init, + AddToPortfolioManager.State.NothingToAdd, + -> Unit + } + } +} + +@ModelScoped +internal class AddToPortfolioCallbackDelegate @Inject constructor() : + ChooseNetworkComponent.Callbacks, + TokenActionsComponent.Callbacks, + AddTokenComponent.Callbacks { + + val onNetworkSelected = Channel() + val onLaterClick = Channel() + val onChangeNetworkClick = Channel() + val onChangePortfolioClick = Channel() + val onTokenAdded = Channel() + + override fun onNetworkSelected(network: TokenMarketInfo.Network) { + onNetworkSelected.trySend(network) + } + + override fun onLaterClick() { + onLaterClick.trySend(Unit) + } + + override fun onChangeNetworkClick() { + onChangeNetworkClick.trySend(Unit) + } + + override fun onChangePortfolioClick() { + onChangePortfolioClick.trySend(Unit) + } + + override fun onTokenAdded(status: CryptoCurrencyStatus) { + onTokenAdded.trySend(status) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt new file mode 100644 index 0000000000..a8c2acb3aa --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt @@ -0,0 +1,28 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import androidx.compose.runtime.Immutable +import com.tangem.core.decompose.navigation.Route +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +internal sealed interface AddToPortfolioRoutes : Route { + + @Serializable + data object Empty : AddToPortfolioRoutes + + @Serializable + data object PortfolioSelector : AddToPortfolioRoutes + + @Serializable + data class NetworkSelector( + val selectedPortfolio: SelectedPortfolio, + ) : AddToPortfolioRoutes + + @Serializable + data object AddToken : AddToPortfolioRoutes + + @Serializable + data object TokenActions : AddToPortfolioRoutes +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt new file mode 100644 index 0000000000..b8547e0118 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt @@ -0,0 +1,119 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.tangem.common.ui.addtoken.AddTokenUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase +import com.tangem.domain.models.account.Account +import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenUiBuilder.Companion.toggleProgress +import com.tangem.features.feed.impl.R +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +@Suppress("LongParameterList") +internal class AddTokenModel @Inject constructor( + paramsContainer: ParamsContainer, + private val uiBuilder: AddTokenUiBuilder, + private val messageSender: UiMessageSender, + private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, + override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, +) : Model() { + + private val params = paramsContainer.require() + private val analyticsEventBuilder = params.eventBuilder + private val addTokenJob = JobHolder() + + val uiState: StateFlow + field = MutableStateFlow(value = null) + + init { + combine( + flow = params.selectedNetwork.distinctUntilChanged(), + flow2 = params.selectedPortfolio.distinctUntilChanged(), + transform = { selectedNetwork, selectedPortfolio -> + addTokenJob.cancel() + val isTangemIconVisible = needColdWalletInteraction(selectedNetwork, selectedPortfolio) + uiBuilder.updateContent( + selectedPortfolio = selectedPortfolio, + selectedNetwork = selectedNetwork, + isTangemIconVisible = isTangemIconVisible, + onConfirmClick = { onAddClick(selectedNetwork, selectedPortfolio).saveIn(addTokenJob) }, + ) + }, + ) + .onEach { newUI -> uiState.value = newUI } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun onAddClick(selectedNetwork: SelectedNetwork, selectedPortfolio: SelectedPortfolio) = + modelScope.launch(dispatchers.default) { + val um = uiState.value ?: return@launch + uiState.value = um.toggleProgress(true) + val blockchainNames = listOf(selectedNetwork.selectedNetwork) + .mapNotNull { BlockchainUtils.getNetworkInfo(it.networkId)?.name } + analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames)) + + val cryptoCurrency = selectedNetwork.cryptoCurrency + val account = selectedPortfolio.account.account.account + val accountId = account.accountId + manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency) + .onLeft { throwable -> + processError(error = throwable) + uiState.value = um.toggleProgress(false) + return@launch + } + + val status = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = accountId.userWalletId, + currencyId = cryptoCurrency.id, + network = cryptoCurrency.network, + ).getOrNull() + if (status == null) { + processError(error = null) + } else { + when (account) { + is Account.CryptoPortfolio -> if (!account.isMainAccount) { + analyticsEventHandler.send(analyticsEventBuilder.addToNotMainAccount()) + } + is Account.Payment -> TODO("[REDACTED_JIRA]") + } + params.callbacks.onTokenAdded(status.status) + } + uiState.value = um.toggleProgress(false) + } + + private suspend fun needColdWalletInteraction( + selectedNetwork: SelectedNetwork, + selectedPortfolio: SelectedPortfolio, + ): Boolean = coldWalletAndHasMissedDerivationsUseCase.invoke( + userWalletId = selectedPortfolio.userWallet.walletId, + networksWithDerivationPath = mapOf(selectedNetwork.selectedNetwork.networkId to null), + ) + + private fun processError(error: Throwable?) { + val message = error?.message?.let { stringReference(it) } + ?: resourceReference(R.string.common_something_went_wrong) + messageSender.send(ToastMessage(message = message)) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt new file mode 100644 index 0000000000..b6ad346e0c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt @@ -0,0 +1,105 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.tangem.common.ui.account.CryptoPortfolioIconUM +import com.tangem.common.ui.account.PortfolioSelectUM +import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.addtoken.AddTokenUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.iconResId +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.AccountStatus +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork +import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent +import com.tangem.features.feed.impl.R +import javax.inject.Inject + +@ModelScoped +internal class AddTokenUiBuilder @Inject constructor( + paramsContainer: ParamsContainer, +) { + private val params = paramsContainer.require() + + private fun createNetwork(selectedNetwork: SelectedNetwork): AddTokenUM.Network { + return AddTokenUM.Network( + icon = selectedNetwork.cryptoCurrency.network.iconResId, + name = stringReference(selectedNetwork.cryptoCurrency.network.name), + editable = selectedNetwork.isAvailableMoreNetwork, + onClick = { params.callbacks.onChangeNetworkClick() }, + ) + } + + private fun createPortfolio(selectedPortfolio: SelectedPortfolio): PortfolioSelectUM { + val accountIcon: CryptoPortfolioIconUM? + val portfolioName: TextReference + when (selectedPortfolio.isAccountMode) { + false -> { + accountIcon = null + portfolioName = stringReference(selectedPortfolio.userWallet.name) + } + true -> { + val accountStatus = selectedPortfolio.account.account + portfolioName = accountStatus.account.accountName.toUM().value + accountIcon = when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.account.icon.toUM() + } + } + } + return PortfolioSelectUM( + icon = accountIcon, + name = portfolioName, + isAccountMode = selectedPortfolio.isAccountMode, + isMultiChoice = selectedPortfolio.isAvailableMorePortfolio, + onClick = { params.callbacks.onChangePortfolioClick() }, + ) + } + + fun updateContent( + selectedPortfolio: SelectedPortfolio, + selectedNetwork: SelectedNetwork, + isTangemIconVisible: Boolean, + onConfirmClick: () -> Unit, + ): AddTokenUM { + // its may happens when change portfolio after selected both params in line navigation + val isAvailableNetwork = selectedPortfolio.account.availableToAddNetworks + .any { selectedNetwork.selectedNetwork.networkId == it.networkId } + val button = AddTokenUM.Button( + isEnabled = isAvailableNetwork, + showProgress = false, + isTangemIconVisible = isTangemIconVisible, + text = resourceReference(R.string.common_add), + onConfirmClick = onConfirmClick, + ) + val networkUM = createNetwork(selectedNetwork) + val portfolioUM = createPortfolio(selectedPortfolio) + val currency = selectedNetwork.cryptoCurrency + val tokenToAdd = TokenItemState.Content( + id = currency.id.value, + iconState = CryptoCurrencyToIconStateConverter().convert(currency), + titleState = TokenItemState.TitleState.Content(stringReference(currency.name)), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = ""), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = ""), + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(currency.symbol)), + onItemClick = null, + onItemLongClick = null, + ) + return AddTokenUM( + tokenToAdd = tokenToAdd, + network = networkUM, + portfolio = portfolioUM, + button = button, + ) + } + + companion object { + + fun AddTokenUM.toggleProgress(showProgress: Boolean) = this.copy( + button = this.button.copy(showProgress = showProgress), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt new file mode 100644 index 0000000000..4d51ae620c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt @@ -0,0 +1,126 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import arrow.core.getOrElse +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase +import com.tangem.domain.managetokens.model.CurrencyUnsupportedState +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM +import com.tangem.features.feed.components.market.details.portfolio.impl.model.BlockchainRowUMConverter +import com.tangem.features.feed.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@ModelScoped +@Suppress("LongParameterList") +internal class ChooseNetworkModel @Inject constructor( + paramsContainer: ParamsContainer, + private val messageSender: UiMessageSender, + private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params = paramsContainer.require() + + val uiState: StateFlow = MutableStateFlow(buildUI()) + + private fun buildUI(): ChooseNetworkUM { + val allAvailable = params.selectedPortfolio.account.availableNetworks + val alreadyAdded = allAvailable + .subtract(params.selectedPortfolio.account.availableToAddNetworks) + val converter = BlockchainRowUMConverter( + alreadyAddedNetworks = alreadyAdded.mapTo(mutableSetOf()) { it.networkId }, + ) + val allAvailableNetworks = allAvailable.map { it to true } + return ChooseNetworkUM( + networks = converter.convertList(allAvailableNetworks).toPersistentList(), + onNetworkClick = onNetworkClick@{ row -> + val network = allAvailable + .find { it.networkId == row.id } + ?: return@onNetworkClick + checkNetwork(row, network) + }, + ) + } + + private fun checkNetwork(row: BlockchainRowUM, network: TokenMarketInfo.Network) = modelScope.launch { + val selectedWalletId = params.selectedPortfolio.userWallet.walletId + val unsupportedState = checkCurrencyUnsupportedState( + userWalletId = selectedWalletId, + rawNetworkId = row.id, + isMainNetwork = row.isMainNetwork, + ) + if (unsupportedState != null) { + showUnsupportedWarning(unsupportedState) + } else { + params.callbacks.onNetworkSelected(network) + } + } + + private suspend fun checkCurrencyUnsupportedState( + userWalletId: UserWalletId, + rawNetworkId: String, + isMainNetwork: Boolean, + ): CurrencyUnsupportedState? { + return checkCurrencyUnsupportedUseCase( + userWalletId = userWalletId, + networkId = rawNetworkId, + isMainNetwork = isMainNetwork, + ).getOrElse { throwable -> + Timber.e( + throwable, + """ + Failed to check currency unsupported state + |- User wallet ID: $userWalletId + |- Network ID: $rawNetworkId + |- Is main network: $isMainNetwork + """.trimIndent(), + ) + + val message = SnackbarMessage( + message = throwable.localizedMessage?.let(::stringReference) + ?: resourceReference(R.string.common_error), + ) + messageSender.send(message) + + null + } + } + + private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) { + val message = DialogMessage( + message = when (unsupportedState) { + is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + }, + ) + + messageSender.send(message) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt new file mode 100644 index 0000000000..0bb9a30fd9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt @@ -0,0 +1,76 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory +import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM +import com.tangem.features.feed.components.market.details.portfolio.impl.model.TokenActionsHandler +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +@Suppress("LongParameterList") +internal class TokenActionsModel @Inject constructor( + paramsContainer: ParamsContainer, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + tokenActionsIntentsFactory: TokenActionsHandler.Factory, + override val dispatchers: CoroutineDispatcherProvider, + private val uiBuilder: TokenActionsUiBuilder, + private val analyticsEventHandler: AnalyticsEventHandler, + private val receiveAddressesFactory: ReceiveAddressesFactory, +) : Model() { + + private val params = paramsContainer.require() + private val analyticsEventBuilder get() = params.eventBuilder + private val currentAppCurrency = getSelectedAppCurrencyUseCase.invokeOrDefault() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + + private val tokenActionsHandler: TokenActionsHandler = + tokenActionsIntentsFactory.create( + currentAppCurrency = Provider { currentAppCurrency.value }, + updateTokenReceiveBSConfig = { }, + onHandleQuickAction = { handledAction -> handledQuickAction(handledAction) }, + ) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val uiState: StateFlow = params.data + .mapLatest { uiBuilder.build(it, tokenActionsHandler) } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) + + private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) { + val event = analyticsEventBuilder.getTokenActionClick(actionUM = handledAction.action) + analyticsEventHandler.send(event) + val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive + if (!isReceive) return + modelScope.launch { + val tokenConfig = receiveAddressesFactory.create( + status = handledAction.cryptoCurrencyData.status, + userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId, + ) ?: return@launch + bottomSheetNavigation.activate(tokenConfig) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt new file mode 100644 index 0000000000..8828010452 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt @@ -0,0 +1,44 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.model.PortfolioTokenUMConverter +import com.tangem.features.feed.components.market.details.portfolio.impl.model.TokenActionsHandler +import javax.inject.Inject + +@ModelScoped +internal class TokenActionsUiBuilder @Inject constructor( + paramsContainer: ParamsContainer, + private val analyticsEventHandler: AnalyticsEventHandler, +) { + private val params = paramsContainer.require() + + fun build(data: PortfolioData.CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): TokenActionsUM { + val status = data.status + val tokenUM = TokenItemState.Content( + id = status.currency.id.value, + iconState = CryptoCurrencyToIconStateConverter().convert(status.currency), + titleState = TokenItemState.TitleState.Content(stringReference(status.currency.name)), + fiatAmountState = null, + subtitle2State = null, + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(status.currency.symbol)), + onItemClick = null, + onItemLongClick = null, + ) + return TokenActionsUM( + token = tokenUM, + onLaterClick = { + analyticsEventHandler.send(params.eventBuilder.getTokenLater()) + params.callbacks.onLaterClick() + }, + quickActions = PortfolioTokenUMConverter.quickActions(data, tokenActionsHandler), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt new file mode 100644 index 0000000000..7b92e78e3b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt @@ -0,0 +1,110 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.core.ui.components.label.Label +import com.tangem.core.ui.components.label.entity.LabelStyle +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.components.rows.BlockchainRow +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +private const val DISABLED_ALPHA = 0.4f + +@Composable +internal fun ChooseNetworkContent(state: ChooseNetworkUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(TangemTheme.colors.background.action), + ) { + state.networks.fastForEachIndexed { index, model -> + key(model.id) { + BlockchainRow( + model = model, + itemPadding = PaddingValues( + horizontal = TangemTheme.dimens.spacing12, + vertical = TangemTheme.dimens.spacing14, + ), + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = model.isEnabled, onClick = { state.onNetworkClick(model) }), + ) { + if (!model.isEnabled) { + Label( + modifier = Modifier.alpha(DISABLED_ALPHA), + state = LabelUM( + text = resourceReference(R.string.common_added), + style = LabelStyle.REGULAR, + ), + ) + } + } + } + } + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview(@PreviewParameter(ChooseNetworkContentProvider::class) content: ChooseNetworkUM) { + TangemThemePreview { + ChooseNetworkContent( + state = content, + ) + } +} + +internal class ChooseNetworkContentProvider : PreviewParameterProvider { + + private val blockchainRow = BlockchainRowUM( + id = UUID.randomUUID().toString(), + name = "Etherium 3", + type = "TEST", + iconResId = R.drawable.img_eth_22, + isMainNetwork = false, + isSelected = true, + isEnabled = true, + ) + + override val values: Sequence + get() = sequenceOf( + ChooseNetworkUM( + onNetworkClick = {}, + networks = persistentListOf( + blockchainRow.copy( + type = "MAIN", + isMainNetwork = true, + ), + blockchainRow.copy( + iconResId = R.drawable.ic_bsc_16, + isEnabled = false, + ), + blockchainRow.copy(iconResId = R.drawable.img_polygon_22), + blockchainRow.copy(iconResId = R.drawable.img_optimism_22), + ), + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt new file mode 100644 index 0000000000..8c27a3929d --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt @@ -0,0 +1,71 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui + +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.feed.components.market.details.portfolio.add.impl.converter.AvailableToAddDataConverter +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +internal class DefaultAddToPortfolioManager @AssistedInject constructor( + private val availableToAddDataConverter: AvailableToAddDataConverter, + @Assisted override val token: TokenMarketParams, + @Assisted override val analyticsParams: AddToPortfolioManager.AnalyticsParams?, + @Assisted val scope: CoroutineScope, + dispatchers: CoroutineDispatcherProvider, + portfolioFetcherFactory: PortfolioFetcher.Factory, +) : AddToPortfolioManager { + + private val _allAvailableNetworks = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val allAvailableNetworks: Flow> = _allAvailableNetworks.asSharedFlow() + override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create( + mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true), + scope = scope, + ) + + override val state: StateFlow = + combine( + flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(), + flow2 = allAvailableNetworks.map { it.toSet() }.distinctUntilChanged(), + ) { balances, availableNetworks -> + val data = availableToAddDataConverter.convert( + balances = balances, + availableNetworks = availableNetworks, + marketParams = token, + ) + if (data.isAvailableToAdd) { + AddToPortfolioManager.State.AvailableToAdd(data) + } else { + AddToPortfolioManager.State.NothingToAdd + } + } + .flowOn(dispatchers.default) + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = AddToPortfolioManager.State.Init, + ) + + override fun setTokenNetworks(networks: List) { + _allAvailableNetworks.tryEmit(networks) + } + + @AssistedFactory + interface Factory : AddToPortfolioManager.Factory { + override fun create( + scope: CoroutineScope, + token: TokenMarketParams, + analyticsParams: AddToPortfolioManager.AnalyticsParams?, + ): DefaultAddToPortfolioManager + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt new file mode 100644 index 0000000000..d88388bb30 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt @@ -0,0 +1,204 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.icons.badge.drawBadge +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +@Composable +internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + TokenItem( + modifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.action), + state = state.token, + isBalanceHidden = false, + ) + + SpacerH(TangemTheme.dimens.spacing14) + Column( + modifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(TangemTheme.colors.background.action), + ) { + state.quickActions.actions.fastForEach { actionUM -> + key(actionUM.title) { + ActionRow( + state = actionUM, + onClick = { state.quickActions.onQuickActionClick(actionUM) }, + onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) }, + ) + } + } + } + + SpacerH16() + + SecondaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_later), + onClick = state.onLaterClick, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ActionRow( + state: QuickActionUM, + onClick: () -> Unit, + onLongClick: (() -> Unit), + modifier: Modifier = Modifier, +) { + val hapticManager = LocalHapticManager.current + val onLongClickInternal = { + hapticManager.perform(TangemHapticEffect.View.LongPress) + onLongClick() + } + + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onLongClick = onLongClickInternal.takeIf { state.isLongClickAvailable }, + onClick = { + hapticManager.perform(TangemHapticEffect.View.SegmentTick) + onClick() + }, + ) + .padding(horizontal = TangemTheme.dimens.spacing12, vertical = TangemTheme.dimens.spacing15), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + val containerColor = TangemTheme.colors.background.action + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = CircleShape, + ) + .size(36.dp) + .drawWithContent { + drawContent() + if (state is QuickActionUM.Exchange && state.shouldShowBadge) { + drawBadge(containerColor = containerColor, offset = 4.dp) + } + }, + ) { + Icon( + modifier = Modifier.requiredSize(TangemTheme.dimens.size16), + imageVector = ImageVector.vectorResource(id = state.icon), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2), + ) { + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = state.description.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(TokenActionsContentPreviewProvider::class) state: TokenActionsUM) { + TangemThemePreview { + TokenActionsContent( + state = state, + ) + } +} + +private class TokenActionsContentPreviewProvider : PreviewParameterProvider { + private val tokenState + get() = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = R.drawable.img_eth_22, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + titleState = TokenItemState.TitleState.Content( + text = stringReference(value = "Tether"), + ), + fiatAmountState = null, + subtitle2State = null, + subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")), + onItemClick = {}, + onItemLongClick = {}, + ) + + override val values: Sequence + get() = sequenceOf( + TokenActionsUM( + quickActions = PortfolioTokenUM.QuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(shouldShowBadge = true), + QuickActionUM.Receive, + ), + onQuickActionClick = {}, + onQuickActionLongClick = {}, + ), + token = tokenState, + onLaterClick = {}, + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt new file mode 100644 index 0000000000..8f54e3cb3b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt @@ -0,0 +1,9 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import kotlinx.collections.immutable.ImmutableList + +data class ChooseNetworkUM( + val networks: ImmutableList, + val onNetworkClick: (BlockchainRowUM) -> Unit, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt new file mode 100644 index 0000000000..d90e3ca130 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state + +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM + +internal data class TokenActionsUM( + val token: TokenItemState, + val quickActions: PortfolioTokenUM.QuickActions, + val onLaterClick: () -> Unit, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/api/MarketsPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/api/MarketsPortfolioComponent.kt new file mode 100644 index 0000000000..18aa2ea032 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/api/MarketsPortfolioComponent.kt @@ -0,0 +1,29 @@ +package com.tangem.features.feed.components.market.details.portfolio.api + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import kotlinx.serialization.Serializable + +@Stable +interface MarketsPortfolioComponent : ComposableContentComponent { + + @Serializable + data class Params( + val token: TokenMarketParams, + val analyticsParams: AnalyticsParams?, + ) + + @Serializable + data class AnalyticsParams( + val source: String, + ) + + fun setTokenNetworks(networks: List) + + fun setNoNetworksAvailable() + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt new file mode 100644 index 0000000000..6df78dda91 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt @@ -0,0 +1,89 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioModel +import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioRoute +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.MyPortfolio +import com.tangem.features.tokenreceive.TokenReceiveComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Stable +internal class DefaultMarketsPortfolioComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: MarketsPortfolioComponent.Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, +) : AppComponentContext by context, MarketsPortfolioComponent { + + private val model: MarketsPortfolioModel = getOrCreateModel(params) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = MarketsPortfolioRoute.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + override fun setTokenNetworks(networks: List) { + model.setTokenNetworks(networks) + } + + override fun setNoNetworksAvailable() { + model.setNoNetworksAvailable() + } + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + + MyPortfolio(modifier = modifier, state = state) + bottomSheet.child?.instance?.BottomSheet() + } + + @Suppress("UnsafeCallOnNullableType") + private fun bottomSheetChild( + config: MarketsPortfolioRoute, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = when (config) { + MarketsPortfolioRoute.AddToPortfolio -> addToPortfolioComponentFactory.create( + context = childByContext(componentContext), + params = AddToPortfolioComponent.Params( + addToPortfolioManager = model.newAddToPortfolioManager!!, + callback = model.addToPortfolioCallback, + ), + ) + is MarketsPortfolioRoute.TokenReceive -> tokenReceiveComponentFactory.create( + context = childByContext(componentContext), + params = TokenReceiveComponent.Params( + config = config.config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + } + + @AssistedFactory + interface Factory : MarketsPortfolioComponent.Factory { + override fun create( + context: AppComponentContext, + params: MarketsPortfolioComponent.Params, + ): DefaultMarketsPortfolioComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt new file mode 100644 index 0000000000..ee93e97a0c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt @@ -0,0 +1,74 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM + +internal class PortfolioAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) { + + data class EventBuilder( + val token: TokenMarketParams, + val source: String?, + ) { + + fun addToPortfolioClicked() = PortfolioAnalyticsEvent( + event = "Button - Add To Portfolio", + params = mapOf( + "Token" to token.symbol, + ), + ) + + fun popupToChooseAccount() = PortfolioAnalyticsEvent( + event = "Popup to choose account", + ) + + fun addToNotMainAccount() = PortfolioAnalyticsEvent( + event = "Button - Add (token not to main Account)", + ) + + fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent(event = "Wallet Selected") + + fun addToPortfolioContinue(blockchainNames: List) = PortfolioAnalyticsEvent( + event = "Token Network Selected", + params = mapOf( + "Count" to blockchainNames.size.toString(), + "Token" to token.symbol, + "blockchain" to blockchainNames.joinToString(separator = ", "), + ), + ) + + fun quickActionClick(actionUM: TokenActionsBSContentUM.Action, blockchainName: String) = + PortfolioAnalyticsEvent( + event = when (actionUM) { + TokenActionsBSContentUM.Action.Buy -> "Button - Buy" + TokenActionsBSContentUM.Action.Receive -> "Button - Receive" + TokenActionsBSContentUM.Action.Exchange -> "Button - Swap" + TokenActionsBSContentUM.Action.Stake -> "Button - Stake" + TokenActionsBSContentUM.Action.YieldMode -> "Button - Yield Mode" + else -> "error" + }, + params = buildMap { + put("Token", token.symbol) + source?.let { put("Source", it) } + put("blockchain", blockchainName) + }, + ) + + fun getTokenActionClick(actionUM: TokenActionsBSContentUM.Action) = PortfolioAnalyticsEvent( + event = when (actionUM) { + TokenActionsBSContentUM.Action.Buy -> "Popup Get token - Button Buy" + TokenActionsBSContentUM.Action.Receive -> "Popup Get token - Button Receive" + TokenActionsBSContentUM.Action.Exchange -> "Popup Get token - Button Exchange" + TokenActionsBSContentUM.Action.Stake -> "Popup Get token - Button Stake" + else -> "error" + }, + ) + + fun getTokenLater() = PortfolioAnalyticsEvent( + event = "Popup Get token - Button Later", + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ComponentModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ComponentModule.kt new file mode 100644 index 0000000000..beb881a924 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ComponentModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.di + +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.impl.DefaultMarketsPortfolioComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface ComponentModule { + + @Binds + @Singleton + fun bindMarketsPortfolioComponent( + factory: DefaultMarketsPortfolioComponent.Factory, + ): MarketsPortfolioComponent.Factory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ModelModule.kt new file mode 100644 index 0000000000..4656963d6b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface ModelModule { + + @Binds + @IntoMap + @ClassKey(MarketsPortfolioModel::class) + fun provideMarketsPortfolioModel(model: MarketsPortfolioModel): Model +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt new file mode 100644 index 0000000000..044a0ef229 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt @@ -0,0 +1,33 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.loader + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.tokens.model.TokenActionsState + +/** + * Portfolio data. Combined data from all flows that required to setup portfolio + * + * @property walletsWithCurrencies wallets with crypto currency statuses + * @property appCurrency app currency + * @property isBalanceHidden flag that indicates if balance should be hidden + * @property walletsWithBalance wallets with total balance + * +[REDACTED_AUTHOR] + */ +internal data class PortfolioData( + val walletsWithCurrencies: Map>, + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val walletsWithBalance: Map>, +) { + data class CryptoCurrencyData( + val userWallet: UserWallet, + val status: CryptoCurrencyStatus, + val actions: List, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioDataLoader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioDataLoader.kt new file mode 100644 index 0000000000..c22e143ea1 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioDataLoader.kt @@ -0,0 +1,135 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.loader + +import arrow.core.getOrElse +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.GetAllWalletsCryptoCurrencyStatusesUseCase +import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase +import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.yield.supply.models.YieldSupplyAvailability +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +/** + * Loader of portfolio data + * + * @property getAllWalletsCryptoCurrencyStatusesUseCase use case for getting all wallets crypto currency statuses + * @property getSelectedAppCurrencyUseCase use case for getting selected app currency + * @property getBalanceHidingSettingsUseCase use case for getting balance hiding settings + * @property getWalletTotalBalanceUseCase use case for getting wallet total balance + * +[REDACTED_AUTHOR] + */ +internal class PortfolioDataLoader @Inject constructor( + private val getAllWalletsCryptoCurrencyStatusesUseCase: GetAllWalletsCryptoCurrencyStatusesUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val getWalletTotalBalanceUseCase: GetWalletTotalBalanceUseCase, + private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, +) { + + /** Load data by [currencyRawId] */ + @OptIn(ExperimentalCoroutinesApi::class) + fun load(currencyRawId: CryptoCurrency.RawID): Flow { + return combine( + flow = getAllWalletsCryptoCurrenciesData(currencyRawId = currencyRawId), + flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(), + flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(), + ) { walletsWithCurrencies, appCurrency, isBalanceHidden -> + PortfolioData( + walletsWithCurrencies = walletsWithCurrencies, + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + walletsWithBalance = emptyMap(), + ) + } + // setup balances for wallets from walletsWithCurrencyStatuses + .flatMapLatest { portfolioData -> + getWalletsWithTotalBalanceFlow( + ids = portfolioData.walletsWithCurrencies.keys.map(UserWallet::walletId), + ) + .map { portfolioData.copy(walletsWithBalance = it) } + .onEmpty { emit(portfolioData) } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun getAllWalletsCryptoCurrenciesData( + currencyRawId: CryptoCurrency.RawID, + ): Flow>> { + return getAllWalletsCryptoCurrencyStatusesUseCase(currencyRawId) + .distinctUntilChanged() + .map { walletsWithMaybeStatuses -> + walletsWithMaybeStatuses.mapValues { entry -> + entry.value.mapNotNull { it.getOrNull() } + } + } + .flatMapLatest { walletsWithStatuses -> + val actionsFlows = walletsWithStatuses.flatMap { (wallet, statuses) -> + statuses.map { status -> + val yieldSupplyAvailability = yieldSupplyGetAvailabilityUseCase(status.currency).getOrElse { + YieldSupplyAvailability.Unavailable + } + getCryptoCurrencyActionsUseCase(wallet, status, yieldSupplyAvailability) + .map { tokenActionsState -> + PortfolioData.CryptoCurrencyData( + userWallet = wallet, + status = status, + actions = tokenActionsState.states, + ) + } + } + } + + combine(actionsFlows) { actions -> + walletsWithStatuses.mapValues { entry -> + entry.value.mapNotNull { status -> + actions.firstOrNull { + it.userWallet == entry.key && it.status == status + } + } + } + }.onEmpty { + emit( + walletsWithStatuses.mapValues { (wallet, statuses) -> + statuses.map { status -> + PortfolioData.CryptoCurrencyData( + userWallet = wallet, + status = status, + actions = emptyList(), + ) + } + }, + ) + } + }.onEmpty { + emit(emptyMap()) + } + .distinctUntilChanged() + } + + private fun getWalletsWithTotalBalanceFlow( + ids: List, + ): Flow>> { + return combine( + flows = ids + .map { userWalletId -> + getWalletTotalBalanceUseCase(userWalletId) + .map { userWalletId to it } + .distinctUntilChanged() + }, + transform = { it.toMap() }, + ) + .distinctUntilChanged() + .onEmpty { ids.associateWith { Lce.Loading(partialContent = null) } } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt new file mode 100644 index 0000000000..e139659522 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt @@ -0,0 +1,161 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.ui.userwallet.converter.UserWalletItemUMConverter +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.WalletSelectorBSContentUM +import kotlinx.collections.immutable.toImmutableList + +/** + * Factory to create AddToPortfolio bottom sheet content [TangemBottomSheetConfig] + * + * @property token token params + * @property onAddToPortfolioVisibilityChange callback is invoked when add to portfolio visibility is changed + * @property onWalletSelectorVisibilityChange callback is invoked when wallet selector visibility is changed + * @property onNetworkSwitchClick callback is invoked when network switch is clicked + * @property onAnotherWalletSelect callback is invoked when wallet is selected + * @property onContinueClick callback is invoked when continue button is clicked + * +[REDACTED_AUTHOR] + */ +@Suppress("LongParameterList") +internal class AddToPortfolioBSContentUMFactory( + private val addToPortfolioManager: AddToPortfolioManager, + private val token: TokenMarketParams, + private val onAddToPortfolioVisibilityChange: (Boolean) -> Unit, + private val onWalletSelectorVisibilityChange: (Boolean) -> Unit, + private val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit, + private val onAnotherWalletSelect: (UserWalletId) -> Unit, + private val onContinueClick: (selectedWalletId: UserWalletId, addedNetworks: Set) -> Unit, +) { + + /** + * Create [TangemBottomSheetConfig] + * + + * @param portfolioData portfolio data + * @param portfolioUIData portfolio bottom sheet visibility model + * @param selectedWallet selected wallet + * @param alreadyAddedNetworks already added networks + */ + @Suppress("LongParameterList") + fun create( + currentState: TangemBottomSheetConfig?, + portfolioData: PortfolioData, + portfolioUIData: PortfolioUIData, + selectedWallet: UserWallet?, + alreadyAddedNetworks: Set?, + artworks: Map, + ): TangemBottomSheetConfig { + return (currentState ?: TangemBottomSheetConfig.Empty).copy( + isShown = portfolioUIData.portfolioBSVisibilityModel.isAddToPortfolioBSVisible, + onDismissRequest = { onAddToPortfolioVisibilityChange(false) }, + content = if (selectedWallet != null && alreadyAddedNetworks != null) { + AddToPortfolioBSContentUM( + selectedWallet = selectedWallet.toSelectedUserWalletItemUM( + portfolioData = portfolioData, + balance = portfolioData.walletsWithBalance[selectedWallet.walletId]?.getOrNull(), + artwork = artworks[selectedWallet.walletId], + ), + selectNetworkUM = SelectNetworkUMConverter( + networksWithToggle = addToPortfolioManager.associateWithToggle( + userWalletId = selectedWallet.walletId, + alreadyAddedNetworkIds = alreadyAddedNetworks, + addToPortfolioData = portfolioUIData.addToPortfolioData, + ), + alreadyAddedNetworks = alreadyAddedNetworks, + onNetworkSwitchClick = onNetworkSwitchClick, + ).convert(value = token), + isScanCardNotificationVisible = portfolioUIData.isNeededColdWalletInteraction, + isContinueButtonEnabled = portfolioUIData.addToPortfolioData.isUserAddedNetworks( + userWalletId = selectedWallet.walletId, + ), + onContinueButtonClick = { + onContinueClick( + selectedWallet.walletId, + portfolioUIData.addToPortfolioData.getAddedNetworks( + userWalletId = selectedWallet.walletId, + alreadyAddedNetworkIds = alreadyAddedNetworks, + ), + ) + }, + walletSelectorConfig = createWalletSelectorBSConfig( + isShow = portfolioUIData.portfolioBSVisibilityModel.isWalletSelectorBSVisible, + portfolioData = portfolioData, + selectedWalletId = selectedWallet.walletId, + artworks = artworks, + ), + isWalletBlockVisible = portfolioData.walletsWithCurrencies + .filterKeys(UserWallet::isMultiCurrency).size > 1, + ) + } else { + TangemBottomSheetConfigContent.Empty + }, + ) + } + + private fun UserWallet.toSelectedUserWalletItemUM( + artwork: UserWalletItemUM.ImageState? = null, + portfolioData: PortfolioData, + balance: TotalFiatBalance?, + ): UserWalletItemUM { + return UserWalletItemUMConverter( + onClick = { onWalletSelectorVisibilityChange(true) }, + endIcon = UserWalletItemUM.EndIcon.Arrow, + balance = balance, + artwork = artwork, + appCurrency = portfolioData.appCurrency, + isBalanceHidden = portfolioData.isBalanceHidden, + ).convert(value = this) + } + + private fun createWalletSelectorBSConfig( + isShow: Boolean, + portfolioData: PortfolioData, + selectedWalletId: UserWalletId, + artworks: Map, + ): TangemBottomSheetConfig { + return TangemBottomSheetConfig( + isShown = isShow, + onDismissRequest = { onWalletSelectorVisibilityChange(false) }, + content = WalletSelectorBSContentUM( + userWallets = portfolioData.walletsWithCurrencies + .filterKeys(UserWallet::isMultiCurrency) + .map { it.key } + .map { userWallet -> + val balance = portfolioData.walletsWithBalance[userWallet.walletId] + + UserWalletItemUMConverter( + onClick = { id -> + if (id != selectedWalletId) { + onAnotherWalletSelect(id) + onWalletSelectorVisibilityChange(false) + } + }, + appCurrency = portfolioData.appCurrency, + balance = balance?.getOrNull(), + isBalanceHidden = portfolioData.isBalanceHidden, + endIcon = if (userWallet.walletId == selectedWalletId) { + UserWalletItemUM.EndIcon.Checkmark + } else { + UserWalletItemUM.EndIcon.None + }, + artwork = artworks[userWallet.walletId], + ).convert(userWallet) + } + .toImmutableList(), + onBack = { onWalletSelectorVisibilityChange(false) }, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioManager.kt new file mode 100644 index 0000000000..d176536657 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioManager.kt @@ -0,0 +1,193 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import timber.log.Timber +import javax.inject.Inject +import kotlin.collections.firstOrNull +import kotlin.collections.orEmpty +import kotlin.collections.set + +internal typealias WalletsWithNetworks = Map> + +/** + * Manager for tracking changing networks in AddToPortfolio + * +[REDACTED_AUTHOR] + */ +internal class AddToPortfolioManager @Inject constructor( + private val filterAvailableNetworksForWalletUseCase: FilterAvailableNetworksForWalletUseCase, +) { + + val availableNetworks = MutableStateFlow?>(value = null) + private val addedNetworks = MutableStateFlow(value = emptyMap()) + private val removedNetworks = MutableStateFlow(value = emptyMap()) + + /** Get [AddToPortfolioData] as flow */ + fun getAddToPortfolioData(): Flow { + return combine( + flow = availableNetworks, + flow2 = addedNetworks, + flow3 = removedNetworks, + transform = ::AddToPortfolioData, + ) + } + + /** Set available networks [networks] */ + fun setAvailableNetworks(networks: List) { + availableNetworks.value = networks.toSet() + } + + /** Add network [networkId] to [userWalletId] */ + fun addNetwork(userWalletId: UserWalletId, networkId: String) { + addedNetworks.add(userWalletId, networkId) + + removedNetworks.cancelPrevChangeIfExist(userWalletId = userWalletId, networkId = networkId) + } + + /** Remove network [networkId] from [userWalletId] */ + fun removeNetwork(userWalletId: UserWalletId, networkId: String) { + removedNetworks.add(userWalletId, networkId) + + addedNetworks.cancelPrevChangeIfExist( + userWalletId = userWalletId, + networkId = networkId, + ) + } + + /** Remove all networks by [userWalletId] */ + fun removeAllChanges(userWalletId: UserWalletId) { + addedNetworks.update { + it.toMutableMap().apply { remove(userWalletId) } + } + + removedNetworks.update { + it.toMutableMap().apply { remove(userWalletId) } + } + } + + fun associateWithToggle( + userWalletId: UserWalletId, + alreadyAddedNetworkIds: Set, + addToPortfolioData: AddToPortfolioData, + ): Map { + val filteredNetworks = filterAvailableNetworksForWalletUseCase( + userWalletId = userWalletId, + networks = addToPortfolioData.availableNetworks.orEmpty(), + ) + // Use user choice or check already added networks + return filteredNetworks.associateWith { availableNetwork -> + val isAddedByUser = addToPortfolioData.addedNetworks[userWalletId]?.contains(availableNetwork) + + if (isAddedByUser == true) return@associateWith true + + val isRemovedByUser = addToPortfolioData.removedNetworks[userWalletId]?.contains(availableNetwork) + + if (isRemovedByUser == true) return@associateWith false + + val isAddedBefore = alreadyAddedNetworkIds.any { it == availableNetwork.networkId } + + isAddedBefore + } + } + + private fun MutableStateFlow.cancelPrevChangeIfExist( + userWalletId: UserWalletId, + networkId: String, + ) { + if (value[userWalletId].orEmpty().any { it.networkId == networkId }) remove(userWalletId, networkId) + } + + private fun MutableStateFlow.add(userWalletId: UserWalletId, networkId: String) { + change(userWalletId = userWalletId, networkId = networkId, isAddAction = true) + } + + private fun MutableStateFlow.remove(userWalletId: UserWalletId, networkId: String) { + change(userWalletId = userWalletId, networkId = networkId, isAddAction = false) + } + + private fun MutableStateFlow.change( + userWalletId: UserWalletId, + networkId: String, + isAddAction: Boolean, + ) { + val network = availableNetworks.value.orEmpty().firstOrNull { it.networkId == networkId } + + if (network == null) { + Timber.d( + "Network [$networkId] doesn't contain in available networks [%s]", + availableNetworks.value?.joinToString { it.networkId }, + ) + + return + } + + update { walletsWithNetworks -> + walletsWithNetworks.toMutableMap().apply { + this[userWalletId] = if (isAddAction) { + this[userWalletId].orEmpty() + network + } else { + this[userWalletId].orEmpty() - network + } + } + } + } + + /** + * Add to portfolio data + * + * @property availableNetworks available networks that user can add to portfolio + * @property addedNetworks networks that user toggled on, but it might have already been added to the wallet + * @property removedNetworks networks that user toggled off, but it might haven't been added to the wallet + * + * Example for [addedNetworks] and [removedNetworks]. This lists will include new networks when user just + * toggle it. But when we will save user changes, we will check what tokens have already been added or + * haven't been added to the wallet. See [getAddedNetworks] and [getRemovedNetworks] + */ + data class AddToPortfolioData( + val availableNetworks: Set?, + val addedNetworks: WalletsWithNetworks, + val removedNetworks: WalletsWithNetworks, + ) { + + fun isUserAddedNetworks(userWalletId: UserWalletId): Boolean { + return addedNetworks[userWalletId].orEmpty().isNotEmpty() + } + + fun isUserChangedNetworks(userWalletId: UserWalletId): Boolean { + return addedNetworks[userWalletId].orEmpty().isNotEmpty() || + removedNetworks[userWalletId].orEmpty().isNotEmpty() + } + + /** Get new networks that user [userWalletId] added using [alreadyAddedNetworkIds] */ + fun getAddedNetworks( + userWalletId: UserWalletId, + alreadyAddedNetworkIds: Set, + ): Set { + val addedNetworksByUser = addedNetworks[userWalletId].orEmpty() + + return addedNetworksByUser.map { it.networkId } + .minus(alreadyAddedNetworkIds) + .mapNotNull { networkId -> addedNetworksByUser.firstOrNull { it.networkId == networkId } } + .toSet() + } + + /** Get networks that user [userWalletId] removed using [alreadyAddedNetworkIds] */ + fun getRemovedNetworks( + userWalletId: UserWalletId, + alreadyAddedNetworkIds: Set, + ): Set { + val removedNetworksByUser = removedNetworks[userWalletId].orEmpty() + + return alreadyAddedNetworkIds + .minus(removedNetworksByUser.map { it.networkId }.toSet()) + .mapNotNull { networkId -> removedNetworksByUser.firstOrNull { it.networkId == networkId } } + .toSet() + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt new file mode 100644 index 0000000000..e4be2f3924 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt @@ -0,0 +1,64 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.extensions.getGreyedOutIconRes +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketInfo.Network +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.converter.Converter + +/** + * Converter from [TokenMarketInfo.Network] to [BlockchainRowUM] + * + * @property alreadyAddedNetworks set of already added networks + * +[REDACTED_AUTHOR] + */ +internal class BlockchainRowUMConverter( + private val alreadyAddedNetworks: Set, +) : Converter, BlockchainRowUM> { + + override fun convert(value: Pair): BlockchainRowUM { + val (network, isSelected) = value + + val blockchainInfo = BlockchainUtils.getNetworkInfo(networkId = network.networkId) + ?: error("Can't find blockchain info for ${network.networkId}") + + val isMainNetwork = network.contractAddress == null + + val isEnabled = !alreadyAddedNetworks.contains(network.networkId) + + return BlockchainRowUM( + id = network.networkId, + name = blockchainInfo.name, + type = getNetworkType(network, blockchainInfo), + iconResId = if (isEnabled) { + if (isSelected) { + getActiveIconRes(blockchainInfo.blockchainId) + } else { + getGreyedOutIconRes(blockchainInfo.blockchainId) + } + } else { + getGreyedOutIconRes(blockchainInfo.blockchainId) + }, + isMainNetwork = isMainNetwork, + isSelected = isSelected, + isEnabled = isEnabled, + ) + } + + private fun getNetworkType(network: Network, blockchainInfo: BlockchainUtils.BlockchainInfo): String { + val isMainNetwork = network.contractAddress == null + return when { + BlockchainUtils.isL2Network(networkId = network.networkId) -> MAIN_NETWORK_L2_TYPE_NAME + isMainNetwork -> MAIN_NETWORK_TYPE_NAME + else -> blockchainInfo.protocolName + } + } + + private companion object { + const val MAIN_NETWORK_TYPE_NAME = "MAIN" + const val MAIN_NETWORK_L2_TYPE_NAME = "MAIN L2" + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt new file mode 100644 index 0000000000..1715e60e47 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt @@ -0,0 +1,441 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase +import com.tangem.domain.managetokens.model.CurrencyUnsupportedState +import com.tangem.domain.markets.SaveMarketTokensUseCase +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory +import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioDataLoader +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.features.feed.impl.R +import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle +import com.tangem.features.wallet.utils.UserWalletImageFetcher +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.operations.attestation.ArtworkSize +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager as NewAddToPortfolioManager + +@Suppress("LongParameterList", "LargeClass") +@Stable +@ModelScoped +internal class MarketsPortfolioModel @Inject constructor( + paramsContainer: ParamsContainer, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + tokenActionsIntentsFactory: TokenActionsHandler.Factory, + override val dispatchers: CoroutineDispatcherProvider, + private val messageSender: UiMessageSender, + private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, + private val getSelectedWalletUseCase: GetSelectedWalletUseCase, + private val portfolioDataLoader: PortfolioDataLoader, + private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, + private val saveMarketTokensUseCase: SaveMarketTokensUseCase, + private val addToPortfolioManager: AddToPortfolioManager, + private val analyticsEventHandler: AnalyticsEventHandler, + private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, + private val userWalletImageFetcher: UserWalletImageFetcher, + private val receiveAddressesFactory: ReceiveAddressesFactory, + accountsFeatureToggles: AccountsFeatureToggles, + newAddToPortfolioManagerFactory: NewAddToPortfolioManager.Factory, + newMarketsPortfolioDelegateFactory: NewMarketsPortfolioDelegate.Factory, +) : Model() { + + private val _state: MutableStateFlow = MutableStateFlow(value = MyPortfolioUM.Loading) + val state: StateFlow get() = _state + + private val params = paramsContainer.require() + private val analyticsEventBuilder = PortfolioAnalyticsEvent.EventBuilder( + token = params.token, + source = params.analyticsParams?.source, + ) + + val newAddToPortfolioManager: NewAddToPortfolioManager? + val newMarketsPortfolioDelegate: NewMarketsPortfolioDelegate? + + /** Multi-wallet [UserWalletId] that user uses to add new tokens in AddToPortfolio bottom sheet */ + private val selectedMultiWalletIdFlow = MutableStateFlow(value = null) + + private val portfolioBSVisibilityModelFlow = MutableStateFlow(value = PortfolioBSVisibilityModel()) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val addToPortfolioCallback = object : AddToPortfolioComponent.Callback { + override fun onDismiss() = bottomSheetNavigation.dismiss() + override fun onSuccess(addedToken: CryptoCurrency) = bottomSheetNavigation.dismiss() + } + + private val currentAppCurrency = getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + + private val tokenActionsHandler = tokenActionsIntentsFactory.create( + currentAppCurrency = Provider { currentAppCurrency.value }, + updateTokenReceiveBSConfig = { updateBlock -> + if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled.not()) { + updateTokensState { + it.copy(tokenReceiveBSConfig = updateBlock(it.tokenReceiveBSConfig)) + } + } + }, + onHandleQuickAction = { handledAction -> + analyticsEventHandler.send( + analyticsEventBuilder.quickActionClick( + actionUM = handledAction.action, + blockchainName = handledAction + .cryptoCurrencyData + .status + .currency + .network + .name, + ), + ) + if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) { + configureReceiveAddresses(handledAction) + } + }, + ) + + private val factory = MyPortfolioUMFactory( + onAddClick = { + onAddToPortfolioBSVisibilityChange(isShow = true) + // === Analytics === + analyticsEventHandler.send( + analyticsEventBuilder.addToPortfolioClicked(), + ) + }, + addToPortfolioBSContentUMFactory = AddToPortfolioBSContentUMFactory( + addToPortfolioManager = addToPortfolioManager, + token = params.token, + onAddToPortfolioVisibilityChange = ::onAddToPortfolioBSVisibilityChange, + onWalletSelectorVisibilityChange = ::onWalletSelectorVisibilityChange, + onNetworkSwitchClick = ::onNetworkSwitchClick, + onAnotherWalletSelect = { walletId -> + onWalletSelect(walletId) + // === Analytics === + analyticsEventHandler.send( + analyticsEventBuilder.addToPortfolioWalletChanged(), + ) + }, + onContinueClick = { selectedWalletId, addedNetworks -> + onContinueClick(selectedWalletId, addedNetworks) + + // === Analytics === + analyticsEventHandler.send( + analyticsEventBuilder.addToPortfolioContinue( + blockchainNames = addedNetworks.mapNotNull { + BlockchainUtils.getNetworkInfo(it.networkId)?.name + }, + ), + ) + }, + ), + currentState = Provider { _state.value }, + tokenActionsHandler = tokenActionsHandler, + updateTokens = { updateBlock -> + updateTokensState { state -> + state.copy(tokens = updateBlock(state.tokens)) + } + }, + ) + + init { + if (accountsFeatureToggles.isFeatureEnabled) { + newAddToPortfolioManager = newAddToPortfolioManagerFactory + .create( + modelScope, + params.token, + params.analyticsParams?.source?.let { NewAddToPortfolioManager.AnalyticsParams(it) }, + ) + newMarketsPortfolioDelegate = newMarketsPortfolioDelegateFactory.create( + scope = modelScope, + token = params.token, + tokenActionsHandler = tokenActionsHandler, + buttonState = newAddToPortfolioManager.state.map { state -> + when (state) { + is NewAddToPortfolioManager.State.AvailableToAdd -> { + MyPortfolioUM.Tokens.AddButtonState.Available + } + NewAddToPortfolioManager.State.Init -> MyPortfolioUM.Tokens.AddButtonState.Loading + NewAddToPortfolioManager.State.NothingToAdd -> MyPortfolioUM.Tokens.AddButtonState.Unavailable + } + }, + onAddClick = { + analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioClicked()) + bottomSheetNavigation.activate(MarketsPortfolioRoute.AddToPortfolio) + }, + ) + newMarketsPortfolioDelegate.combineData() + .onEach { _state.value = it } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } else { + newAddToPortfolioManager = null + newMarketsPortfolioDelegate = null + // Subscribe on selected wallet flow to support actual selected wallet + subscribeOnSelectedMultiWalletUpdates() + + subscribeOnStateUpdates() + } + } + + fun setTokenNetworks(networks: List) { + addToPortfolioManager.setAvailableNetworks(networks) + newAddToPortfolioManager?.setTokenNetworks(networks) + newMarketsPortfolioDelegate?.setTokenNetworks(networks) + } + + fun setNoNetworksAvailable() { + addToPortfolioManager.setAvailableNetworks(emptyList()) + newAddToPortfolioManager?.setTokenNetworks(emptyList()) + newMarketsPortfolioDelegate?.setTokenNetworks(emptyList()) + } + + private fun subscribeOnSelectedMultiWalletUpdates() { + getSelectedWalletUseCase() + .getOrElse { e -> + Timber.e("Failed to load selected wallet: $e") + error("Failed to load selected wallet") + } + .onEach { userWallet -> + selectedMultiWalletIdFlow.value = userWallet.takeIf { it.isMultiCurrency }?.walletId + } + .launchIn(modelScope) + } + + private fun subscribeOnStateUpdates() { + combine( + flow = loadPortfolioDataWithArtworks(params.token.id), + flow2 = getPortfolioUIDataFlow(), + transform = { pair, portfolioUIData -> + val (portfolioData, artworks) = pair + factory.create(portfolioData, portfolioUIData, artworks) + }, + ) + .onEach { _state.value = it } + .launchIn(modelScope) + } + + private fun loadPortfolioDataWithArtworks( + currencyRawId: CryptoCurrency.RawID, + ): Flow>> { + val wallets = Channel>() + val portfolioFlow = portfolioDataLoader + .load(currencyRawId) + .onEach { wallets.trySend(it.walletsWithCurrencies.keys) } + + val artworksFlow = wallets.receiveAsFlow() + .distinctUntilChanged() + .flatMapLatest { userWalletImageFetcher.walletsImage(wallets = it, size = ArtworkSize.SMALL) } + + return combine( + flow = portfolioFlow, + flow2 = artworksFlow, + ) { portfolioData, artworks -> portfolioData to artworks } + } + + private fun getPortfolioUIDataFlow(): Flow { + return combine( + flow = portfolioBSVisibilityModelFlow, + flow2 = selectedMultiWalletIdFlow, + flow3 = addToPortfolioManager.getAddToPortfolioData(), + transform = { portfolioBSVisibilityModel, selectedWalletId, addToPortfolioData -> + PortfolioUIData( + portfolioBSVisibilityModel = portfolioBSVisibilityModel, + selectedWalletId = selectedWalletId, + addToPortfolioData = addToPortfolioData, + isNeededColdWalletInteraction = isNeededColdWalletInteraction(selectedWalletId, addToPortfolioData), + ) + }, + ) + } + + private suspend fun isNeededColdWalletInteraction( + selectedWalletId: UserWalletId?, + addToPortfolioData: AddToPortfolioManager.AddToPortfolioData, + ): Boolean { + return if (selectedWalletId != null) { + coldWalletAndHasMissedDerivationsUseCase.invoke( + userWalletId = selectedWalletId, + networksWithDerivationPath = addToPortfolioData.addedNetworks[selectedWalletId].orEmpty() + .associate { it.networkId to null }, + ) + } else { + false + } + } + + private fun onNetworkSwitchClick(blockchainRowUM: BlockchainRowUM, isChecked: Boolean) { + val selectedWalletId = selectedMultiWalletIdFlow.value + + if (selectedWalletId == null) { + Timber.e("Impossible to switch network when selected wallet is null") + return + } + + if (isChecked) { + modelScope.launch { + val unsupportedState = checkCurrencyUnsupportedState( + userWalletId = selectedWalletId, + rawNetworkId = blockchainRowUM.id, + isMainNetwork = blockchainRowUM.isMainNetwork, + ) + if (unsupportedState != null) { + showUnsupportedWarning(unsupportedState) + } else { + addToPortfolioManager.addNetwork(userWalletId = selectedWalletId, networkId = blockchainRowUM.id) + } + } + } else { + addToPortfolioManager.removeNetwork(userWalletId = selectedWalletId, networkId = blockchainRowUM.id) + } + } + + private suspend fun checkCurrencyUnsupportedState( + userWalletId: UserWalletId, + rawNetworkId: String, + isMainNetwork: Boolean, + ): CurrencyUnsupportedState? { + return checkCurrencyUnsupportedUseCase( + userWalletId = userWalletId, + networkId = rawNetworkId, + isMainNetwork = isMainNetwork, + ).getOrElse { throwable -> + Timber.e( + throwable, + """ + Failed to check currency unsupported state + |- User wallet ID: $userWalletId + |- Network ID: $rawNetworkId + |- Is main network: $isMainNetwork + """.trimIndent(), + ) + + val message = SnackbarMessage( + message = throwable.localizedMessage + ?.let(::stringReference) + ?: resourceReference(R.string.common_error), + ) + messageSender.send(message) + + null + } + } + + private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) { + val message = DialogMessage( + message = when (unsupportedState) { + is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + }, + ) + + messageSender.send(message) + } + + private fun onWalletSelect(userWalletId: UserWalletId) { + selectedMultiWalletIdFlow.update { prevUserWalletId -> + prevUserWalletId?.let(addToPortfolioManager::removeAllChanges) + + userWalletId + } + } + + private fun onContinueClick(userWalletId: UserWalletId, addedNetworks: Set) { + modelScope.launch { + saveMarketTokensUseCase( + userWalletId = userWalletId, + tokenMarketParams = params.token, + addedNetworks = addedNetworks, + removedNetworks = emptySet(), + ) + + onAddToPortfolioBSVisibilityChange(isShow = false) + + addToPortfolioManager.removeAllChanges(userWalletId) + } + } + + private fun onAddToPortfolioBSVisibilityChange(isShow: Boolean) { + portfolioBSVisibilityModelFlow.update { + it.copy(isAddToPortfolioBSVisible = isShow, isWalletSelectorBSVisible = false) + } + } + + private fun onWalletSelectorVisibilityChange(isShow: Boolean) { + portfolioBSVisibilityModelFlow.update { + it.copy(isAddToPortfolioBSVisible = true, isWalletSelectorBSVisible = isShow) + } + } + + private fun updateTokensState(block: (MyPortfolioUM.Tokens) -> MyPortfolioUM) { + _state.update { stateToUpdate -> + val tokensState = stateToUpdate as? MyPortfolioUM.Tokens ?: return@update stateToUpdate + block(tokensState) + } + } + + private fun configureReceiveAddresses(quickAction: TokenActionsHandler.HandledQuickAction) { + val isNewReceive = quickAction.action == TokenActionsBSContentUM.Action.Receive && + tokenReceiveFeatureToggle.isNewTokenReceiveEnabled + if (isNewReceive) { + modelScope.launch { + val tokenConfig = receiveAddressesFactory.create( + status = quickAction.cryptoCurrencyData.status, + userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId, + ) ?: return@launch + bottomSheetNavigation.activate(MarketsPortfolioRoute.TokenReceive(tokenConfig)) + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioRoute.kt new file mode 100644 index 0000000000..684f586b8a --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioRoute.kt @@ -0,0 +1,17 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.models.TokenReceiveConfig +import kotlinx.serialization.Serializable + +@Serializable +sealed interface MarketsPortfolioRoute : Route { + + @Serializable + data object AddToPortfolio : MarketsPortfolioRoute + + @Serializable + data class TokenReceive( + val config: TokenReceiveConfig, + ) : MarketsPortfolioRoute +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MyPortfolioUMFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MyPortfolioUMFactory.kt new file mode 100644 index 0000000000..72ff24db98 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MyPortfolioUMFactory.kt @@ -0,0 +1,153 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.utils.Provider +import kotlinx.collections.immutable.ImmutableList + +/** + * Factory for creating [MyPortfolioUM] + * + * @property onAddClick callback when user wants to add new token + * @property addToPortfolioBSContentUMFactory factory for creating add to portfolio bottom sheet content + * @property tokenActionsHandler token actions handler + * @property currentState current state provider + * @property updateTokens callback for updating tokens + * +[REDACTED_AUTHOR] + */ +internal class MyPortfolioUMFactory( + private val onAddClick: () -> Unit, + private val addToPortfolioBSContentUMFactory: AddToPortfolioBSContentUMFactory, + private val tokenActionsHandler: TokenActionsHandler, + private val currentState: Provider, + private val updateTokens: ((ImmutableList) -> ImmutableList) -> Unit, +) { + + fun create( + portfolioData: PortfolioData, + portfolioUIData: PortfolioUIData, + artworks: Map, + ): MyPortfolioUM { + val addToPortfolioData = portfolioUIData.addToPortfolioData + + val isOnlyUnavailableNetworks = addToPortfolioData.availableNetworks?.isEmpty() == true + if (isOnlyUnavailableNetworks) return MyPortfolioUM.Unavailable + + val walletsWithCurrencies = if (addToPortfolioData.availableNetworks == null) { + portfolioData.walletsWithCurrencies + } else { + portfolioData.walletsWithCurrencies.filterAvailableNetworks(networks = addToPortfolioData.availableNetworks) + } + + val isPortfolioEmpty = walletsWithCurrencies.flatMap { it.value }.isEmpty() + if (isPortfolioEmpty) { + val hasMultiWallets = walletsWithCurrencies.filterKeys(UserWallet::isMultiCurrency).isNotEmpty() + + return if (hasMultiWallets) { + MyPortfolioUM.AddFirstToken( + addToPortfolioBSConfig = createAddToPortfolioBSConfig( + portfolioData = portfolioData, + portfolioUIData = portfolioUIData, + artworks = artworks, + ), + onAddClick = onAddClick, + ) + } else { + MyPortfolioUM.UnavailableForWallet + } + } + + return TokensPortfolioUMConverter( + appCurrency = portfolioData.appCurrency, + isBalanceHidden = portfolioData.isBalanceHidden, + addButtonState = walletsWithCurrencies.getAddButtonState( + availableNetworks = addToPortfolioData.availableNetworks, + ), + bsConfig = createAddToPortfolioBSConfig( + portfolioData = portfolioData, + portfolioUIData = portfolioUIData, + artworks = artworks, + ), + onAddClick = onAddClick, + quickActionsIntents = tokenActionsHandler, + currentState = currentState, + updateTokens = updateTokens, + ) + .convert(walletsWithCurrencies) + } + + private fun createAddToPortfolioBSConfig( + portfolioData: PortfolioData, + portfolioUIData: PortfolioUIData, + artworks: Map, + ): TangemBottomSheetConfig { + val selectedWallet = portfolioData.walletsWithCurrencies.keys + .firstOrNull { it.walletId == portfolioUIData.selectedWalletId } + ?: portfolioData.walletsWithCurrencies.keys.firstOrNull { it.isMultiCurrency } + + val availableNetworks = portfolioUIData.addToPortfolioData.availableNetworks.orEmpty() + + val alreadyAddedNetworks = portfolioData.walletsWithCurrencies + .filterAvailableNetworks(availableNetworks)[selectedWallet] + ?.filter { !it.status.currency.isCustom } + ?.map { it.status.currency.network.backendId } + ?.toSet() + + return addToPortfolioBSContentUMFactory.create( + currentState = currentState().addToPortfolioBSConfig, + portfolioData = portfolioData, + portfolioUIData = portfolioUIData, + selectedWallet = selectedWallet, + alreadyAddedNetworks = alreadyAddedNetworks, + artworks = artworks, + ) + } + + private fun Map>.getAddButtonState( + availableNetworks: Set?, + ): MyPortfolioUM.Tokens.AddButtonState { + if (availableNetworks == null) return MyPortfolioUM.Tokens.AddButtonState.Loading + + val networkIds = availableNetworks.map { it.networkId } + + val isAllAvailableNetworksAdded = this + // User can add currencies only in multi-currency wallets + .filterKeys(UserWallet::isMultiCurrency) + .mapValues { entry -> entry.value.map { it.status.currency.network.backendId } } + // Each wallets contains all available networks? + .all { it.value.containsAll(networkIds) } + + return if (isAllAvailableNetworksAdded) { + MyPortfolioUM.Tokens.AddButtonState.Unavailable + } else { + MyPortfolioUM.Tokens.AddButtonState.Available + } + } + + /** Filter map values by available networks [networks] */ + private fun Map>.filterAvailableNetworks( + networks: Set, + ): Map> { + return mapValues { entry -> entry.value.filterAvailableNetworks(networks) } + } + + /** Filter list of [CryptoCurrencyStatus] by available networks [networks] */ + private fun List.filterAvailableNetworks( + networks: Set, + ): List { + val networkIds = networks.map(TokenMarketInfo.Network::networkId) + + return mapNotNull { cryptoCurrencyData -> + cryptoCurrencyData.takeIf { networkIds.contains(it.status.currency.network.backendId) } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/NewMarketsPortfolioDelegate.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/NewMarketsPortfolioDelegate.kt new file mode 100644 index 0000000000..02e0ec8a02 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/NewMarketsPortfolioDelegate.kt @@ -0,0 +1,333 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.toUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioHeader +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioListItem +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.WalletHeader +import com.tangem.utils.extensions.isZero +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LongParameterList") +internal class NewMarketsPortfolioDelegate @AssistedInject constructor( + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val allAccountSupplier: MultiAccountStatusListSupplier, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, + private val getUserWalletUseCase: GetUserWalletUseCase, + isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + @Assisted private val scope: CoroutineScope, + @Assisted private val token: TokenMarketParams, + @Assisted private val tokenActionsHandler: TokenActionsHandler, + @Assisted private val buttonState: Flow, + @Assisted private val onAddClick: () -> Unit, +) { + + private val currencyRawId: CryptoCurrency.RawID = token.id + private var expandedHolder: MutableStateFlow>>? = null + + private val settingsFlow: Flow = combine( + flow = getSelectedAppCurrencyUseCase.invokeOrDefault(), + flow2 = getBalanceHidingSettingsUseCase.isBalanceHidden(), + flow3 = isAccountsModeEnabledUseCase(), + transform = ::SettingsBox, + ).shareIn( + replay = 1, + started = SharingStarted.Eagerly, + scope = scope, + ).distinctUntilChanged() + + private val availableNetworks = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + fun setTokenNetworks(networks: List) { + availableNetworks.tryEmit(networks) + } + + fun combineData(): Flow { + return availableNetworks.transformLatest { availableNetworks -> + when { + availableNetworks.isEmpty() -> emit(MyPortfolioUM.Unavailable) + else -> emitAll(onAvailableNetworksFlow().distinctUntilChanged()) + } + }.distinctUntilChanged() + } + + private fun onAvailableNetworksFlow(): Flow = + portfolioWithThisCurrencyFLow().transformLatest { portfolioWithCurrency -> + when (portfolioWithCurrency.flattenAddedCurrency.isEmpty()) { + false -> emitAll(contentFlow(portfolioWithCurrency).distinctUntilChanged()) + true -> when (portfolioWithCurrency.hasMultiWallets) { + true -> emitAll(addFirstTokenFlow()) + false -> emit(MyPortfolioUM.UnavailableForWallet) + } + } + } + + private fun addFirstTokenFlow(): Flow = buttonState.map { state -> + when (state) { + MyPortfolioUM.Tokens.AddButtonState.Loading -> MyPortfolioUM.Loading + MyPortfolioUM.Tokens.AddButtonState.Available -> MyPortfolioUM.AddFirstToken( + onAddClick = onAddClick, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + ) + MyPortfolioUM.Tokens.AddButtonState.Unavailable -> MyPortfolioUM.Unavailable + } + } + + private fun contentFlow(portfoliosWithThisCurrency: PortfoliosWithThisCurrency): Flow { + fun Portfolio.actionsFoAccountCurrencies(): List>> = + accountsWithAdded.map { account -> + fun CryptoCurrencyStatus.actionsFlow() = getCryptoCurrencyActionsUseCase( + accountId = account.accountStatus.account.accountId, + currency = this.currency, + ).map { actionsState -> actionsState.cryptoCurrencyStatus.currency to actionsState } + account.addedCurrency.map { it.actionsFlow() } + }.flatten() + + val allAddedTokenActions = + portfoliosWithThisCurrency.portfolios.map { portfolio -> portfolio.actionsFoAccountCurrencies() }.flatten() + + return combine( + flow = combine(allAddedTokenActions) { it.toMap() }.distinctUntilChanged(), + flow2 = buttonState.distinctUntilChanged(), + flow3 = getExpandedHolder(portfoliosWithThisCurrency), + flow4 = settingsFlow.distinctUntilChanged(), + transform = { actions, addButtonState, expanded, settings -> + buildContentState( + portfolio = portfoliosWithThisCurrency, + allActions = actions, + addButtonState = addButtonState, + expanded = expanded, + settings = settings, + ) + }, + ) + } + + private fun getExpandedHolder( + portfolio: PortfoliosWithThisCurrency, + ): StateFlow>> { + val expandedHolder = this.expandedHolder + if (expandedHolder != null) return expandedHolder + val allAddedCurrency = portfolio.flattenAddedCurrency + val shouldForceExpand = allAddedCurrency.size == 1 && + allAddedCurrency.first().value.amount?.isZero() == true + + val initValue = when { + shouldForceExpand -> { + val currency = allAddedCurrency.first() + // find userWallet than have this single added token + portfolio.portfolios + .find { it.accountsWithAdded.any { account -> account.addedCurrency.isNotEmpty() } } + ?.userWallet + ?.let { setOf(it.walletId to currency.currency.id) } + .orEmpty() + } + else -> emptySet() + } + return MutableStateFlow(initValue) + .also { this.expandedHolder = it } + } + + private fun portfolioWithThisCurrencyFLow(): Flow = + allAccountSupplier().map { list -> list.map { it.addedAccountsFlow() } }.flatMapLatest { flows -> + combine(flows) { portfolios -> + PortfoliosWithThisCurrency( + currencyRawId = currencyRawId, + portfolios = portfolios.toList(), + ) + } + }.distinctUntilChanged() + + private fun AccountStatusList.addedAccountsFlow(): Flow = + getUserWalletUseCase.invokeFlow(this.userWalletId).mapNotNull { it.getOrNull() }.map { wallet -> + Portfolio( + userWallet = wallet, + accountStatusList = this, + accountsWithAdded = this.filterByRawID(), + ) + }.distinctUntilChanged() + + private fun AccountStatusList.filterByRawID(): List { + fun AccountStatus.filterByRawID(): List = when (this) { + is AccountStatus.CryptoPortfolio -> this.tokenList.flattenCurrencies() + .filter { status -> status.currency.id.rawCurrencyId == currencyRawId } + } + return accountStatuses.map { accountStatus -> + AccountWithAdded( + accountStatus = accountStatus, + addedCurrency = accountStatus.filterByRawID(), + ) + } + } + + private fun buildContentState( + portfolio: PortfoliosWithThisCurrency, + allActions: Map, + addButtonState: MyPortfolioUM.Tokens.AddButtonState, + expanded: Set>, + settings: SettingsBox, + ): MyPortfolioUM.Content { + val appCurrency = settings.appCurrency + val isBalanceHidden = settings.isBalanceHidden + val isAccountMode = settings.isAccountMode + val uiItems: MutableList = mutableListOf() + + fun toggleQuickActions(key: Pair) = expandedHolder?.update { expanded -> + val isExpand = expanded.contains(key) + if (isExpand) expanded.minus(key) else expanded.plus(key) + } + + val tokenUMConverter = PortfolioTokenUMConverter( + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + onTokenItemClick = { }, + tokenActionsHandler = tokenActionsHandler, + ) + + portfolio.portfolios.forEach { portfolioItem -> + if (portfolioItem.flattenAddedCurrency.isEmpty()) return@forEach + val userWallet = portfolioItem.userWallet + if (isAccountMode) { + uiItems.add(portfolioItem.userWallet.toWalletHeader()) + } else { + uiItems.add(portfolioItem.userWallet.toWalletPortfolioHeader()) + } + + portfolioItem.accountsWithAdded.forEach { accountWithAdded -> + if (accountWithAdded.addedCurrency.isEmpty()) return@forEach + if (isAccountMode) { + val account = accountWithAdded.accountStatus.account + uiItems.add(account.toAccountPortfolioHeader()) + } + + accountWithAdded.addedCurrency.forEach { currencyStatus -> + val actions = allActions[currencyStatus.currency]?.states.orEmpty() + val value = PortfolioData.CryptoCurrencyData( + userWallet = userWallet, + status = currencyStatus, + actions = actions, + ) + val expandedKey = portfolioItem.userWallet.walletId to currencyStatus.currency.id + val isExpand = expanded.contains(expandedKey) + + val tokenItem = tokenUMConverter.convertV2( + onTokenItemClick = { wallet, status -> + toggleQuickActions(wallet.walletId to status.currency.id) + }, + value = value, + isQuickActionsShown = isExpand, + ) + uiItems.add(tokenItem) + } + } + } + + return MyPortfolioUM.Content( + items = uiItems.toImmutableList(), + buttonState = addButtonState, + onAddClick = onAddClick, + ) + } + + private fun Account.toAccountPortfolioHeader(): PortfolioHeader = PortfolioHeader( + id = this.accountId.value, + state = AccountTitleUM.Account( + prefixText = TextReference.EMPTY, + name = this.accountName.toUM().value, + icon = when (this) { + is Account.CryptoPortfolio -> this.icon.toUM() + is Account.Payment -> TODO("[REDACTED_JIRA]") + }, + ), + ) + + private fun UserWallet.toWalletPortfolioHeader(): PortfolioHeader = PortfolioHeader( + id = this.walletId.stringValue, + state = AccountTitleUM.Text( + title = stringReference(this.name), + ), + ) + + private fun UserWallet.toWalletHeader(): WalletHeader = WalletHeader( + id = this.walletId.stringValue, + name = stringReference(this.name), + ) + + @Suppress("LongParameterList") + @AssistedFactory + interface Factory { + fun create( + scope: CoroutineScope, + token: TokenMarketParams, + tokenActionsHandler: TokenActionsHandler, + buttonState: Flow, + onAddClick: () -> Unit, + ): NewMarketsPortfolioDelegate + } +} + +private data class PortfoliosWithThisCurrency( + val currencyRawId: CryptoCurrency.RawID, + val portfolios: List, +) { + + val hasMultiWallets: Boolean = portfolios.any { it.userWallet.isMultiCurrency } + + val flattenAddedCurrency: List = + portfolios.map { portfolio -> portfolio.flattenAddedCurrency }.flatten() +} + +private data class Portfolio( + val userWallet: UserWallet, + val accountStatusList: AccountStatusList, + val accountsWithAdded: List, +) { + val flattenAddedCurrency: List = + accountsWithAdded.map { it.addedCurrency }.flatten() +} + +private data class AccountWithAdded( + val addedCurrency: List, + val accountStatus: AccountStatus, +) + +private data class SettingsBox( + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val isAccountMode: Boolean, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioBSVisibilityModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioBSVisibilityModel.kt new file mode 100644 index 0000000000..dd3f13b3e7 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioBSVisibilityModel.kt @@ -0,0 +1,14 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +/** + * Model for portfolio bottom sheet visibility + * + * @property isAddToPortfolioBSVisible visibility of add to portfolio bottom sheet + * @property isWalletSelectorBSVisible visibility of wallet selector bottom sheet + * +[REDACTED_AUTHOR] + */ +internal data class PortfolioBSVisibilityModel( + val isAddToPortfolioBSVisible: Boolean = false, + val isWalletSelectorBSVisible: Boolean = false, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt new file mode 100644 index 0000000000..8041823bc4 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt @@ -0,0 +1,124 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList + +/** + * Converter from [UserWallet] and [CryptoCurrencyStatus] to [PortfolioTokenUM] + * +[REDACTED_AUTHOR] + */ +internal class PortfolioTokenUMConverter( + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val onTokenItemClick: (CryptoCurrencyStatus) -> Unit, + private val tokenActionsHandler: TokenActionsHandler, +) : Converter { + + fun convertV2( + value: PortfolioData.CryptoCurrencyData, + isQuickActionsShown: Boolean, + onTokenItemClick: (UserWallet, CryptoCurrencyStatus) -> Unit, + ): PortfolioTokenUM { + val tokenItemStateConverter = TokenItemStateConverter( + appCurrency = appCurrency, + onItemClick = { _, status -> onTokenItemClick(value.userWallet, status) }, + ) + return PortfolioTokenUM( + tokenItemState = tokenItemStateConverter.convert(value = value.status), + walletId = value.userWallet.walletId, + isBalanceHidden = isBalanceHidden, + isQuickActionsShown = isQuickActionsShown, + quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler), + ) + } + + override fun convert(value: PortfolioData.CryptoCurrencyData): PortfolioTokenUM { + val tokenItemStateConverter = TokenItemStateConverter( + appCurrency = appCurrency, + titleStateProvider = { TokenItemState.TitleState.Content(text = stringReference(value.userWallet.name)) }, + subtitleStateProvider = { + TokenItemState.SubtitleState.TextContent(value = stringReference(value.status.currency.name)) + }, + onItemClick = { _, status -> onTokenItemClick(status) }, + ) + + return PortfolioTokenUM( + tokenItemState = tokenItemStateConverter.convert(value = value.status), + walletId = value.userWallet.walletId, + isBalanceHidden = isBalanceHidden, + isQuickActionsShown = false, + quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler), + ) + } + + companion object { + fun quickActions( + cryptoData: PortfolioData.CryptoCurrencyData, + tokenActionsHandler: TokenActionsHandler, + ): PortfolioTokenUM.QuickActions { + return PortfolioTokenUM.QuickActions( + actions = toQuickActions(cryptoData.actions), + onQuickActionClick = { quickActionUM -> + when (quickActionUM) { + QuickActionUM.Buy -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Buy, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.Exchange -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Exchange, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.Receive -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Receive, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.Stake -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Stake, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.YieldMode -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.YieldMode, + cryptoCurrencyData = cryptoData, + ) + } + }, + onQuickActionLongClick = { actionUM -> + if (actionUM == QuickActionUM.Receive) { + tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.CopyAddress, + cryptoCurrencyData = cryptoData, + ) + } + }, + ) + } + + fun toQuickActions(actions: List) = buildList { + actions.forEach { action -> + if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { + when (action) { + is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy + is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(action.showBadge) + is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive + is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake + is TokenActionsState.ActionState.YieldMode -> QuickActionUM.YieldMode(action.apy) + else -> null + }?.let(::add) + } + } + }.toImmutableList() + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioUIData.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioUIData.kt new file mode 100644 index 0000000000..894b86dc98 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioUIData.kt @@ -0,0 +1,20 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Portfolio UI data. Combined data from all UI flows that required to setup portfolio + * + * @property portfolioBSVisibilityModel portfolio bottom sheet visibility model + * @property selectedWalletId selected wallet id + * @property addToPortfolioData add to portfolio data + * @property isNeededColdWalletInteraction flag that indicates if user has missed derivations and has a cold wallet + * +[REDACTED_AUTHOR] + */ +internal data class PortfolioUIData( + val portfolioBSVisibilityModel: PortfolioBSVisibilityModel, + val selectedWalletId: UserWalletId?, + val addToPortfolioData: AddToPortfolioManager.AddToPortfolioData, + val isNeededColdWalletInteraction: Boolean, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/SelectNetworkUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/SelectNetworkUMConverter.kt new file mode 100644 index 0000000000..5f9c6b282e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/SelectNetworkUMConverter.kt @@ -0,0 +1,37 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList + +/** + * Converter from [TokenMarketParams] to [SelectNetworkUM] + * + * @property networksWithToggle map of networks with toggles + * @property alreadyAddedNetworks already added networks + * @property onNetworkSwitchClick callback is called when network switch is clicked + * +[REDACTED_AUTHOR] + */ +internal class SelectNetworkUMConverter( + private val networksWithToggle: Map, + private val alreadyAddedNetworks: Set, + private val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit, +) : Converter { + + override fun convert(value: TokenMarketParams): SelectNetworkUM { + return SelectNetworkUM( + tokenId = value.id.value, + iconUrl = value.imageUrl, + tokenName = value.name, + tokenCurrencySymbol = value.symbol, + networks = BlockchainRowUMConverter(alreadyAddedNetworks) + .convertList(networksWithToggle.toList()) + .toImmutableList(), + onNetworkSwitchClick = { um, isChecked -> onNetworkSwitchClick(um, isChecked) }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt new file mode 100644 index 0000000000..55d52cea7d --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt @@ -0,0 +1,208 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.routing.AppRoute +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig +import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.share.ShareManager +import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.onramp.model.OnrampSource +import com.tangem.domain.redux.ReduxStateHolder +import com.tangem.domain.tokens.legacy.TradeCryptoAction +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.features.feed.impl.R +import com.tangem.utils.Provider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.toImmutableList + +@Suppress("LongParameterList") +internal class TokenActionsHandler @AssistedInject constructor( + private val router: Router, + private val clipboardManager: ClipboardManager, + private val uiMessageSender: UiMessageSender, + private val reduxStateHolder: ReduxStateHolder, + @Assisted private val currentAppCurrency: Provider, + @Assisted private val updateTokenReceiveBSConfig: ((TangemBottomSheetConfig) -> TangemBottomSheetConfig) -> Unit, + @Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit, + private val isDemoCardUseCase: IsDemoCardUseCase, + private val messageSender: UiMessageSender, + private val shareManager: ShareManager, +) { + + private val disabledActionsInDemoMode = buildSet { + add(TokenActionsBSContentUM.Action.Sell) + } + + fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + onHandleQuickAction( + HandledQuickAction( + action = action, + cryptoCurrencyData = cryptoCurrencyData, + ), + ) + val userWallet = cryptoCurrencyData.userWallet + if (userWallet is UserWallet.Cold && handleDemoMode(action, userWallet)) return + + when (action) { + TokenActionsBSContentUM.Action.Buy -> onBuyClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Exchange -> onExchangeClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Receive -> onReceiveClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.CopyAddress -> onCopyAddress(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Sell -> onSellClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Send -> onSendClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Stake -> onStakeClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.YieldMode -> onYieldModeClick(cryptoCurrencyData) + } + } + + private fun handleDemoMode(action: TokenActionsBSContentUM.Action, userWallet: UserWallet.Cold): Boolean { + val isDemoCard = isDemoCardUseCase.invoke(userWallet.cardId) + val isNeededShowDemoWarning = isDemoCard && disabledActionsInDemoMode.contains(action) + + if (isNeededShowDemoWarning) { + showDemoModeWarning() + } + + return isNeededShowDemoWarning + } + + private fun showDemoModeWarning() { + val message = DialogMessage( + message = resourceReference(R.string.alert_demo_feature_disabled), + ) + messageSender.send(message) + } + + private fun onReceiveClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val cryptoCurrencyStatus = cryptoCurrencyData.status + val currency = cryptoCurrencyStatus.currency + val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return + + updateTokenReceiveBSConfig { + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = { + updateTokenReceiveBSConfig { + it.copy(isShown = false) + } + }, + content = TokenReceiveBottomSheetConfig( + asset = TokenReceiveBottomSheetConfig.Asset.Currency( + name = currency.name, + symbol = currency.symbol, + ), + network = currency.network, + networkAddress = networkAddress, + showMemoDisclaimer = currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE, + onCopyClick = { clipboardManager.setText(networkAddress.defaultAddress.value, isSensitive = true) }, + onShareClick = { shareManager.shareText(networkAddress.defaultAddress.value) }, + ), + ) + } + } + + private fun onCopyAddress(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val cryptoCurrencyStatus = cryptoCurrencyData.status + val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return + val addresses = networkAddress.availableAddresses + .mapToAddressModels(cryptoCurrencyStatus.currency) + .toImmutableList() + val defaultAddress = addresses.firstOrNull()?.value ?: return + + clipboardManager.setText(text = defaultAddress, isSensitive = true) + uiMessageSender.send(SnackbarMessage(resourceReference(R.string.wallet_notification_address_copied))) + } + + private fun onBuyClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + router.push( + AppRoute.Onramp( + userWalletId = cryptoCurrencyData.userWallet.walletId, + currency = cryptoCurrencyData.status.currency, + source = OnrampSource.MARKETS, + ), + ) + } + + private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + reduxStateHolder.dispatch( + TradeCryptoAction.Sell( + cryptoCurrencyStatus = cryptoCurrencyData.status, + appCurrencyCode = currentAppCurrency().code, + ), + ) + } + + private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + router.push( + AppRoute.Swap( + currencyFrom = cryptoCurrencyData.status.currency, + userWalletId = cryptoCurrencyData.userWallet.walletId, + isInitialReverseOrder = true, + screenSource = AnalyticsParam.ScreensSources.Markets.value, + ), + ) + } + + private fun onSendClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val route = AppRoute.SendEntryPoint( + userWalletId = cryptoCurrencyData.userWallet.walletId, + currency = cryptoCurrencyData.status.currency, + ) + router.push(route) + } + + private fun onStakeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val option = cryptoCurrencyData.actions.firstOrNull { it is TokenActionsState.ActionState.Stake } + ?.let { it as TokenActionsState.ActionState.Stake } + ?.option ?: return + + router.push( + AppRoute.Staking( + userWalletId = cryptoCurrencyData.userWallet.walletId, + cryptoCurrency = cryptoCurrencyData.status.currency, + integrationId = option.integrationId, + ), + ) + } + + private fun onYieldModeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val yieldSupplyApy = cryptoCurrencyData.actions.filterIsInstance() + .firstOrNull()?.apy ?: return + + router.push( + AppRoute.YieldSupplyEntry( + userWalletId = cryptoCurrencyData.userWallet.walletId, + cryptoCurrency = cryptoCurrencyData.status.currency, + apy = yieldSupplyApy, + ), + ) + } + + @AssistedFactory + interface Factory { + fun create( + currentAppCurrency: Provider, + updateTokenReceiveBSConfig: ((TangemBottomSheetConfig) -> TangemBottomSheetConfig) -> Unit, + onHandleQuickAction: (HandledQuickAction) -> Unit, + ): TokenActionsHandler + } + + data class HandledQuickAction( + val action: TokenActionsBSContentUM.Action, + val cryptoCurrencyData: PortfolioData.CryptoCurrencyData, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokensPortfolioUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokensPortfolioUMConverter.kt new file mode 100644 index 0000000000..4444e6a82f --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokensPortfolioUMConverter.kt @@ -0,0 +1,116 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isZero +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlin.collections.map + +/** + * Converter from [Map] of [UserWallet] and [CryptoCurrencyStatus] to [MyPortfolioUM.Tokens] + * +[REDACTED_AUTHOR] + */ +@Suppress("LongParameterList") +internal class TokensPortfolioUMConverter( + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val addButtonState: MyPortfolioUM.Tokens.AddButtonState, + private val bsConfig: TangemBottomSheetConfig, + private val onAddClick: () -> Unit, + private val quickActionsIntents: TokenActionsHandler, + private val currentState: Provider, + private val updateTokens: ((ImmutableList) -> ImmutableList) -> Unit, +) : Converter>, MyPortfolioUM.Tokens> { + + override fun convert(value: Map>): MyPortfolioUM.Tokens { + val currentTokensState = currentState() as? MyPortfolioUM.Tokens + + return MyPortfolioUM.Tokens( + tokens = value + .flatMap { entry -> entry.value } + .map { cryptoData -> + PortfolioTokenUMConverter( + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + onTokenItemClick = { toggleQuickActions(cryptoData) }, + tokenActionsHandler = quickActionsIntents, + ).convert(value = cryptoData) to cryptoData + } + .setQuickActionsVisibility(currentState = currentTokensState) + .toImmutableList(), + buttonState = addButtonState, + addToPortfolioBSConfig = bsConfig, + onAddClick = onAddClick, + tokenReceiveBSConfig = (currentState() as? MyPortfolioUM.Tokens) + ?.tokenReceiveBSConfig + ?: TangemBottomSheetConfig.Empty, + ) + } + + private fun List>.setQuickActionsVisibility( + currentState: MyPortfolioUM.Tokens?, + ): List { + return when { + // if there is only one token and it has empty balance, show quick actions for it + currentState == null && this.size == 1 && isEmptyBalance(this.first().second) -> { + this.map { (token, _) -> + token.copy(isQuickActionsShown = true) + } + } + // if there is no previous state, hide quick actions for all tokens + currentState == null -> { + this.map { (token, _) -> + token.copy(isQuickActionsShown = false) + } + } + else -> { + val previousList = currentState.tokens + + // otherwise, keep previous state + this.map { (token, _) -> + token.copy( + isQuickActionsShown = previousList + .firstOrNull { it.matchWith(token) } + ?.isQuickActionsShown == true, + ) + } + } + } + } + + private fun isEmptyBalance(cryptoData: PortfolioData.CryptoCurrencyData): Boolean { + return cryptoData.status.value.amount?.isZero() == true + } + + private fun toggleQuickActions(cryptoData: PortfolioData.CryptoCurrencyData) { + updateTokens { tokenList -> + tokenList.map { portfolioTokenUM -> + portfolioTokenUM.copy( + isQuickActionsShown = if (portfolioTokenUM.matchWith(cryptoData)) { + !portfolioTokenUM.isQuickActionsShown + } else { + false + }, + ) + }.toImmutableList() + } + } + + private fun PortfolioTokenUM.matchWith(token: PortfolioTokenUM): Boolean { + return this.walletId == token.walletId && this.tokenItemState.id == token.tokenItemState.id + } + + private fun PortfolioTokenUM.matchWith(cryptoData: PortfolioData.CryptoCurrencyData): Boolean { + return this.walletId == cryptoData.userWallet.walletId && + this.tokenItemState.id == cryptoData.status.currency.id.value + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/AddToPortfolioBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/AddToPortfolioBottomSheet.kt new file mode 100644 index 0000000000..16993ea52d --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/AddToPortfolioBottomSheet.kt @@ -0,0 +1,383 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.common.ui.userwallet.UserWalletItem +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.buttons.common.TangemButton +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.buttons.common.TangemButtonSize +import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults +import com.tangem.core.ui.components.currency.icon.CoinIcon +import com.tangem.core.ui.components.rows.ArrowRow +import com.tangem.core.ui.components.rows.BlockchainRow +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewAddToPortfolioBSContentProvider +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM +import com.tangem.features.feed.impl.R +import kotlinx.coroutines.delay + +@Composable +internal fun AddToPortfolioBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.tertiary, + addBottomInsets = false, + titleText = resourceReference(R.string.common_add_to_portfolio), + ) { contentUM -> + Content( + modifier = Modifier.fillMaxWidth(), + state = contentUM, + ) + + WalletSelectorBottomSheet(contentUM.walletSelectorConfig) + } +} + +@Composable +private fun Content(state: AddToPortfolioBSContentUM, modifier: Modifier = Modifier) { + var continueButtonAreaHeight by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + val scrollState = rememberScrollState() + + Box(modifier = modifier) { + Column( + modifier = Modifier + .verticalScroll(state = scrollState) + .padding(horizontal = TangemTheme.dimens.spacing16), + ) { + if (state.isWalletBlockVisible) { + UserWalletItem( + state = state.selectedWallet, + blockColors = TangemBlockCardColors.copy( + containerColor = TangemTheme.colors.background.action, + disabledContainerColor = TangemTheme.colors.background.action, + ), + ) + SpacerH12() + } + + NetworkSelection( + modifier = Modifier.fillMaxWidth(), + state = state.selectNetworkUM, + ) + + SpacerH12() + + AnimatedVisibility( + visible = state.isScanCardNotificationVisible, + modifier = Modifier.fillMaxWidth(), + ) { + Column { + ScanWalletWarning(modifier = Modifier.fillMaxWidth()) + SpacerH12() + } + + // Scroll to the bottom when the notification appears and the scroll is at the bottom + LaunchedEffect(Unit) { + if (scrollState.canScrollForward.not()) { + delay(timeMillis = 500) + scrollState.animateScrollTo(scrollState.maxValue) + } + } + } + + SpacerH(with(density) { continueButtonAreaHeight.toDp() }) + } + + AnimatedVisibility( + visible = scrollState.canScrollForward, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + BottomFade(Modifier.align(Alignment.BottomCenter)) + } + + ContinueButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .onGloballyPositioned { + continueButtonAreaHeight = it.size.height + }, + enabled = state.isContinueButtonEnabled, + isTangemIconVisible = state.isScanCardNotificationVisible, + onClick = state.onContinueButtonClick, + ) + } +} + +@Composable +private fun ContinueButton( + enabled: Boolean, + isTangemIconVisible: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TangemButton( + enabled = enabled, + modifier = modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ) + .navigationBarsPadding() + .fillMaxWidth(), + text = stringResourceSafe(R.string.common_continue), + icon = if (enabled && isTangemIconVisible) { + TangemButtonIconPosition.End(R.drawable.ic_tangem_24) + } else { + TangemButtonIconPosition.None + }, + showProgress = false, + size = TangemButtonSize.Default, + colors = TangemButtonsDefaults.primaryButtonColors, + textStyle = TangemTheme.typography.subtitle1, + onClick = onClick, + animateContentChange = true, + ) +} + +@Suppress("LongMethod") +@Composable +private fun NetworkSelection(state: SelectNetworkUM, modifier: Modifier = Modifier) { + val hapticManager = LocalHapticManager.current + + InformationBlock( + modifier = modifier, + title = { + Text( + text = stringResourceSafe(R.string.markets_select_network), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + }, + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens.spacing14), + verticalAlignment = Alignment.CenterVertically, + ) { + CoinIcon( + modifier = Modifier.size(TangemTheme.dimens.size36), + url = state.iconUrl, + alpha = 1f, + colorFilter = null, + fallbackResId = R.drawable.ic_custom_token_44, + ) + SpacerW12() + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .weight(1f, fill = false) + .alignByBaseline(), + text = state.tokenName, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + SpacerW6() + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .alignByBaseline(), + text = state.tokenCurrencySymbol, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.tertiary, + overflow = TextOverflow.Visible, + maxLines = 1, + ) + } + + state.networks.fastForEachIndexed { index, network -> + ArrowRow( + isLastItem = index == state.networks.lastIndex, + content = { + BlockchainRow( + modifier = Modifier.padding(end = TangemTheme.dimens.spacing4), + model = network, + action = { + TangemSwitch( + checked = network.isSelected, + checkedColor = if (network.isEnabled) { + TangemTheme.colors.control.checked + } else { + TangemTheme.colors.icon.inactive + }, + onCheckedChange = { checked -> + if (checked) { + hapticManager.perform(TangemHapticEffect.View.ToggleOn) + } else { + hapticManager.perform(TangemHapticEffect.View.ToggleOff) + } + + state.onNetworkSwitchClick(network, checked) + }, + enabled = network.isEnabled, + ) + }, + ) + }, + ) + } + } + } +} + +@Composable +private fun ScanWalletWarning(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .background( + color = TangemTheme.colors.button.disabled, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .padding(TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing10), + ) { + Icon( + modifier = Modifier.requiredSize(TangemTheme.dimens.size20), + imageVector = ImageVector.vectorResource(R.drawable.ic_tangem_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + Text( + text = stringResourceSafe(R.string.markets_generate_addresses_notification), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview { + AddToPortfolioBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + content = content, + onDismissRequest = {}, + ), + ) + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PreviewContent( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview { + Content( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .fillMaxWidth(), + state = content, + ) + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PreviewContentRtl( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview(rtl = true) { + Content( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .fillMaxWidth(), + state = content, + ) + } +} + +// For on device testing +@Composable +@Preview +private fun PreviewContentTestOnDevice( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview( + alwaysShowBottomSheets = false, + ) { + var isShow by remember { mutableStateOf(false) } + + var contentState by remember { + mutableStateOf(content) + } + + LaunchedEffect(Unit) { + contentState = content.copy( + onContinueButtonClick = { + contentState = contentState.copy( + isScanCardNotificationVisible = !contentState.isScanCardNotificationVisible, + ) + }, + isContinueButtonEnabled = true, + selectedWallet = content.selectedWallet.copy( + onClick = { + contentState = contentState.copy( + isContinueButtonEnabled = !contentState.isContinueButtonEnabled, + ) + }, + ), + ) + } + + AddToPortfolioBottomSheet( + config = TangemBottomSheetConfig( + isShown = isShow, + content = contentState, + onDismissRequest = { isShow = false }, + ), + ) + + Button( + onClick = { isShow = !isShow }, + ) { + Text(text = "Toggle") + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/MyPortfolio.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/MyPortfolio.kt new file mode 100644 index 0000000000..7ff006cdb9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/MyPortfolio.kt @@ -0,0 +1,341 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.common.ui.account.AccountTitle +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SmallButtonShimmer +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewMyPortfolioUMProvider +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.* +import com.tangem.features.feed.impl.R + +@Composable +internal fun MyPortfolio(state: MyPortfolioUM, modifier: Modifier = Modifier) { + if (state is MyPortfolioUM.Content) { + val contentModifier = Modifier.padding( + start = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing20, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing32, + ) + PortfolioList(state, contentModifier) + return + } + InformationBlock( + modifier = modifier, + contentHorizontalPadding = TangemTheme.dimens.spacing0, + title = { Title() }, + action = { + if (state !is MyPortfolioUM.Tokens) return@InformationBlock + + AddButton(state = state.buttonState, onClick = state.onAddClick) + }, + ) { + val contentModifier = Modifier.padding( + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing12, + ) + + when (state) { + is MyPortfolioUM.Tokens -> TokenList(state = state) + is MyPortfolioUM.AddFirstToken -> AddFirstTokenContent(state = state, modifier = contentModifier) + MyPortfolioUM.Loading -> LoadingPlaceholder(modifier = contentModifier) + MyPortfolioUM.Unavailable -> UnavailableAsset(modifier = contentModifier) + MyPortfolioUM.UnavailableForWallet -> UnavailableAssetForWallet(modifier = contentModifier) + is MyPortfolioUM.Content -> PortfolioList(state = state) + } + } + + val bsConfig = state.addToPortfolioBSConfig + if (bsConfig != null) { + AddToPortfolioBottomSheet(config = bsConfig) + } +} + +@Composable +private fun Title() { + Text( + text = stringResourceSafe(R.string.markets_common_my_portfolio), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) +} + +@Composable +private fun AddButton(state: MyPortfolioUM.Tokens.AddButtonState, onClick: () -> Unit) { + when (state) { + MyPortfolioUM.Tokens.AddButtonState.Loading -> { + Box { + SmallButtonShimmer( + modifier = Modifier.width(width = TangemTheme.dimens.size63), + shape = RoundedCornerShape(TangemTheme.dimens.radius3), + withIcon = true, + ) + + Box( + Modifier + .matchParentSize() + .background(TangemTheme.colors.background.action), + ) + + RectangleShimmer( + modifier = Modifier + .align(Alignment.Center) + .size(width = TangemTheme.dimens.size63, height = TangemTheme.dimens.size18), + radius = TangemTheme.dimens.radius3, + ) + } + } + MyPortfolioUM.Tokens.AddButtonState.Available, + MyPortfolioUM.Tokens.AddButtonState.Unavailable, + -> { + SecondarySmallButton( + config = SmallButtonConfig( + text = resourceReference(R.string.markets_add_token), + icon = TangemButtonIconPosition.Start(R.drawable.ic_plus_24), + onClick = onClick, + isEnabled = state == MyPortfolioUM.Tokens.AddButtonState.Available, + ), + ) + } + } +} + +@Composable +private fun TokenList(state: MyPortfolioUM.Tokens, modifier: Modifier = Modifier) { + Column(modifier) { + state.tokens.fastForEachIndexed { index, token -> + key(token.tokenItemState.id) { + PortfolioItem( + modifier = Modifier.background(color = TangemTheme.colors.background.action), + state = token, + lastInList = index == state.tokens.size - 1, + ) + } + } + } + + TokenReceiveBottomSheet(config = state.tokenReceiveBSConfig) +} + +@Composable +private fun PortfolioList(state: MyPortfolioUM.Content, modifier: Modifier = Modifier) { + Column(modifier) { + key("PortfolioListHeader") { + Row( + modifier = Modifier.padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResourceSafe(R.string.markets_common_my_portfolio), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + AddButton(state = state.buttonState, onClick = state.onAddClick) + } + } + + state.items.fastForEachIndexed { index, item -> + val previousItem = state.items.getOrNull(index.dec()) + val nextItem = state.items.getOrNull(index.inc()) + val itemModifier = Modifier + .fillMaxWidth() + .getOffsetModifier(item, previousItem) + .getBackgroundModifier(item, previousItem, nextItem) + + key(item.id) { + PortfolioItem( + item = item, + modifier = itemModifier, + lastInList = index == state.items.size - 1, + ) + } + } + } +} + +@Composable +private fun Modifier.getBackgroundModifier( + item: PortfolioListItem, + previousItem: PortfolioListItem?, + nextItem: PortfolioListItem?, +): Modifier { + val color = TangemTheme.colors.background.action + val radius = 14.dp + val topRound = RoundedCornerShape(topStart = radius, topEnd = radius) + val bottomRound = RoundedCornerShape(bottomStart = radius, bottomEnd = radius) + val allRound = RoundedCornerShape(size = radius) + val backgroundModifier = when (item) { + is WalletHeader -> this + is PortfolioHeader -> this + .clip(topRound) + .background(color = color) + is PortfolioTokenUM -> when { + previousItem is PortfolioHeader && nextItem !is PortfolioTokenUM -> this + .clip(bottomRound) + .background(color = color) + previousItem is WalletHeader && nextItem !is PortfolioTokenUM -> this + .clip(allRound) + .background(color = color) + previousItem is PortfolioTokenUM && nextItem !is PortfolioTokenUM -> this + .clip(bottomRound) + .background(color = color) + else -> this.background(color = color) + } + } + return backgroundModifier +} + +private fun Modifier.getOffsetModifier(item: PortfolioListItem, previousItem: PortfolioListItem?): Modifier = when { + item is WalletHeader -> this.padding(top = 20.dp, start = 4.dp, end = 4.dp) + item is PortfolioHeader && previousItem is PortfolioTokenUM -> this.padding(top = 12.dp) + item is PortfolioHeader && previousItem == null -> this.padding(top = 20.dp) + previousItem is WalletHeader -> this.padding(top = 12.dp) + previousItem is PortfolioTokenUM -> this + else -> this +} + +@Composable +private fun PortfolioItem(item: PortfolioListItem, lastInList: Boolean, modifier: Modifier = Modifier) { + when (item) { + is PortfolioHeader -> AccountTitle( + modifier = modifier.padding( + start = 12.dp, + top = 12.dp, + bottom = 8.dp, + ), + accountTitleUM = item.state, + textStyle = TangemTheme.typography.caption1, + textColor = TangemTheme.colors.text.primary1, + ) + is PortfolioTokenUM -> PortfolioItem( + state = item, + modifier = modifier, + lastInList = lastInList, + ) + is WalletHeader -> Text( + modifier = modifier, + text = item.name.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + } +} + +@Composable +fun UnavailableAsset(modifier: Modifier = Modifier) { + UnavailableContent( + textId = R.string.markets_add_to_my_portfolio_unavailable_description, + modifier = modifier, + ) +} + +@Composable +fun UnavailableAssetForWallet(modifier: Modifier = Modifier) { + UnavailableContent( + textId = R.string.markets_add_to_my_portfolio_unavailable_for_wallet_description, + modifier = modifier, + ) +} + +@Composable +private fun UnavailableContent(@StringRes textId: Int, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = stringResourceSafe(textId), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) +} + +@Composable +private fun AddFirstTokenContent(state: MyPortfolioUM.AddFirstToken, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Text( + text = stringResourceSafe(R.string.markets_add_to_my_portfolio_description), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_add_to_portfolio), + onClick = state.onAddClick, + ) + } +} + +@Composable +private fun LoadingPlaceholder(modifier: Modifier = Modifier) { + Column(modifier = modifier) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + + TextShimmer( + modifier = Modifier.fillMaxWidth(fraction = 0.7f), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state: MyPortfolioUM) { + TangemThemePreview { + Box( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary), + ) { + MyPortfolio(state) + } + } +} + +@Preview +@Composable +private fun PreviewRtl(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state: MyPortfolioUM) { + TangemThemePreview(rtl = true) { + Box( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .padding(TangemTheme.dimens.spacing8), + ) { + MyPortfolio(state) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioItem.kt new file mode 100644 index 0000000000..9faba4479e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioItem.kt @@ -0,0 +1,153 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.icons.IconTint +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewMyPortfolioUMProvider +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.features.feed.impl.R +import com.tangem.utils.StringsSigns.DASH_SIGN +import kotlinx.collections.immutable.persistentListOf +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState as TokenFiatAmountState + +@Composable +internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifier: Modifier = Modifier) { + Column(modifier) { + val hapticManager = LocalHapticManager.current + val tokenItemState = remember(state.tokenItemState) { + when (state.tokenItemState) { + is TokenItemState.Content -> state.tokenItemState.copy( + onItemClick = { tokenItemState -> + val onClick = state.tokenItemState.onItemClick + if (onClick != null) { + hapticManager.perform(TangemHapticEffect.View.ContextClick) + onClick.invoke(tokenItemState) + } + }, + ) + else -> state.tokenItemState + } + } + TokenItem( + state = tokenItemState, + isBalanceHidden = state.isBalanceHidden, + itemPaddingValues = PaddingValues( + start = TangemTheme.dimens.spacing10, + end = TangemTheme.dimens.spacing12, + ), + ) + + PortfolioQuickActions( + modifier = Modifier + .padding( + bottom = if (lastInList) { + TangemTheme.dimens.spacing12 + } else { + TangemTheme.dimens.spacing24 + }, + ), + actions = state.quickActions.actions, + isVisible = state.isQuickActionsShown, + onActionClick = state.quickActions.onQuickActionClick, + onActionLongClick = state.quickActions.onQuickActionLongClick, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(PortfolioTokenUMProvider::class) tokenUM: PortfolioTokenUM) { + TangemThemePreview { + var isQuickActionsShown by remember { mutableStateOf(value = false) } + + val onItemClick = { + isQuickActionsShown = isQuickActionsShown.not() + } + + PortfolioItem( + modifier = Modifier.background(color = TangemTheme.colors.background.action), + state = tokenUM.copy( + tokenItemState = when (tokenUM.tokenItemState) { + is TokenItemState.Content -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() }) + is TokenItemState.Unreachable -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() }) + else -> tokenUM.tokenItemState + }, + isQuickActionsShown = isQuickActionsShown, + ), + lastInList = true, + ) + } +} + +private class PortfolioTokenUMProvider : CollectionPreviewParameterProvider( + collection = listOf( + tokenUM.copy( + tokenItemState = (tokenUM.tokenItemState as TokenItemState.Content).copy( + fiatAmountState = contentFiatAmount?.copy( + icons = persistentListOf( + TokenFiatAmountState.Content.IconUM( + iconRes = R.drawable.ic_staking_24, + tint = IconTint.Accent, + ), + ), + ), + ), + ), + tokenUM.copy( + tokenItemState = tokenUM.tokenItemState.copy( + fiatAmountState = contentFiatAmount?.copy(text = DASH_SIGN), + subtitle2State = (tokenUM.tokenItemState.subtitle2State as? TokenItemState.Subtitle2State.TextContent) + ?.copy(text = DASH_SIGN), + ), + ), + tokenUM.copy(isBalanceHidden = true), + tokenUM.copy( + tokenItemState = TokenItemState.Unreachable( + id = tokenUM.tokenItemState.id, + iconState = tokenUM.tokenItemState.iconState, + titleState = tokenUM.tokenItemState.titleState, + subtitleState = tokenUM.tokenItemState.subtitleState, + onItemClick = {}, + onItemLongClick = {}, + ), + ), + tokenUM.copy( + tokenItemState = TokenItemState.NoAddress( + id = tokenUM.tokenItemState.id, + iconState = tokenUM.tokenItemState.iconState, + titleState = tokenUM.tokenItemState.titleState, + subtitleState = tokenUM.tokenItemState.subtitleState, + onItemLongClick = {}, + ), + ), + tokenUM.copy( + tokenItemState = TokenItemState.Loading( + id = tokenUM.tokenItemState.id, + iconState = tokenUM.tokenItemState.iconState, + titleState = tokenUM.tokenItemState.titleState as TokenItemState.TitleState.Content, + subtitleState = tokenUM.tokenItemState.subtitleState, + ), + ), + ), +) { + + companion object { + val tokenUM = PreviewMyPortfolioUMProvider().sampleToken + val contentFiatAmount = tokenUM.tokenItemState.fiatAmountState as? TokenFiatAmountState.Content + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt new file mode 100644 index 0000000000..ffed264514 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt @@ -0,0 +1,268 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.SpacerH4 +import com.tangem.core.ui.components.icons.badge.drawBadge +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun PortfolioQuickActions( + actions: ImmutableList, + isVisible: Boolean, + onActionClick: (QuickActionUM) -> Unit, + onActionLongClick: (QuickActionUM) -> Unit, + modifier: Modifier = Modifier, +) { + if (actions.isEmpty()) return + + AnimatedVisibility( + modifier = modifier, + visible = isVisible, + enter = expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top), + ) { + Column { + actions.fastForEach { action -> + LineSeparator() + QuickActionItem( + state = action, + onClick = { onActionClick(action) }, + onLongClick = { onActionLongClick(action) }.takeIf { action.isLongClickAvailable }, + ) + } + } + } +} + +@Composable +private fun AnimatedVisibilityScope.LineSeparator(modifier: Modifier = Modifier) { + val lineColor = TangemTheme.colors.stroke.primary + val strokeWidth = TangemTheme.dimens.size1 + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val startPadding = TangemTheme.dimens.spacing30 + + val height = TangemTheme.dimens.size16 + + Canvas( + modifier = modifier + .animateEnterExit( + enter = expandVertically( + animationSpec = spring( + stiffness = Spring.StiffnessLow, + ), + expandFrom = Alignment.Top, + ) + fadeIn(), + exit = shrinkVertically( + spring( + stiffness = Spring.StiffnessLow, + ), + shrinkTowards = Alignment.Top, + ) + fadeOut(), + ) + .fillMaxWidth() + .height(height), + ) { + val x = if (isLtr) startPadding.toPx() else size.width - startPadding.toPx() + + drawLine( + color = lineColor, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = strokeWidth.toPx(), + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun AnimatedVisibilityScope.QuickActionItem( + state: QuickActionUM, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val hapticManager = LocalHapticManager.current + val onLongClickInternal: (() -> Unit)? = if (onLongClick != null) { + { + hapticManager.perform(TangemHapticEffect.View.LongPress) + onLongClick() + } + } else { + null + } + + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onLongClick = onLongClickInternal, + onClick = { + hapticManager.perform(TangemHapticEffect.View.SegmentTick) + onClick() + }, + ) + .padding(horizontal = TangemTheme.dimens.spacing14, vertical = TangemTheme.dimens.spacing4), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing18), + ) { + QuickActionIcon(state) + Column( + modifier = Modifier + .animateEnterExit( + enter = fadeIn(), + exit = fadeOut(), + ), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2), + ) { + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = state.description.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } +} + +@Composable +private fun AnimatedVisibilityScope.QuickActionIcon(state: QuickActionUM) { + val containerColor = TangemTheme.colors.background.action + Box( + Modifier + .animateEnterExit( + enter = scaleIn(), + exit = scaleOut(), + ) + .background( + color = TangemTheme.colors.button.secondary, + shape = CircleShape, + ) + .size(TangemTheme.dimens.size32) + .drawWithContent { + drawContent() + if (state is QuickActionUM.Exchange && state.shouldShowBadge) { + drawBadge(containerColor = containerColor, offset = 4.dp) + } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier + .requiredSize(TangemTheme.dimens.size16), + imageVector = ImageVector.vectorResource(id = state.icon), + contentDescription = null, + tint = TangemTheme.colors.button.primary, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + var isVisible by remember { mutableStateOf(true) } + + Column( + modifier = Modifier + .fillMaxWidth() + .height(680.dp), + ) { + Button( + onClick = { isVisible = !isVisible }, + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + ) { + Text(text = "Toggle") + } + SpacerH4() + Box( + modifier = Modifier.background(color = TangemTheme.colors.background.action), + ) { + PortfolioQuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(shouldShowBadge = true), + QuickActionUM.Receive, + ), + isVisible = isVisible, + onActionClick = {}, + onActionLongClick = {}, + ) + } + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewRtl() { + TangemThemePreview(rtl = true) { + Box(modifier = Modifier.background(color = TangemTheme.colors.background.action)) { + PortfolioQuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(shouldShowBadge = true), + QuickActionUM.Receive, + ), + isVisible = true, + onActionClick = {}, + onActionLongClick = {}, + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/TokenActionsBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/TokenActionsBottomSheet.kt new file mode 100644 index 0000000000..7b4a266b9e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/TokenActionsBottomSheet.kt @@ -0,0 +1,86 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.SimpleSettingsRow +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun TokenActionsBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + title = { content -> + TangemBottomSheetTitle(content.title) + }, + containerColor = TangemTheme.colors.background.tertiary, + content = { Content(it) }, + ) +} + +@Composable +private fun Content(content: TokenActionsBSContentUM) { + Column( + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) { + content.actions.forEachIndexed { index, action -> + Box( + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = content.actions.lastIndex, + addDefaultPadding = false, + ) + .background(TangemTheme.colors.background.action), + ) { + SimpleSettingsRow( + title = action.text.resolveReference(), + icon = action.iconRes, + redesign = true, + onItemsClick = { content.onActionClick(action) }, + ) + } + } + } +} + +@Preview(widthDp = 360, heightDp = 640) +@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview( + alwaysShowBottomSheets = true, + ) { + Box(Modifier.background(TangemTheme.colors.background.secondary)) { + TokenActionsBottomSheet( + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = TokenActionsBSContentUM( + title = "Wallet 1", + actions = TokenActionsBSContentUM.Action.entries.toImmutableList(), + onActionClick = {}, + ), + ), + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/WalletSelectorBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/WalletSelectorBottomSheet.kt new file mode 100644 index 0000000000..530786cf30 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/WalletSelectorBottomSheet.kt @@ -0,0 +1,145 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.common.ui.userwallet.UserWalletItem +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewAddToPortfolioBSContentProvider +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.WalletSelectorBSContentUM +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun WalletSelectorBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.tertiary, + addBottomInsets = false, + title = { content -> + TangemTopAppBar( + title = resourceReference(R.string.common_choose_wallet), + titleAlignment = Alignment.CenterHorizontally, + startButton = TopAppBarButtonUM.Back(content.onBack), + height = TangemTopAppBarHeight.BOTTOM_SHEET, + ) + }, + ) { content -> + Content( + modifier = Modifier + .fillMaxSize() + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing8, + ), + state = content, + ) + } +} + +@Composable +private fun Content(state: WalletSelectorBSContentUM, modifier: Modifier = Modifier) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + + Column( + modifier = modifier + .verticalScroll(rememberScrollState()), + ) { + BlockCard( + modifier = Modifier.fillMaxSize(), + colors = TangemBlockCardColors.copy( + containerColor = TangemTheme.colors.background.action, + disabledContainerColor = TangemTheme.colors.background.action, + ), + ) { + state.userWallets.forEach { state -> + key(state.id) { + UserWalletItem( + modifier = Modifier.fillMaxWidth(), + blockColors = TangemBlockCardColors.copy( + containerColor = TangemTheme.colors.background.action, + disabledContainerColor = TangemTheme.colors.background.action, + ), + state = state, + ) + } + } + } + SpacerH(bottomBarHeight) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + WalletSelectorBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = WalletSelectorBSContentUM( + userWallets = persistentListOf( + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.Checkmark, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + ), + onBack = {}, + ), + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewContent() { + TangemThemePreview { + Content( + state = WalletSelectorBSContentUM( + userWallets = persistentListOf( + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.Checkmark, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + ), + onBack = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt new file mode 100644 index 0000000000..4de442ca27 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt @@ -0,0 +1,86 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.persistentListOf + +internal class PreviewAddToPortfolioBSContentProvider : PreviewParameterProvider { + + private val blockchainRow = BlockchainRowUM( + id = "1", + name = "Etherium 3", + type = "TEST", + iconResId = R.drawable.ic_eth_16, + isMainNetwork = false, + isSelected = false, + ) + + val userWallet = UserWalletItemUM( + id = "1", + name = stringReference("Wallet 1"), + information = UserWalletItemUM.Information.Loaded(TextReference.Str("3 cards")), + balance = UserWalletItemUM.Balance.Loading, + isEnabled = true, + endIcon = UserWalletItemUM.EndIcon.Arrow, + onClick = {}, + ) + + override val values: Sequence + get() = sequenceOf( + AddToPortfolioBSContentUM( + selectedWallet = userWallet, + selectNetworkUM = SelectNetworkUM( + tokenId = "etherium", + tokenName = "Etherium", + tokenCurrencySymbol = "ETH", + networks = persistentListOf( + blockchainRow.copy( + type = "MAIN", + isMainNetwork = true, + isSelected = true, + ), + blockchainRow, + blockchainRow, + ), + onNetworkSwitchClick = { _, _ -> }, + iconUrl = null, + ), + isScanCardNotificationVisible = true, + isWalletBlockVisible = true, + isContinueButtonEnabled = true, + onContinueButtonClick = {}, + walletSelectorConfig = TangemBottomSheetConfig.Empty, + ), + AddToPortfolioBSContentUM( + selectedWallet = userWallet, + selectNetworkUM = SelectNetworkUM( + tokenId = "etherium", + tokenName = "Etherium Etherium Etherium Etherium", + tokenCurrencySymbol = "ETH", + networks = persistentListOf( + blockchainRow.copy( + type = "MAIN", + isMainNetwork = true, + isSelected = true, + ).copy(name = "Etherium Etherium Etherium Etherium"), + *Array(25) { blockchainRow }, + ), + + onNetworkSwitchClick = { _, _ -> }, + iconUrl = null, + ), + isScanCardNotificationVisible = true, + isWalletBlockVisible = false, + isContinueButtonEnabled = false, + onContinueButtonClick = {}, + walletSelectorConfig = TangemBottomSheetConfig.Empty, + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt new file mode 100644 index 0000000000..3a40be94b8 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt @@ -0,0 +1,150 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.common.ui.account.AccountIconPreviewData +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.* +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +@Suppress("PropertyUsedBeforeDeclaration") +internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider { + + override val values: Sequence + get() = sequenceOf( + MyPortfolioUM.Tokens( + tokens = persistentListOf(sampleToken, sampleToken), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Available, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + tokenReceiveBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.Tokens( + tokens = persistentListOf(sampleToken, sampleToken.copy(isQuickActionsShown = true)), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Unavailable, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + tokenReceiveBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.Tokens( + tokens = persistentListOf(sampleToken.copy(isQuickActionsShown = true), sampleToken), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Loading, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + tokenReceiveBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.AddFirstToken( + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.Content( + items = persistentListOf( + walletPortfolioHeader, + accountToken, + accountToken, + ), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Available, + onAddClick = {}, + ), + MyPortfolioUM.Content( + items = persistentListOf( + walletHeader, + accountHeader, + accountToken, + accountToken, + ), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Available, + onAddClick = {}, + ), + MyPortfolioUM.Content( + items = persistentListOf( + walletHeader, + accountHeader, + accountToken.copy(isQuickActionsShown = true), + accountToken, + ), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Available, + onAddClick = {}, + ), + MyPortfolioUM.Loading, + MyPortfolioUM.Unavailable, + MyPortfolioUM.UnavailableForWallet, + ) + + val walletHeader + get() = WalletHeader( + id = UUID.randomUUID().toString(), + name = stringReference("Wallet 1"), + ) + + val walletPortfolioHeader + get() = PortfolioHeader( + state = AccountTitleUM.Text(title = stringReference("Wallet 1")), + id = UUID.randomUUID().toString(), + ) + + val accountHeader + get() = PortfolioHeader( + state = AccountTitleUM.Account( + icon = AccountIconPreviewData.randomAccountIcon(), + name = stringReference("Main Account"), + prefixText = TextReference.EMPTY, + ), + id = UUID.randomUUID().toString(), + ) + val coinIconState + get() = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = com.tangem.core.ui.R.drawable.img_polygon_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ) + val accountToken + get() = sampleToken.copy( + tokenItemState = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = coinIconState, + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Polygon")), + fiatAmountState = FiatAmountState.Content(text = "321 $"), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "5,412 MATIC"), + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(value = "Token")), + onItemClick = {}, + onItemLongClick = {}, + ), + ) + + val sampleToken + get() = PortfolioTokenUM( + tokenItemState = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = CurrencyIconState.Locked, + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "My wallet")), + fiatAmountState = FiatAmountState.Content(text = "486,65 \$"), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "733,71097 MATIC"), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = stringReference(value = "XRP Ledger token"), + ), + onItemClick = {}, + onItemLongClick = {}, + ), + isQuickActionsShown = false, + quickActions = PortfolioTokenUM.QuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(shouldShowBadge = true), + QuickActionUM.Receive, + ), + onQuickActionClick = {}, + onQuickActionLongClick = {}, + ), + isBalanceHidden = false, + walletId = UserWalletId(""), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/AddToPortfolioBSContentUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/AddToPortfolioBSContentUM.kt new file mode 100644 index 0000000000..11e574c9e7 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/AddToPortfolioBSContentUM.kt @@ -0,0 +1,15 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent + +internal data class AddToPortfolioBSContentUM( + val selectedWallet: UserWalletItemUM, + val selectNetworkUM: SelectNetworkUM, + val isWalletBlockVisible: Boolean, + val isScanCardNotificationVisible: Boolean, + val isContinueButtonEnabled: Boolean, + val onContinueButtonClick: () -> Unit, + val walletSelectorConfig: TangemBottomSheetConfig, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/MyPortfolioUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/MyPortfolioUM.kt new file mode 100644 index 0000000000..010f7023cf --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/MyPortfolioUM.kt @@ -0,0 +1,52 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class MyPortfolioUM { + + abstract val addToPortfolioBSConfig: TangemBottomSheetConfig? + + data class Tokens( + override val addToPortfolioBSConfig: TangemBottomSheetConfig, + val tokens: ImmutableList, + val buttonState: AddButtonState, + val tokenReceiveBSConfig: TangemBottomSheetConfig, + val onAddClick: () -> Unit, + ) : MyPortfolioUM() { + + enum class AddButtonState { + Loading, + Available, + Unavailable, + } + } + + data class Content( + val items: ImmutableList, + val buttonState: Tokens.AddButtonState, + val onAddClick: () -> Unit, + ) : MyPortfolioUM() { + + override val addToPortfolioBSConfig: TangemBottomSheetConfig = TangemBottomSheetConfig.Empty + } + + data class AddFirstToken( + override val addToPortfolioBSConfig: TangemBottomSheetConfig, + val onAddClick: () -> Unit, + ) : MyPortfolioUM() + + data object Loading : MyPortfolioUM() { + override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null + } + + data object Unavailable : MyPortfolioUM() { + override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null + } + + data object UnavailableForWallet : MyPortfolioUM() { + override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/PortfolioTokenUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/PortfolioTokenUM.kt new file mode 100644 index 0000000000..c4817c30c2 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/PortfolioTokenUM.kt @@ -0,0 +1,39 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed interface PortfolioListItem { + val id: String +} + +internal data class WalletHeader( + override val id: String, + val name: TextReference, +) : PortfolioListItem + +internal data class PortfolioHeader( + override val id: String, + val state: AccountTitleUM, +) : PortfolioListItem + +internal data class PortfolioTokenUM( + val tokenItemState: TokenItemState, + val walletId: UserWalletId, + val isBalanceHidden: Boolean, + val isQuickActionsShown: Boolean, + val quickActions: QuickActions, +) : PortfolioListItem { + override val id: String = tokenItemState.id + + data class QuickActions( + val actions: ImmutableList, + val onQuickActionClick: (QuickActionUM) -> Unit, + val onQuickActionLongClick: (QuickActionUM) -> Unit, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt new file mode 100644 index 0000000000..c98c012df0 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt @@ -0,0 +1,51 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.features.feed.impl.R + +@Immutable +internal sealed class QuickActionUM( + val title: TextReference, + val description: TextReference, + @DrawableRes val icon: Int, + val isLongClickAvailable: Boolean = false, +) { + data object Buy : QuickActionUM( + title = resourceReference(R.string.common_buy), + description = resourceReference(R.string.buy_token_description), + icon = R.drawable.ic_plus_24, + ) + + data class Exchange( + val shouldShowBadge: Boolean, + ) : QuickActionUM( + title = resourceReference(R.string.common_exchange), + description = resourceReference(R.string.exсhange_token_description), + icon = R.drawable.ic_exchange_vertical_24, + ) + + data object Receive : QuickActionUM( + title = resourceReference(R.string.common_receive), + description = resourceReference(R.string.receive_token_description), + icon = R.drawable.ic_arrow_down_24, + isLongClickAvailable = true, + ) + + data object Stake : QuickActionUM( + title = resourceReference(R.string.common_stake), + description = resourceReference(R.string.stake_token_description), + icon = R.drawable.ic_staking_24, + ) + + data class YieldMode( + private val apy: String, + ) : QuickActionUM( + title = resourceReference(R.string.common_yield_mode), + description = resourceReference(R.string.yield_module_main_screen_promo_banner_message, wrappedList(apy)), + icon = R.drawable.ic_analytics_up_mini_24, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/SelectNetworkUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/SelectNetworkUM.kt new file mode 100644 index 0000000000..4ee09913a4 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/SelectNetworkUM.kt @@ -0,0 +1,13 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import kotlinx.collections.immutable.ImmutableList + +internal data class SelectNetworkUM( + val tokenId: String, + val iconUrl: String?, + val tokenName: String, + val tokenCurrencySymbol: String, + val networks: ImmutableList, + val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt new file mode 100644 index 0000000000..f35729eaa8 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt @@ -0,0 +1,58 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.ImmutableList + +internal data class TokenActionsBSContentUM( + val title: String, + val actions: ImmutableList, + val onActionClick: (Action) -> Unit, +) : TangemBottomSheetConfigContent { + + @Immutable + enum class Action( + val text: TextReference, + @DrawableRes val iconRes: Int, + ) { + CopyAddress( + text = resourceReference(R.string.common_copy_address), + iconRes = R.drawable.ic_copy_24, + ), + Send( + text = resourceReference(R.string.common_send), + iconRes = R.drawable.ic_arrow_up_24, + ), + Receive( + text = resourceReference(R.string.common_receive), + iconRes = R.drawable.ic_arrow_down_24, + ), + Buy( + text = resourceReference(R.string.common_buy), + iconRes = R.drawable.ic_plus_24, + ), + Sell( + text = resourceReference(R.string.common_sell), + iconRes = R.drawable.ic_currency_24, + ), + Exchange( + text = resourceReference(R.string.common_exchange), + iconRes = R.drawable.ic_exchange_horizontal_24, + ), + Stake( + text = resourceReference(R.string.common_stake), + iconRes = R.drawable.ic_staking_24, + ), + YieldMode( + text = resourceReference(R.string.common_yield_mode), + iconRes = R.drawable.ic_analytics_up_mini_24, + ), + ; + + val order: Int = ordinal + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/WalletSelectorBSContentUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/WalletSelectorBSContentUM.kt new file mode 100644 index 0000000000..e93a65ddcd --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/WalletSelectorBSContentUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import kotlinx.collections.immutable.ImmutableList + +internal data class WalletSelectorBSContentUM( + val userWallets: ImmutableList, + val onBack: () -> Unit, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt index ca5d351c48..c4c3190a5a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt @@ -1,27 +1,70 @@ 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() + val bsState by bottomSheetState + TopBarWithSearch( + onBackClick = params.onBackClicked, + onSearchClick = state.onSearchClicked, + marketsSearchBar = state.marketsSearchBar, + bottomSheetState = bsState, + ) } @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..38bef9331e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt @@ -1,23 +1,70 @@ package com.tangem.features.feed.components.news.details import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.R +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.news.model.NewsListConfig +import com.tangem.features.feed.model.news.details.NewsDetailsModel +import com.tangem.features.feed.ui.news.details.NewsDetailsContent +import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM +import kotlinx.serialization.Serializable internal class DefaultNewsDetailsComponent( appComponentContext: AppComponentContext, -) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + private val params: Params, +) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { + + private val newsDetailsModel = getOrCreateModel(params = params) @Composable - override fun Title() { + override fun Title(bottomSheetState: State) { + val background = LocalMainBottomSheetColor.current.value + val state by newsDetailsModel.state.collectAsStateWithLifecycle() + TangemTopAppBar( + containerColor = background, + title = null, + startButton = TopAppBarButtonUM.Icon( + iconRes = R.drawable.ic_back_24, + onClicked = state.onBackClick, + isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED, + ), + endButton = TopAppBarButtonUM.Icon( + iconRes = R.drawable.ic_share_24, + onClicked = state.onShareClick, + isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED && + state.articlesStateUM is ArticlesStateUM.Content, + ), + ) } @Composable - override fun Content(modifier: Modifier) { + override fun Content(bottomSheetState: State, modifier: Modifier) { + val state by newsDetailsModel.state.collectAsStateWithLifecycle() + NewsDetailsContent( + state = state, + modifier = modifier, + ) } - @Composable - override fun Footer() { - } + @Serializable + data class Params( + val screenSource: String, + val articleId: Int, + val onBackClicked: () -> Unit, + val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), + val preselectedArticlesId: List = emptyList(), + val paginationConfig: NewsListConfig? = null, + ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt index bdde19a5c8..73676ae03b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt @@ -1,23 +1,62 @@ package com.tangem.features.feed.components.news.list import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.R +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.domain.news.model.NewsListConfig +import com.tangem.features.feed.model.news.list.NewsListModel +import com.tangem.features.feed.ui.news.list.NewsListContent +import kotlinx.serialization.Serializable internal class DefaultNewsListComponent( appComponentContext: AppComponentContext, -) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + private val params: Params, +) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { + + private val newsListModel = getOrCreateModel(params = params) @Composable - override fun Title() { + override fun Title(bottomSheetState: State) { + val background = LocalMainBottomSheetColor.current.value + val state by newsListModel.state.collectAsStateWithLifecycle() + TangemTopAppBar( + containerColor = background, + title = stringResourceSafe(R.string.common_news), + startButton = TopAppBarButtonUM.Icon( + iconRes = R.drawable.ic_back_24, + onClicked = state.onBackClick, + isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED, + ), + ) } @Composable - override fun Content(modifier: Modifier) { + override fun Content(bottomSheetState: State, modifier: Modifier) { + val state by newsListModel.state.collectAsStateWithLifecycle() + NewsListContent( + state = state, + modifier = modifier, + ) } - @Composable - override fun Footer() { - } + @Serializable + data class Params( + val onArticleClicked: ( + currentArticle: Int, + prefetchedArticles: List, + paginationConfig: NewsListConfig?, + ) -> Unit, + val onBackClick: () -> Unit, + ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDetailsDeepLinkHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDetailsDeepLinkHandler.kt new file mode 100644 index 0000000000..8409d68360 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDetailsDeepLinkHandler.kt @@ -0,0 +1,61 @@ +package com.tangem.features.feed.deeplink + +import android.net.Uri +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +internal class DefaultNewsDetailsDeepLinkHandler @AssistedInject constructor( + @Assisted private val scope: CoroutineScope, + @Assisted private val deeplinkUri: Uri, + private val appRouter: AppRouter, +) : NewsDetailsDeepLinkHandler { + + init { + handleDeepLink() + } + + private fun handleDeepLink() { + scope.launch { + val articleId = extractArticleIdFromUri(deeplinkUri) + if (articleId == null) { + Timber.e( + """ + Failed to extract article ID from deep link + |- Received URI: $deeplinkUri + """.trimIndent(), + ) + return@launch + } + + appRouter.push( + AppRoute.NewsDetails(newsId = articleId), + ) + } + } + + /** + * Parsing URI with format https://tangem.com/news/{category}/{id}-{slug} to get id + */ + private fun extractArticleIdFromUri(uri: Uri): Int? { + val path = uri.path ?: return null + val pathSegments = path.split("/").filter { it.isNotBlank() } + if (pathSegments.isEmpty() || pathSegments[0] != "news" || pathSegments.size < 2) { + return null + } + val lastSegment = pathSegments.last() + val idPart = lastSegment.substringBefore("-") + return idPart.toIntOrNull() + } + + @AssistedFactory + interface Factory : NewsDetailsDeepLinkHandler.Factory { + override fun create(coroutineScope: CoroutineScope, deeplinkUri: Uri): DefaultNewsDetailsDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt new file mode 100644 index 0000000000..d00b30cc79 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.feed.deeplink.di + +import com.tangem.features.feed.deeplink.DefaultNewsDetailsDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface FeedDeepLinkModule { + + @Binds + @Singleton + fun bindNewsDetailsDeepLinkHandlerFactory( + impl: DefaultNewsDetailsDeepLinkHandler.Factory, + ): NewsDetailsDeepLinkHandler.Factory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt index f00b75a4b1..6feb4b7bfc 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt @@ -2,7 +2,12 @@ package com.tangem.features.feed.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model +import com.tangem.features.feed.model.FeedEntryModel import com.tangem.features.feed.model.feed.FeedComponentModel +import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel +import com.tangem.features.feed.model.market.list.MarketsListModel +import com.tangem.features.feed.model.news.details.NewsDetailsModel +import com.tangem.features.feed.model.news.list.NewsListModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -17,4 +22,29 @@ 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 + + @Binds + @IntoMap + @ClassKey(NewsDetailsModel::class) + fun provideNewsDetailsModel(model: NewsDetailsModel): Model + + @Binds + @IntoMap + @ClassKey(NewsListModel::class) + fun provideNewsListModel(model: NewsListModel): Model + + @Binds + @IntoMap + @ClassKey(FeedEntryModel::class) + fun provideFeedEntryModel(model: FeedEntryModel): Model } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/FeedEntryModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/FeedEntryModel.kt new file mode 100644 index 0000000000..62db5f2cf8 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/FeedEntryModel.kt @@ -0,0 +1,35 @@ +package com.tangem.features.feed.model + +import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.features.feed.model.market.list.analytics.MarketsListAnalyticsEvent +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +@Stable +@ModelScoped +internal class FeedEntryModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, +) : Model() { + + val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED) + + init { + observeContainerBottomSheet() + } + + private fun observeContainerBottomSheet() { + containerBottomSheetState.onEach { bottomSheetState -> + if (bottomSheetState == BottomSheetState.EXPANDED) { + analyticsEventHandler.send(MarketsListAnalyticsEvent.BottomSheetOpened()) + } + }.launchIn(modelScope) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt new file mode 100644 index 0000000000..0cf83cd01f --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt @@ -0,0 +1,103 @@ +package com.tangem.features.feed.model.converter + +import com.tangem.pagination.Batch +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext + +internal class BatchListStateManager( + private val converter: BatchItemConverter, + private val dispatchers: CoroutineDispatcherProvider, +) { + val state = MutableStateFlow( + BatchListState( + uiBatches = emptyList(), + processedItems = emptyList(), + ), + ) + + suspend fun update(newList: List>>, forceUpdate: Boolean) = + withContext(dispatchers.default) { + state.update { currentState -> + val uiBatches = currentState.uiBatches + val previousList = currentState.processedItems + + if (newList.isEmpty()) { + return@update BatchListState(uiBatches = emptyList(), processedItems = emptyList()) + } + + val isInitialLoading = forceUpdate || + previousList.isEmpty() || + newList.firstOrNull()?.key != previousList.firstOrNull()?.key + + val outItems = if (isInitialLoading) { + newList.map { batch -> + Batch(key = batch.key, data = batch.data.map { converter.convert(it) }) + } + } else { + if (previousList.size != newList.size) { + val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) + val newBatches = newList.filter { keysToAdd.contains(it.key) } + + uiBatches + newBatches.map { batch -> + Batch(key = batch.key, data = batch.data.map { converter.convert(it) }) + } + } else { + uiBatches.mapIndexed { batchIndex, batch -> + val prevBatch = previousList[batchIndex] + val newBatch = newList[batchIndex] + if (prevBatch == newBatch) return@mapIndexed batch + + Batch( + key = batch.key, + data = batch.data.mapIndexed { index, currentUiItem -> + val prevItem = prevBatch.data.getOrNull(index) + val newItem = newBatch.data.getOrNull(index) + + if (prevItem != null && newItem != null) { + converter.update(prevItem, currentUiItem, newItem) + } else if (newItem != null) { + converter.convert(newItem) + } else { + currentUiItem + } + }, + ) + } + } + } + + coroutineContext.ensureActive() + + BatchListState( + uiBatches = outItems, + processedItems = newList, + ) + } + } +} + +internal data class BatchListState( + val uiBatches: List>>, + val processedItems: List>>, +) + +internal interface BatchItemConverter { + fun convert(item: Domain): UI + + fun update(prevDomain: Domain, currentUI: UI, newDomain: Domain): UI { + return convert(newDomain) + } +} + +internal fun Flow>>>.distinctBatchesContent(): Flow>>> { + return this.distinctUntilChanged { old, new -> + old.size == new.size && + old.map { it.key } == new.map { it.key } && + old.flatMap { it.data } == new.flatMap { it.data } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/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/converter/ShortArticleToArticleConfigUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt new file mode 100644 index 0000000000..245f6b84c7 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt @@ -0,0 +1,52 @@ +package com.tangem.features.feed.model.converter + +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.news.ShortArticle +import com.tangem.features.feed.ui.utils.mapFormattedDate +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toPersistentSet + +internal class ShortArticleToArticleConfigUMConverter( + private val isTrending: Provider, +) : Converter, ImmutableList> { + + override fun convert(value: List): ImmutableList { + return value.map { shortArticle -> + ArticleConfigUM( + id = shortArticle.id, + title = shortArticle.title, + score = shortArticle.score, + isTrending = isTrending(), + tags = buildArticleTags(shortArticle), + createdAt = mapFormattedDate(shortArticle.createdAt), + isViewed = shortArticle.viewed, + ) + }.toPersistentList() + } + + private fun buildArticleTags(article: ShortArticle): ImmutableSet { + val categoryLabels = article.categories.map { category -> + LabelUM(text = TextReference.Str(category.name)) + } + val tokenLabels = article.relatedTokens.map { token -> + LabelUM( + text = TextReference.Str(token.symbol), + leadingContent = LabelLeadingContentUM.Token( + iconUrl = getTokenIconUrlFromDefaultHost( + tokenId = CryptoCurrency.RawID(token.id), + ), + ), + ) + } + return (categoryLabels + tokenLabels).toPersistentSet() + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 6b7fb68da4..29cf1833dd 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -2,10 +2,12 @@ package com.tangem.features.feed.model.feed import androidx.compose.runtime.Stable import arrow.core.getOrElse +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -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 @@ -17,11 +19,16 @@ import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.impl.R +import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent +import com.tangem.features.feed.model.feed.state.FeedMarketsBatchFlowManager +import com.tangem.features.feed.model.feed.state.FeedStateController +import com.tangem.features.feed.model.feed.state.transformers.UpdateGlobalFeedStateTransformer +import com.tangem.features.feed.model.feed.state.transformers.UpdateMarketChartsTransformer +import com.tangem.features.feed.model.feed.state.transformers.UpdateTrendingNewsStateTransformer +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.feed.state.* -import com.tangem.features.feed.ui.market.state.SortByTypeUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentHashMap import kotlinx.coroutines.Job @@ -34,13 +41,16 @@ import javax.inject.Inject @Stable @ModelScoped +@Suppress("LongParameterList") internal class FeedComponentModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val fetchTrendingNewsUseCase: FetchTrendingNewsUseCase, private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, paramsContainer: ParamsContainer, + private val stateController: FeedStateController, ) : Model() { private val params = paramsContainer.require() @@ -63,25 +73,12 @@ 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 }, - onStateUpdate = { newState -> state.update { newState } }, - ) - } + get() = stateController.uiState val isVisibleOnScreen = MutableStateFlow(false) init { + initializeState() updateCallbacks() modelScope.launch(dispatchers.default) { @@ -95,17 +92,50 @@ internal class FeedComponentModel @Inject constructor( flow3 = marketsBatchFlowManager.errorStatesByOrder, flow4 = manageTrendingNewsUseCase.observeTrendingNews(), ) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult -> - updateMarketCharts(itemsByOrder, loadingStatesByOrder, errorStatesByOrder) - trendingNewsStateFactory.updateTrendingNewsState( - result = trendingNewsResult, + val globalStateTransformer = UpdateGlobalFeedStateTransformer( + loadingStatesByOrder = loadingStatesByOrder, + errorStatesByOrder = errorStatesByOrder, + trendingNewsResult = trendingNewsResult, onRetryClicked = { modelScope.launch(dispatchers.default) { + stateController.update { currentState -> + currentState.copy(globalState = GlobalFeedState.Loading) + } fetchTrendingNewsUseCase.invoke() + marketsBatchFlowManager.reloadAll() } }, + analyticsEventHandler = analyticsEventHandler, ) - updateGlobalState() - val currentSortType = state.value.marketChartConfig.currentSortByType + + val currentState = stateController.value + val newGlobalState = globalStateTransformer.transform(currentState) + val shouldSkipIndividualStates = newGlobalState.globalState !is GlobalFeedState.Content + + stateController.update(globalStateTransformer) + + if (!shouldSkipIndividualStates) { + stateController.updateAll( + UpdateMarketChartsTransformer( + itemsByOrder = itemsByOrder, + loadingStatesByOrder = loadingStatesByOrder, + errorStatesByOrder = errorStatesByOrder, + onReloadAll = { marketsBatchFlowManager.reloadAll() }, + analyticsEventHandler = analyticsEventHandler, + ), + UpdateTrendingNewsStateTransformer( + result = trendingNewsResult, + onRetryClicked = { + modelScope.launch(dispatchers.default) { + fetchTrendingNewsUseCase.invoke() + } + }, + analyticsEventHandler = analyticsEventHandler, + ), + ) + } + + val currentSortType = stateController.value.marketChartConfig.currentSortByType val items = itemsByOrder[currentSortType] val isLoading = loadingStatesByOrder[currentSortType] == true if (items != null && items.isNotEmpty() && !isLoading) { @@ -133,15 +163,19 @@ internal class FeedComponentModel @Inject constructor( } } + private fun initializeState() { + stateController.update { initialState() } + } + 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 = { + analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked()) + params.feedClickIntents.onMarketOpenClick(null) + }, ), feedListCallbacks = FeedListCallbacks( onSearchClick = {}, @@ -150,8 +184,18 @@ internal class FeedComponentModel @Inject constructor( onOpenAllNews = {}, onMarketItemClick = {}, onSortTypeClick = {}, + onSliderScroll = { + analyticsEventHandler.send(FeedAnalyticsEvent.NewsCarouselScrolled()) + }, + onSliderEndReached = { + analyticsEventHandler.send(FeedAnalyticsEvent.NewsCarouselEndReached()) + }, + ), + news = NewsUM( + content = persistentListOf(), + onRetryClicked = {}, + newsUMState = NewsUMState.LOADING, ), - news = NewsUM.Loading, trendingArticle = null, marketChartConfig = MarketChartConfig( marketCharts = buildMap { @@ -159,7 +203,7 @@ internal class FeedComponentModel @Inject constructor( put(it, MarketChartUM.Loading) } }.toPersistentHashMap(), - currentSortByType = SortByTypeUM.Trending, + currentSortByType = SortByTypeUM.TopGainers, ), globalState = GlobalFeedState.Loading, ) @@ -170,101 +214,8 @@ internal class FeedComponentModel @Inject constructor( return DateTimeFormatters.formatDate(formatter = DateTimeFormatters.dateDMMM, date = localDate) } - private fun updateMarketCharts( - itemsByOrder: Map>, - loadingStatesByOrder: Map, - errorStatesByOrder: Map, - ) { - state.update { currentState -> - val newMarketCharts = buildMap { - SortByTypeUM.entries.forEach { sortByType -> - val items = itemsByOrder[sortByType] ?: persistentListOf() - val isLoading = loadingStatesByOrder[sortByType] == true - val hasError = errorStatesByOrder[sortByType] == true - - when { - hasError -> { - put( - sortByType, - MarketChartUM.LoadingError( - onRetryClicked = { - marketsBatchFlowManager.reloadAll() - }, - ), - ) - } - isLoading -> { - put(sortByType, MarketChartUM.Loading) - } - items.isEmpty() -> { - put( - sortByType, - MarketChartUM.LoadingError( - onRetryClicked = { - marketsBatchFlowManager.reloadAll() - }, - ), - ) - } - else -> { - put( - sortByType, - MarketChartUM.Content( - items = items, - sortChartConfig = SortChartConfigUM( - sortByType = sortByType, - isSelected = sortByType == currentState.marketChartConfig.currentSortByType, - ), - ), - ) - } - } - } - }.toPersistentHashMap() - - currentState.copy( - marketChartConfig = currentState.marketChartConfig.copy( - marketCharts = newMarketCharts, - ), - ) - } - } - - private fun updateGlobalState() { - state.update { currentState -> - val newsState = currentState.news - val marketCharts = currentState.marketChartConfig.marketCharts - - val isNewsLoading = newsState is NewsUM.Loading - val areAllChartsLoading = marketCharts.values.all { it is MarketChartUM.Loading } - - val isNewsError = newsState is NewsUM.Error - val areAllChartsError = marketCharts.values.all { it is MarketChartUM.LoadingError } - - val newGlobalState = when { - isNewsLoading && areAllChartsLoading -> GlobalFeedState.Loading - isNewsError && areAllChartsError -> GlobalFeedState.Error( - onRetryClicked = { - modelScope.launch(dispatchers.default) { - fetchTrendingNewsUseCase.invoke() - marketsBatchFlowManager.reloadAll() - } - }, - ) - else -> GlobalFeedState.Content - } - - val currentGlobalState = currentState.globalState - if (currentGlobalState::class != newGlobalState::class) { - currentState.copy(globalState = newGlobalState) - } else { - currentState - } - } - } - - private fun onSortTypeClick(sortByType: SortByTypeUM) { - state.update { currentState -> + private fun handleSortTypeClicked(sortByType: SortByTypeUM) { + stateController.update { currentState -> val updatedCharts = currentState.marketChartConfig.marketCharts.mapValues { (chartSortType, chart) -> when (chart) { is MarketChartUM.Content -> { @@ -302,34 +253,87 @@ internal class FeedComponentModel @Inject constructor( } private fun updateCallbacks() { - state.update { feedListUM -> + stateController.update { feedListUM -> feedListUM.copy( - searchBar = state.value.searchBar.copy(onQueryChange = searchBarStateFactory::onSearchQueryChange), feedListCallbacks = feedListUM.feedListCallbacks.copy( - onSortTypeClick = ::onSortTypeClick, - onMarketItemClick = { item -> - val tokenMarket = marketsBatchFlowManager.getTokenMarketById(item.id) - if (tokenMarket != null) { - params.feedClickIntents.onMarketItemClick( - token = tokenMarket.toSerializableParam(), - appCurrency = currentAppCurrency.value, - ) - } - }, - onMarketOpenClick = { sortBy -> - params.feedClickIntents.onMarketOpenClick(sortBy) - }, - onArticleClick = { articleId -> - params.feedClickIntents.onArticleClick(articleId) - }, - onOpenAllNews = { - params.feedClickIntents.onOpenAllNews() - }, + onSortTypeClick = ::handleSortTypeClicked, + onMarketItemClick = ::handleMarketItemClicked, + onMarketOpenClick = ::handleMarketOpenClicked, + onArticleClick = ::handleArticleClicked, + onOpenAllNews = ::handleOpenAllNews, ), ) } } + private fun handleMarketItemClicked(item: MarketsListItemUM) { + val tokenMarket = marketsBatchFlowManager.getTokenMarketById(item.id) + if (tokenMarket != null) { + params.feedClickIntents.onMarketItemClick( + token = tokenMarket.toSerializableParam(), + appCurrency = currentAppCurrency.value, + source = AnalyticsParam.ScreensSources.Market.value, + ) + } + } + + private fun handleMarketOpenClicked(sortBy: SortByTypeUM) { + when (sortBy) { + SortByTypeUM.Rating -> { + analyticsEventHandler.send( + FeedAnalyticsEvent.TokenListOpened( + AnalyticsParam.ScreensSources.Markets, + ), + ) + } + SortByTypeUM.Trending, + SortByTypeUM.ExperiencedBuyers, + SortByTypeUM.TopGainers, + SortByTypeUM.TopLosers, + SortByTypeUM.Staking, + SortByTypeUM.YieldSupply, + -> { + analyticsEventHandler.send( + FeedAnalyticsEvent.TokenListOpened( + AnalyticsParam.ScreensSources.MarketPulse, + ), + ) + } + } + params.feedClickIntents.onMarketOpenClick(sortBy) + } + + private fun handleArticleClicked(articleId: Int) { + val currentState = stateController.value + val trendingArticleId = currentState.trendingArticle?.id + if (articleId == trendingArticleId) { + analyticsEventHandler.send( + event = FeedAnalyticsEvent.NewsCarouselTrendingClicked(newsId = articleId), + ) + } + params.feedClickIntents.onArticleClick( + articleId = articleId, + preselectedArticlesId = listOfNotNull(trendingArticleId) + + when (currentState.news.newsUMState) { + NewsUMState.CONTENT -> currentState.news.content.map { it.id } + NewsUMState.LOADING, + NewsUMState.ERROR, + -> emptyList() + }, + paginationConfig = null, + screenSource = AnalyticsParam.ScreensSources.Markets, + ) + } + + private fun handleOpenAllNews(isFromCarouselButton: Boolean) { + if (isFromCarouselButton) { + analyticsEventHandler.send(FeedAnalyticsEvent.NewsCarouselAllNewsButton()) + } else { + analyticsEventHandler.send(FeedAnalyticsEvent.NewsListOpened()) + } + params.feedClickIntents.onOpenAllNews() + } + private fun SortByTypeUM.toOrder(): TokenMarketListConfig.Order { return when (this) { SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt index 7e518f9c26..d3ade33796 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt @@ -1,15 +1,30 @@ package com.tangem.features.feed.model.feed +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams -import com.tangem.features.feed.ui.market.state.SortByTypeUM +import com.tangem.domain.news.model.NewsListConfig +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 onArticleClick(articleId: Int) + fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency, source: String, newsId: Int? = null) + fun onMarketOpenClick(sortBy: SortByTypeUM?) + + /** + * Method to open article of news from different places. + * @param articleId - id of article, which should be opened. + * @param preselectedArticlesId - ids of articles, which were prefetched before opening news details. + * @param screenSource - source of screen, from which article was opened. + * @param paginationConfig - config of current pagination from news list. Must be null, when open from other places. + */ + fun onArticleClick( + articleId: Int, + preselectedArticlesId: List = emptyList(), + screenSource: AnalyticsParam.ScreensSources, + paginationConfig: NewsListConfig? = null, + ) fun onOpenAllNews() } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt new file mode 100644 index 0000000000..7c9792e701 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/analytics/FeedAnalyticsEvent.kt @@ -0,0 +1,93 @@ +package com.tangem.features.feed.model.feed.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE +import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE +import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR +import com.tangem.core.analytics.models.OneTimePerSessionEvent + +internal sealed class FeedAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Markets", event = event, params = params) { + + data class TokenListOpened( + private val screensSources: AnalyticsParam.ScreensSources, + ) : FeedAnalyticsEvent( + event = "Token List Opened", + params = mapOf( + SOURCE to screensSources.value, + ), + ) + + class NewsListOpened : FeedAnalyticsEvent( + event = "News List Opened", + params = mapOf( + SOURCE to AnalyticsParam.ScreensSources.Markets.value, + ), + ) + + data class NewsCarouselTrendingClicked(private val newsId: Int) : FeedAnalyticsEvent( + event = "News Carousel Trending Clicked", + params = mapOf( + "News id" to newsId.toString(), + ), + ), OneTimePerSessionEvent { + override val oneTimeEventId: String = event + } + + class NewsCarouselAllNewsButton : FeedAnalyticsEvent( + event = "News Carousel All News button", + ), OneTimePerSessionEvent { + override val oneTimeEventId: String = event + } + + class NewsCarouselEndReached : FeedAnalyticsEvent( + event = "News Carousel End Reached", + ), OneTimePerSessionEvent { + override val oneTimeEventId: String = event + } + + class NewsCarouselScrolled : FeedAnalyticsEvent( + event = "News Carousel Scrolled", + ), OneTimePerSessionEvent { + override val oneTimeEventId: String = event + } + + data class MarketsLoadError( + private val code: Int?, + private val message: String, + ) : FeedAnalyticsEvent( + event = "Markets Load Error", + params = mapOf( + ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(), + ERROR_MESSAGE to message, + ), + ) + + data class NewsLoadError( + private val code: Int?, + private val message: String, + ) : FeedAnalyticsEvent( + event = "News Load Error", + params = mapOf( + ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(), + ERROR_MESSAGE to message, + ), + ) + + data class AllWidgetsLoadError( + private val code: Int?, + private val message: String, + ) : FeedAnalyticsEvent( + event = "All Widgets Load Error", + params = mapOf( + ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(), + ERROR_MESSAGE to message, + ), + ) + + class TokenSearchedClicked : FeedAnalyticsEvent(event = "Token Searched Clicked") +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt similarity index 65% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt index b053465040..5f229e09b8 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt @@ -1,13 +1,12 @@ -package com.tangem.features.feed.ui.feed.state +package com.tangem.features.feed.model.feed.state import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.features.feed.model.converter.MarketsTokenItemConverter -import com.tangem.features.feed.ui.market.state.MarketsListUM -import com.tangem.features.feed.ui.market.state.SortByTypeUM -import com.tangem.pagination.Batch +import com.tangem.features.feed.model.converter.* +import com.tangem.features.feed.model.market.list.state.MarketsListUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.pagination.BatchAction import com.tangem.pagination.BatchFetchResult import com.tangem.pagination.PaginationStatus @@ -18,8 +17,11 @@ import com.tangem.utils.coroutines.saveIn import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly +import kotlinx.coroutines.launch @Suppress("LongParameterList") internal class FeedMarketsBatchFlowManager( @@ -28,7 +30,13 @@ internal class FeedMarketsBatchFlowManager( private val modelScope: CoroutineScope, private val dispatchers: CoroutineDispatcherProvider, ) { - private val managersByOrder = TokenMarketListConfig.Order.entries.associateWith { order -> + private val managersByOrder = listOf( + TokenMarketListConfig.Order.ByRating, + TokenMarketListConfig.Order.Trending, + TokenMarketListConfig.Order.Buyers, + TokenMarketListConfig.Order.TopGainers, + TokenMarketListConfig.Order.TopLosers, + ).associateWith { order -> createManagerForOrder(order) } @@ -44,7 +52,7 @@ internal class FeedMarketsBatchFlowManager( } }.stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = emptyMap(), ) @@ -60,23 +68,23 @@ internal class FeedMarketsBatchFlowManager( } }.stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = emptyMap(), ) - val errorStatesByOrder: StateFlow> = + val errorStatesByOrder: StateFlow> = combine( TokenMarketListConfig.Order.entries.mapNotNull { order -> - managersByOrder[order]?.hasError?.map { hasError -> order to hasError } + managersByOrder[order]?.error?.map { error -> order to error } }, ) { errorStatesList -> - errorStatesList.associate { (order, hasError) -> + errorStatesList.associate { (order, error) -> val sortByType = order.toSortByTypeUM() - sortByType to hasError + sortByType to error } }.stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = emptyMap(), ) @@ -141,11 +149,29 @@ internal class FeedMarketsBatchFlowManager( private val dispatchers: CoroutineDispatcherProvider, ) { private val updateStateJob = JobHolder() - private val resultBatches = MutableStateFlow(ResultBatches()) - private val uiBatches = resultBatches.map { it.uiBatches } + + private val batchConverter = object : BatchItemConverter { + + private val internalConverter: MarketsTokenItemConverter + get() = MarketsTokenItemConverter( + currentTrendInterval = MarketsListUM.TrendInterval.H24, + appCurrency = currentAppCurrency(), + ) + + override fun convert(item: TokenMarket) = internalConverter.convert(item) + + override fun update(prevDomain: TokenMarket, currentUI: MarketsListItemUM, newDomain: TokenMarket) = + internalConverter.update(prevDomain, currentUI, newDomain) + } + + private val stateManager = BatchListStateManager( + converter = batchConverter, + dispatchers = dispatchers, + ) val uiItems: StateFlow> = - uiBatches + stateManager.state + .map { it.uiBatches } .map { batches -> batches.asSequence() .map { it.data } @@ -155,7 +181,7 @@ internal class FeedMarketsBatchFlowManager( .distinctUntilChanged() .stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = persistentListOf(), ) @@ -170,23 +196,23 @@ internal class FeedMarketsBatchFlowManager( .distinctUntilChanged() .stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = false, ) - val hasError = batchFlow.state + val error = batchFlow.state .map { state -> when (val status = state.status) { - is PaginationStatus.InitialLoadingError -> true - is PaginationStatus.Paginating -> status.lastResult is BatchFetchResult.Error - else -> false + is PaginationStatus.InitialLoadingError -> status.throwable + is PaginationStatus.Paginating -> (status.lastResult as? BatchFetchResult.Error)?.throwable + else -> null } } .distinctUntilChanged() .stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, - initialValue = false, + started = Eagerly, + initialValue = null, ) val onLastBatchLoadedSuccess = batchFlow.state @@ -210,15 +236,11 @@ internal class FeedMarketsBatchFlowManager( init { batchFlow.state .map { it.data } - .distinctUntilChanged { a, b -> - a.size == b.size && - a.map { it.key } == b.map { it.key } && - a.map { it.data }.flatten() == b.map { it.data }.flatten() - } - .onEach { + .distinctBatchesContent() + .onEach { newList -> coroutineScope { launch { - updateState(it) + stateManager.update(newList = newList, forceUpdate = false) }.saveIn(updateStateJob) } } @@ -226,77 +248,12 @@ internal class FeedMarketsBatchFlowManager( .launchIn(modelScope) } - private suspend fun updateState(newList: List>>, forceUpdate: Boolean = false) = - withContext(dispatchers.default) { - resultBatches.update { resultBatches -> - val items = resultBatches.uiBatches - val previousList = resultBatches.processedItems - - val converter = MarketsTokenItemConverter( - currentTrendInterval = MarketsListUM.TrendInterval.H24, - appCurrency = currentAppCurrency(), - ) - - if (newList.isEmpty()) { - return@update ResultBatches(processedItems = emptyList()) - } - - val isInitialLoading = - forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key - - val outItems = if (isInitialLoading) { - newList.map { batch -> - Batch( - key = batch.key, - data = converter.convertList(batch.data), - ) - } - } else { - // As nextBatchSize = 0, we only have one batch, but keep the logic for safety - if (previousList.size != newList.size) { - val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) - val newBatches = newList.filter { keysToAdd.contains(it.key) } - - items + newBatches.map { batch -> - Batch( - key = batch.key, - data = converter.convertList(batch.data), - ) - } - } else { - items.mapIndexed { batchIndex, batch -> - val prevBatch = previousList[batchIndex] - val newBatch = newList[batchIndex] - if (prevBatch == newBatch) return@mapIndexed batch - - Batch( - key = batch.key, - data = batch.data.mapIndexed { index, marketsListItemUM -> - val prevItem = prevBatch.data.getOrNull(index) - val newItem = newBatch.data.getOrNull(index) - if (prevItem != null && newItem != null) { - converter.update(prevItem, marketsListItemUM, newItem) - } else { - newItem?.let { converter.convert(it) } ?: marketsListItemUM - } - }, - ) - } - } - } - - currentCoroutineContext().ensureActive() - - ResultBatches( - uiBatches = outItems, - processedItems = newList, - ) - } - } - fun reload(fiatPriceCurrency: String) { modelScope.launch(dispatchers.default) { - resultBatches.value = ResultBatches() + stateManager.state.value = BatchListState( + processedItems = emptyList(), + uiBatches = emptyList(), + ) actionsFlow.emit( BatchAction.Reload( requestParams = TokenMarketListConfig( @@ -366,16 +323,11 @@ internal class FeedMarketsBatchFlowManager( } fun getTokenMarketById(tokenId: CryptoCurrency.RawID): TokenMarket? { - return resultBatches.value.processedItems - ?.asSequence() - ?.flatMap { it.data } - ?.firstOrNull { it.id == tokenId } + return stateManager.state.value.processedItems + .asSequence() + .flatMap { it.data } + .firstOrNull { it.id == tokenId } } - - private data class ResultBatches( - val uiBatches: List>> = emptyList(), - val processedItems: List>>? = null, - ) } private fun TokenMarketListConfig.Order.toSortByTypeUM(): SortByTypeUM { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt new file mode 100644 index 0000000000..161b83222b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt @@ -0,0 +1,70 @@ +package com.tangem.features.feed.model.feed.state + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.features.feed.model.feed.state.transformers.FeedListUMTransformer +import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.ui.feed.state.* +import kotlinx.collections.immutable.persistentHashMapOf +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class FeedStateController @Inject constructor() { + + private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) + + val uiState: StateFlow get() = mutableUiState.asStateFlow() + + val value: FeedListUM get() = uiState.value + + fun update(function: (FeedListUM) -> FeedListUM) { + mutableUiState.update(function = function) + } + + fun update(transformer: FeedListUMTransformer) { + mutableUiState.update(function = transformer::transform) + } + + fun updateAll(vararg transformers: FeedListUMTransformer) { + transformers.forEach { mutableUiState.update(function = it::transform) } + } + + fun clear() { + mutableUiState.update { getInitialState() } + } + + private fun getInitialState(): FeedListUM { + return FeedListUM( + currentDate = "", + feedListSearchBar = FeedListSearchBar( + placeholderText = com.tangem.core.ui.extensions.TextReference.EMPTY, + onBarClick = {}, + ), + feedListCallbacks = FeedListCallbacks( + onSearchClick = {}, + onMarketOpenClick = {}, + onArticleClick = {}, + onOpenAllNews = {}, + onMarketItemClick = {}, + onSortTypeClick = {}, + onSliderScroll = {}, + onSliderEndReached = {}, + ), + news = NewsUM( + content = persistentListOf(), + onRetryClicked = {}, + newsUMState = NewsUMState.LOADING, + ), + trendingArticle = null, + marketChartConfig = MarketChartConfig( + marketCharts = persistentHashMapOf(), + currentSortByType = SortByTypeUM.Trending, + ), + globalState = GlobalFeedState.Loading, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/FeedListUMTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/FeedListUMTransformer.kt new file mode 100644 index 0000000000..953e0af7d6 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/FeedListUMTransformer.kt @@ -0,0 +1,7 @@ +package com.tangem.features.feed.model.feed.state.transformers + +import com.tangem.features.feed.ui.feed.state.FeedListUM + +internal interface FeedListUMTransformer { + fun transform(prevState: FeedListUM): FeedListUM +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt new file mode 100644 index 0000000000..ee8c1226fe --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt @@ -0,0 +1,76 @@ +package com.tangem.features.feed.model.feed.state.transformers + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.domain.models.news.TrendingNews +import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent +import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.ui.feed.state.FeedListUM +import com.tangem.features.feed.ui.feed.state.GlobalFeedState +import com.tangem.features.feed.ui.feed.state.NewsUMState + +internal class UpdateGlobalFeedStateTransformer( + private val loadingStatesByOrder: Map, + private val errorStatesByOrder: Map, + private val trendingNewsResult: TrendingNews, + private val onRetryClicked: () -> Unit, + private val analyticsEventHandler: AnalyticsEventHandler, +) : FeedListUMTransformer { + + @Suppress("CyclomaticComplexMethod") + override fun transform(prevState: FeedListUM): FeedListUM { + val previousGlobalState = prevState.globalState + + val isNewsLoading = prevState.news.newsUMState == NewsUMState.LOADING + val isNewsError = trendingNewsResult is TrendingNews.Error || + prevState.news.newsUMState == NewsUMState.ERROR + + val hasLoadingInCharts = loadingStatesByOrder.values.any { it } + val hasErrorInCharts = errorStatesByOrder.values.any { it != null } + val hasAllErrorInCharts = errorStatesByOrder.values.isNotEmpty() && errorStatesByOrder.values.all { it != null } + + val areAllChartsLoading = loadingStatesByOrder.values.isNotEmpty() && loadingStatesByOrder.values.all { it } + val areAllChartsError = hasAllErrorInCharts || hasErrorInCharts && hasLoadingInCharts + + val newGlobalState = when { + isNewsLoading && areAllChartsError -> GlobalFeedState.Loading + isNewsLoading && areAllChartsLoading -> GlobalFeedState.Loading + isNewsError && areAllChartsLoading -> GlobalFeedState.Loading + isNewsError && areAllChartsError -> { + if (previousGlobalState !is GlobalFeedState.Error) { + sendErrorAnalytics(errorStatesByOrder) + } + GlobalFeedState.Error( + onRetryClicked = onRetryClicked, + ) + } + else -> GlobalFeedState.Content + } + + return prevState.copy( + globalState = newGlobalState, + news = prevState.news.copy( + newsUMState = when { + isNewsError -> NewsUMState.ERROR + else -> NewsUMState.CONTENT + }, + ), + ) + } + + private fun sendErrorAnalytics(errorStatesByOrder: Map) { + val firstError: Throwable? = errorStatesByOrder.values.firstNotNullOfOrNull { it } + firstError?.let { error -> + val (code, message) = when (error) { + is ApiResponseError.HttpException -> error.code.numericCode to error.message + else -> null to "" + } + analyticsEventHandler.send( + FeedAnalyticsEvent.AllWidgetsLoadError( + code = code, + message = message.orEmpty(), + ), + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateMarketChartsTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateMarketChartsTransformer.kt new file mode 100644 index 0000000000..d556835e7a --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateMarketChartsTransformer.kt @@ -0,0 +1,102 @@ +package com.tangem.features.feed.model.feed.state.transformers + +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent +import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.ui.feed.state.FeedListUM +import com.tangem.features.feed.ui.feed.state.MarketChartUM +import com.tangem.features.feed.ui.feed.state.SortChartConfigUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentHashMap + +internal class UpdateMarketChartsTransformer( + private val itemsByOrder: Map>, + private val loadingStatesByOrder: Map, + private val errorStatesByOrder: Map, + private val onReloadAll: () -> Unit, + private val analyticsEventHandler: AnalyticsEventHandler, +) : FeedListUMTransformer { + + override fun transform(prevState: FeedListUM): FeedListUM { + val newMarketCharts = buildMap { + SortByTypeUM.entries.forEach { sortByType -> + val items = itemsByOrder[sortByType] ?: persistentListOf() + val isLoading = loadingStatesByOrder[sortByType] == true + val hasError = errorStatesByOrder[sortByType] != null + + when { + hasError -> { + put( + sortByType, + MarketChartUM.LoadingError( + onRetryClicked = onReloadAll, + ), + ) + } + isLoading -> { + put(sortByType, MarketChartUM.Loading) + } + items.isEmpty() -> { + put( + sortByType, + MarketChartUM.LoadingError( + onRetryClicked = onReloadAll, + ), + ) + } + else -> { + put( + sortByType, + MarketChartUM.Content( + items = items, + sortChartConfig = SortChartConfigUM( + sortByType = sortByType, + isSelected = sortByType == prevState.marketChartConfig.currentSortByType, + ), + ), + ) + } + } + } + }.toPersistentHashMap() + + val wasAnyErrorBefore = prevState.marketChartConfig.marketCharts.values.any { + it is MarketChartUM.LoadingError + } + val isAnyErrorNow = newMarketCharts.values.any { it is MarketChartUM.LoadingError } + + if (!wasAnyErrorBefore && isAnyErrorNow) { + sendErrorAnalytics(errorStatesByOrder, itemsByOrder) + } + + return prevState.copy( + marketChartConfig = prevState.marketChartConfig.copy( + marketCharts = newMarketCharts, + ), + ) + } + + private fun sendErrorAnalytics( + errorStatesByOrder: Map, + itemsByOrder: Map>, + ) { + val firstError = errorStatesByOrder.values.firstNotNullOfOrNull { it } + if (firstError == null && !itemsByOrder.values.any { it.isEmpty() }) { + return + } + val (code, message) = if (firstError is ApiResponseError.HttpException) { + firstError.code.numericCode to firstError.message.orEmpty() + } else { + null to "" + } + analyticsEventHandler.send( + FeedAnalyticsEvent.MarketsLoadError( + code = code, + message = message, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateTrendingNewsStateTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateTrendingNewsStateTransformer.kt new file mode 100644 index 0000000000..33c625fb7f --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateTrendingNewsStateTransformer.kt @@ -0,0 +1,97 @@ +package com.tangem.features.feed.model.feed.state.transformers + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.models.news.NewsError +import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.models.news.TrendingNews +import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter +import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent +import com.tangem.features.feed.ui.feed.state.FeedListUM +import com.tangem.features.feed.ui.feed.state.NewsUM +import com.tangem.features.feed.ui.feed.state.NewsUMState +import com.tangem.utils.Provider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import org.joda.time.DateTime + +internal class UpdateTrendingNewsStateTransformer( + private val result: TrendingNews, + private val onRetryClicked: () -> Unit, + private val analyticsEventHandler: AnalyticsEventHandler, +) : FeedListUMTransformer { + + override fun transform(prevState: FeedListUM): FeedListUM { + return when (result) { + is TrendingNews.Data -> handleDataState(prevState, result.articles) + is TrendingNews.Error -> { + val (code, message) = when (val error = result.error) { + is NewsError.HttpError -> error.code to error.message + NewsError.NotHttpError -> null to "" + } + analyticsEventHandler.send( + FeedAnalyticsEvent.NewsLoadError( + code = code, + message = message, + ), + ) + handleErrorState(prevState) + } + } + } + + private fun handleDataState(currentState: FeedListUM, articles: List): FeedListUM { + val (trendingArticle, commonArticles) = separateTrendingAndCommonArticles(articles) + val commonArticlesUM = getShortArticleConfigConverter(isTrending = false) + .convert(commonArticles) + .toPersistentList() + val updatedNews = when (currentState.news.newsUMState) { + NewsUMState.CONTENT -> currentState.news.copy(content = commonArticlesUM) + NewsUMState.LOADING, + NewsUMState.ERROR, + -> NewsUM( + content = commonArticlesUM, + onRetryClicked = onRetryClicked, + newsUMState = NewsUMState.CONTENT, + ) + } + + return currentState.copy( + trendingArticle = trendingArticle?.let { article -> + getShortArticleConfigConverter(isTrending = true) + .convert(listOf(article)) + }?.firstOrNull(), + news = updatedNews, + ) + } + + private fun handleErrorState(currentState: FeedListUM): FeedListUM { + return currentState.copy( + trendingArticle = null, + news = NewsUM( + content = persistentListOf(), + onRetryClicked = onRetryClicked, + newsUMState = NewsUMState.ERROR, + ), + ) + } + + private fun separateTrendingAndCommonArticles( + articles: List, + ): Pair> { + val trendingArticles = articles.filter { it.isTrending } + return if (trendingArticles.isNotEmpty()) { + val newestTrendingArticle = trendingArticles.maxByOrNull { article -> + runCatching { DateTime.parse(article.createdAt).millis }.getOrElse { 0L } + } + val commonArticles = articles.filterNot { it.isTrending || it == newestTrendingArticle } + + trendingArticles.filter { it != newestTrendingArticle } + newestTrendingArticle to commonArticles + } else { + null to articles + } + } + + private fun getShortArticleConfigConverter(isTrending: Boolean): ShortArticleToArticleConfigUMConverter { + return ShortArticleToArticleConfigUMConverter(isTrending = Provider { isTrending }) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt new file mode 100644 index 0000000000..f1c5bb2319 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -0,0 +1,728 @@ +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.datasource.api.common.response.ApiResponseError +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.markets.* +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.news.model.NewsListConfig +import com.tangem.domain.news.usecase.GetNewsUseCase +import com.tangem.domain.settings.usercountry.GetUserCountryUseCase +import com.tangem.domain.settings.usercountry.models.UserCountry +import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent +import com.tangem.features.feed.components.market.details.analytics.MarketTokenAnalyticsEvent +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent +import com.tangem.features.feed.model.market.details.converter.DescriptionConverter +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.model.converter.ShortArticleToArticleConfigUMConverter +import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent +import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +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, + private val getNewsUseCase: GetNewsUseCase, +) : 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 shortArticleToArticleConfigUMConverter by lazy { + ShortArticleToArticleConfigUMConverter(isTrending = Provider { false }) + } + + 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, + relatedNews = MarketsTokenDetailsUM.RelatedNews( + articles = persistentListOf(), + onArticledClicked = {}, + onFirstVisible = {}, + onScroll = {}, + ), + ), + ) + + 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() + loadRelatedNews() + } + + 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 loadRelatedNews() { + modelScope.launch(dispatchers.default) { + getNewsUseCase.getNews( + limit = RELATED_NEWS_LIMIT, + newsListConfig = NewsListConfig( + language = Locale.getDefault().language, + snapshot = null, + tokenIds = listOf(params.token.id.value), + ), + ).onRight { listOfArticles -> + listOfArticles.collect { articles -> + state.update { marketsTokenDetailsUM -> + val relatedNews = shortArticleToArticleConfigUMConverter.convert(articles) + marketsTokenDetailsUM.copy( + relatedNews = marketsTokenDetailsUM.relatedNews.copy( + articles = relatedNews, + onArticledClicked = { articledId -> + params.onArticleClick( + /* articledId */ articledId, + /* preselectedIds */ relatedNews.map { it.id }, + ) + }, + onFirstVisible = { + analyticsEventHandler.send( + MarketTokenAnalyticsEvent.TokenNewsViewed( + token = params.token.symbol, + ), + ) + }, + onScroll = { + analyticsEventHandler.send( + MarketTokenAnalyticsEvent.TokenNewsCarouselScrolled( + token = params.token.symbol, + ), + ) + }, + ), + ) + } + } + }.onLeft { throwable -> + val (code, message) = if (throwable is ApiResponseError.HttpException) { + throwable.code.numericCode to throwable.message.orEmpty() + } else { + null to "" + } + analyticsEventHandler.send( + MarketTokenAnalyticsEvent.TokenNewsLoadError( + code = code, + message = message, + ), + ) + } + } + } + + 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 + const val RELATED_NEWS_LIMIT = 10 + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/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..05b527c4ff --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/analytics/MarketDetailsAnalyticsEvent.kt @@ -0,0 +1,85 @@ +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, newsId: Int? = null) = MarketDetailsAnalyticsEvent( + event = "Token Chart Screen Opened", + params = buildMap { + put("Token", token.symbol) + blockchain?.let { put("blockchain", it) } + put("Source", source) + newsId?.let { put("News Id", it.toString()) } + }, + ) + + 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..2daaa70082 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt @@ -0,0 +1,336 @@ +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.state.ListUM +import com.tangem.features.feed.model.market.list.state.MarketsListUM +import com.tangem.features.feed.model.market.list.state.MarketsNotificationUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager +import com.tangem.features.feed.model.market.list.statemanager.MarketsListUMStateManager +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +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, + analyticsEventHandler = analyticsEventHandler, + ) + } + + 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(), + ).conflate(), + ) { 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(), + ).conflate(), + ) { 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 || marketsListUMStateManager.searchQuery.isNotEmpty()) { + 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) + } + } + + // 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() { + searchMarketsListManager + .isSearchNotFoundState + .onEach { isTokenFound -> + if (isTokenFound) { + analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(isTokenFound = false)) + } + }.launchIn(modelScope) + + searchMarketsListManager.onFirstBatchLoadedSuccess.onEach { + analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(isTokenFound = true)) + }.launchIn(modelScope) + + state + .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 83% 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..8415759005 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,20 @@ 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.query.isNotEmpty() enum class TrendInterval(val text: TextReference) { H24(resourceReference(R.string.markets_selector_interval_24h_title)), @@ -34,7 +32,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 +48,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..779f8af9e3 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt @@ -0,0 +1,325 @@ +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.BatchItemConverter +import com.tangem.features.feed.model.converter.BatchListStateManager +import com.tangem.features.feed.model.converter.MarketsTokenItemConverter +import com.tangem.features.feed.model.converter.distinctBatchesContent +import com.tangem.features.feed.model.market.list.state.MarketsListUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.model.market.list.utils.logAction +import com.tangem.features.feed.model.market.list.utils.logStatus +import com.tangem.features.feed.model.market.list.utils.logUpdateResults +import com.tangem.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 batchConverter = object : BatchItemConverter { + + private val internalConverter: MarketsTokenItemConverter + get() = MarketsTokenItemConverter( + currentTrendInterval = currentTrendInterval(), + appCurrency = currentAppCurrency(), + ) + + override fun convert(item: TokenMarket) = internalConverter.convert(item) + + override fun update(prevDomain: TokenMarket, currentUI: MarketsListItemUM, newDomain: TokenMarket) = + internalConverter.update(prevDomain, currentUI, newDomain) + } + + private val stateManager = BatchListStateManager( + converter = batchConverter, + dispatchers = dispatchers, + ) + + private val resultBatches = MutableStateFlow(ResultBatches()) + private val uiBatches = stateManager.state.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 } + .distinctBatchesContent() + .onEach { newList -> + coroutineScope { + launch { + stateManager.update(newList = newList, forceUpdate = false) + }.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) + } + } + + fun updateUIWithSameState() { + modelScope.launch(dispatchers.default) { + val current = batchFlow.state.value.data + stateManager.update(current, forceUpdate = true) + }.saveIn(updateStateJob) + } + + fun reload(searchText: String? = null) { + modelScope.launch { + 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 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..e503e85087 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt @@ -0,0 +1,290 @@ +package com.tangem.features.feed.model.market.list.statemanager + +import androidx.compose.runtime.Stable +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.event.consumedEvent +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.feed.analytics.FeedAnalyticsEvent +import com.tangem.features.feed.model.market.list.state.* +import com.tangem.utils.Provider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +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, + private val analyticsEventHandler: AnalyticsEventHandler, +) { + + 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 = { + analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked()) + 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/model/news/details/NewsDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt new file mode 100644 index 0000000000..91f9fa30d2 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt @@ -0,0 +1,276 @@ +package com.tangem.features.feed.model.news.details + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.navigation.share.ShareManager +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.GetTokenMarketInfoUseCase +import com.tangem.domain.markets.GetTokenPriceChartUseCase +import com.tangem.domain.models.news.OriginalArticle +import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase +import com.tangem.domain.news.usecase.MarkArticleAsViewedUseCase +import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase +import com.tangem.domain.news.usecase.ToggleArticleLikedUseCase +import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent +import com.tangem.features.feed.model.news.details.analytics.NewsDetailsAnalyticsEvent +import com.tangem.features.feed.model.news.details.converter.NewsDetailsConverter +import com.tangem.features.feed.model.news.details.factory.NewsDetailsIndexManager +import com.tangem.features.feed.model.news.details.factory.NewsDetailsStateFactory +import com.tangem.features.feed.model.news.details.loader.NewsRelatedTokensLoader +import com.tangem.features.feed.ui.news.details.state.ArticleUM +import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM +import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM +import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.Locale +import javax.inject.Inject + +@Stable +@ModelScoped +@Suppress("LongParameterList") +internal class NewsDetailsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase, + private val getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, + private val urlOpener: UrlOpener, + private val shareManager: ShareManager, + private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, + private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, + private val markArticleAsViewedUseCase: MarkArticleAsViewedUseCase, + private val toggleArticleLikedUseCase: ToggleArticleLikedUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, + paramsContainer: ParamsContainer, +) : Model() { + + private val params = paramsContainer.require() + private val currentLanguage = Locale.getDefault().language + + private val newsDetailsConverter = NewsDetailsConverter(onSourceClick = ::onOriginalArticleClick) + + private val paginationManager: NewsDetailsPaginationManager? = params.paginationConfig?.let { config -> + NewsDetailsPaginationManager( + getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, + currentLanguage = Provider { config.language }, + currentCategoryIds = Provider { config.categoryIds }, + modelScope = modelScope, + dispatchers = dispatchers, + observeNewsDetailsUseCase = observeNewsDetailsUseCase, + prefetchedIds = params.preselectedArticlesId.toSet(), + ) + } + + private val relatedTokensLoader by lazy { + NewsRelatedTokensLoader( + getTokenMarketInfoUseCase = getTokenMarketInfoUseCase, + getTokenPriceChartUseCase = getTokenPriceChartUseCase, + dispatchers = dispatchers, + maxCount = RELATED_TOKEN_MAX_COUNT, + ) + } + + private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .flowOn(dispatchers.default) + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + + private val _state = MutableStateFlow( + NewsDetailsUM( + articlesStateUM = ArticlesStateUM.Loading, + articles = persistentListOf(), + selectedArticleIndex = -1, + onShareClick = {}, + onLikeClick = ::onLikeClick, + onBackClick = params.onBackClicked, + onArticleIndexChanged = ::onArticleIndexChanged, + ), + ) + + private val stateFactory by lazy(LazyThreadSafetyMode.NONE) { + NewsDetailsStateFactory( + currentStateProvider = Provider { _state.value }, + shareManager = shareManager, + onStateUpdate = { newState -> _state.update { newState } }, + onRetryClick = ::onRetryClicked, + ) + } + + val state: StateFlow = _state.asStateFlow() + + init { + trackScreenOpened() + loadNews() + } + + private fun trackScreenOpened() { + analyticsEventHandler.send( + event = NewsDetailsAnalyticsEvent.NewsArticleOpened( + source = params.screenSource, + newsId = params.articleId, + ), + ) + } + + private fun onLikeClick(articleId: Int) { + modelScope.launch { + toggleArticleLikedUseCase + .toggleLiked(articleId) + .onLeft { Timber.e(it) } + analyticsEventHandler.send(NewsDetailsAnalyticsEvent.NewsLikeClicked(articleId)) + } + } + + private fun onOriginalArticleClick(originalArticle: OriginalArticle) { + analyticsEventHandler.send( + NewsDetailsAnalyticsEvent.RelatedNewsClicked( + newsId = params.articleId, + relatedNewsId = originalArticle.id, + ), + ) + urlOpener.openUrl(originalArticle.url) + } + + private fun onArticleIndexChanged(newIndex: Int) { + stateFactory.updateSelectedArticleIndex(newIndex) + val currentArticle = when (state.value.articlesStateUM) { + is ArticlesStateUM.Content -> { + paginationManager?.checkAndLoadMoreIfNeeded( + currentIndex = newIndex, + totalArticlesCount = state.value.articles.size, + ) + state.value.articles.getOrNull(newIndex) + } + ArticlesStateUM.Loading, + is ArticlesStateUM.LoadingError, + -> null + } + currentArticle?.let { article -> + markArticleAsViewed(article.id) + loadRelatedTokens(article) + } + } + + private fun loadNews() { + modelScope.launch { + initialPrefetch() + paginationManager?.start() + val articleIdsFlow = paginationManager?.cachedPrefetchedIds ?: flowOf(params.preselectedArticlesId) + combine( + articleIdsFlow, + observeNewsDetailsUseCase.invoke(), + ) { articleIds, articlesMap -> + articleIds.mapNotNull { articleId -> + articlesMap[articleId]?.let { detailedArticle -> + newsDetailsConverter.convert(detailedArticle) + } + } + } + .map { articles -> + articles.toImmutableList() + } + .onEach { newArticles -> + val currentState = _state.value + val newIndex = NewsDetailsIndexManager.calculateNewIndex( + currentState = NewsDetailsIndexManager.NewsDetailsState( + articles = currentState.articles, + selectedArticleIndex = currentState.selectedArticleIndex, + ), + newArticles = newArticles, + defaultArticleId = params.articleId, + ) + stateFactory.updateArticles(newArticles, newIndex) + } + .launchIn(modelScope) + } + } + + private suspend fun initialPrefetch() { + observeNewsDetailsUseCase.prefetch( + newsIds = params.preselectedArticlesId, + language = currentLanguage, + ).onLeft { errors -> + errors.onEach { (newsId, throwable) -> + when { + // an article is opened from deeplink and is not found + params.screenSource == AnalyticsParam.ScreensSources.NewsLink.value && + newsId == params.articleId && + throwable is ApiResponseError.HttpException && + throwable.code == ApiResponseError.HttpException.Code.NOT_FOUND + -> { + analyticsEventHandler.send( + NewsDetailsAnalyticsEvent.NewsLinkMismatch( + newsId = params.articleId, + ), + ) + } + // global request executing error + newsId < 0 -> { + Timber.e(throwable) + } + else -> { + analyticsEventHandler.send( + NewsDetailsAnalyticsEvent.NewsArticleLoadError( + newsId = newsId, + ), + ) + } + } + } + }.mapLeft { + stateFactory.createErrorState() + } + } + + private fun onRetryClicked() { + stateFactory.createLoadingState() + modelScope.launch(dispatchers.default) { + initialPrefetch() + paginationManager?.reload() + } + } + + private fun loadRelatedTokens(article: ArticleUM) { + modelScope.launch(dispatchers.default) { + stateFactory.updateRelatedTokens(RelatedTokensUM.Loading) + val appCurrency = currentAppCurrency.value + val resultState = relatedTokensLoader.load( + articleId = article.id, + relatedTokens = article.relatedTokens, + appCurrency = appCurrency, + onTokenClick = { tokenParams, currency -> + params.onTokenClick(tokenParams, currency) + }, + ) + stateFactory.updateRelatedTokens(resultState) + } + } + + private fun markArticleAsViewed(articleId: Int) { + modelScope.launch(dispatchers.default) { + markArticleAsViewedUseCase.markAsViewed(articleId) + } + } + + companion object { + internal const val RELATED_TOKEN_MAX_COUNT = 5 + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt new file mode 100644 index 0000000000..54ad5c4ec6 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt @@ -0,0 +1,112 @@ +package com.tangem.features.feed.model.news.details + +import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase +import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase +import com.tangem.features.feed.model.news.list.statemanager.NewsListBatchFlowManager +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.PaginationStatus +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +internal class NewsDetailsPaginationManager( + private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase, + private val currentLanguage: Provider, + getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, + dispatchers: CoroutineDispatcherProvider, + currentCategoryIds: Provider>, + modelScope: CoroutineScope, + prefetchedIds: Set, +) : NewsListBatchFlowManager( + getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, + currentLanguage = currentLanguage, + currentCategoryIds = currentCategoryIds, + modelScope = modelScope, + dispatchers = dispatchers, +) { + + private val _cachedPrefetchedIds = MutableStateFlow(prefetchedIds) + val cachedPrefetchedIds = _cachedPrefetchedIds.asStateFlow() + + init { + reload() + } + + fun start() { + modelScope.launch(dispatchers.default) { + combine( + rawArticlesFlow, + observeNewsDetailsUseCase(), + _cachedPrefetchedIds, + ) { articles, cachedArticles, prefetched -> + calculateIdsToFetch( + visibleIds = articles.map { it.id }.toSet(), + cachedIds = cachedArticles.keys, + alreadyPrefetched = prefetched, + ) + } + .distinctUntilChanged() + .collect { newIds -> + if (newIds.isNotEmpty()) { + observeNewsDetailsUseCase.prefetch( + newsIds = newIds, + language = currentLanguage(), + ) + _cachedPrefetchedIds.update { it + newIds } + } + } + } + } + + private fun calculateIdsToFetch(visibleIds: Set, cachedIds: Set, alreadyPrefetched: Set): Set { + return visibleIds - cachedIds - alreadyPrefetched + } + + fun checkAndLoadMoreIfNeeded( + currentIndex: Int, + totalArticlesCount: Int, + preloadThreshold: Int = PRELOAD_THRESHOLD, + ) { + if (currentIndex >= totalArticlesCount - preloadThreshold) { + loadMoreIfPossible() + } + } + + private fun loadMoreIfPossible() { + modelScope.launch(dispatchers.default) { + when (val status = paginationStatus.value) { + is PaginationStatus.InitialLoading, + is PaginationStatus.NextBatchLoading, + is PaginationStatus.EndOfPagination, + -> { + return@launch + } + + is PaginationStatus.InitialLoadingError -> { + reload() + return@launch + } + + is PaginationStatus.Paginating -> { + val lastResult = status.lastResult + val canLoadMore = lastResult is BatchFetchResult.Success && !lastResult.last + + if (canLoadMore) { + loadMore() + } + } + + is PaginationStatus.None -> { + reload() + } + } + } + } + + companion object { + private const val PRELOAD_THRESHOLD = 5 + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/analytics/NewsDetailsAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/analytics/NewsDetailsAnalyticsEvent.kt new file mode 100644 index 0000000000..128235303e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/analytics/NewsDetailsAnalyticsEvent.kt @@ -0,0 +1,62 @@ +package com.tangem.features.feed.model.news.details.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.OneTimePerSessionEvent + +internal sealed class NewsDetailsAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Markets", event = event, params = params) { + + data class NewsArticleOpened( + private val source: String, + private val newsId: Int, + ) : NewsDetailsAnalyticsEvent( + event = "News Article Opened", + params = mapOf( + AnalyticsParam.SOURCE to source, + "News Id" to newsId.toString(), + ), + ) + + data class RelatedNewsClicked( + private val newsId: Int, + private val relatedNewsId: Int, + ) : NewsDetailsAnalyticsEvent( + event = "Related News Clicked", + params = mapOf( + "News Id" to newsId.toString(), + "Related News Id" to relatedNewsId.toString(), + ), + ) + + data class NewsLikeClicked( + private val newsId: Int, + ) : NewsDetailsAnalyticsEvent( + event = "News Like Clicked", + params = mapOf( + "News Id" to newsId.toString(), + ), + ), OneTimePerSessionEvent { + override val oneTimeEventId: String = event + newsId + } + + data class NewsArticleLoadError( + private val newsId: Int, + ) : NewsDetailsAnalyticsEvent( + event = "News Article Load Error", + params = mapOf( + "News Id" to newsId.toString(), + ), + ) + + data class NewsLinkMismatch( + private val newsId: Int, + ) : NewsDetailsAnalyticsEvent( + event = "News Link Mismatch", + params = mapOf( + "News Id" to newsId.toString(), + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt new file mode 100644 index 0000000000..95fd1b69b0 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt @@ -0,0 +1,106 @@ +package com.tangem.features.feed.model.news.details.converter + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.FormattedDate +import com.tangem.core.ui.utils.getFormattedDate +import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.models.news.OriginalArticle +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.news.details.state.ArticleUM +import com.tangem.features.feed.ui.news.details.state.Source +import com.tangem.features.feed.ui.news.details.state.SourceUM +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import org.joda.time.DateTime + +@Stable +internal class NewsDetailsConverter( + private val onSourceClick: (OriginalArticle) -> Unit, +) : Converter { + + override fun convert(value: DetailedArticle): ArticleUM { + return ArticleUM( + id = value.id, + title = value.title, + createdAt = mapFormattedDate(value.createdAt), + score = value.score, + tags = buildTags(value), + shortContent = value.shortContent, + content = value.content, + sources = buildSources(value), + newsUrl = value.newsUrl, + relatedTokens = value.relatedTokens.toImmutableList(), + isLiked = value.isLiked, + ) + } + + private fun buildTags(article: DetailedArticle): ImmutableList { + val categoryLabels = article.categories.map { category -> + LabelUM(text = TextReference.Str(category.name)) + } + val tokenLabels = article.relatedTokens.map { token -> + LabelUM( + text = TextReference.Str(token.symbol), + leadingContent = LabelLeadingContentUM.Token( + iconUrl = getTokenIconUrlFromDefaultHost( + tokenId = CryptoCurrency.RawID(token.id), + ), + ), + ) + } + return (categoryLabels + tokenLabels).toImmutableList() + } + + private fun buildSources(article: DetailedArticle): ImmutableList { + return article.originalArticles.map { originalArticle -> + SourceUM( + id = originalArticle.id, + title = originalArticle.title, + source = Source(id = originalArticle.source.id, name = originalArticle.source.name), + publishedAt = mapFormattedDate(originalArticle.publishedAt), + url = originalArticle.url, + onClick = { onSourceClick(originalArticle) }, + imageUrl = originalArticle.imageUrl, + ) + }.toImmutableList() + } + + private fun mapFormattedDate(createdAt: String): TextReference { + val formattedDate = getFormattedDate( + createdAt = createdAt, + now = DateTime.now(), + ) + return when (formattedDate) { + is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date) + is FormattedDate.HoursAgo -> TextReference.PluralRes( + id = R.plurals.news_published_hours_ago, + count = formattedDate.hours, + formatArgs = wrappedList(formattedDate.hours), + ) + is FormattedDate.MinutesAgo -> TextReference.PluralRes( + id = R.plurals.news_published_minutes_ago, + count = formattedDate.minutes, + formatArgs = wrappedList(formattedDate.minutes), + ) + is FormattedDate.Today -> TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(R.string.common_today), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str(formattedDate.time), + ), + ), + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/RelatedTokenConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/RelatedTokenConverter.kt new file mode 100644 index 0000000000..27c51c52e0 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/RelatedTokenConverter.kt @@ -0,0 +1,104 @@ +package com.tangem.features.feed.model.news.details.converter + +import androidx.compose.runtime.Stable +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter +import com.tangem.common.ui.charts.state.sorted +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.format.bigdecimal.* +import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenChart +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal +import java.math.RoundingMode + +@Stable +internal class RelatedTokenConverter(private val appCurrency: AppCurrency) : + Converter, MarketsListItemUM> { + + private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false) + + override fun convert(value: Pair): MarketsListItemUM { + val (tokenInfo, chart) = value + val tokenId = CryptoCurrency.RawID(tokenInfo.id) + + return MarketsListItemUM( + id = tokenId, + name = tokenInfo.name, + currencySymbol = tokenInfo.symbol, + ratingPosition = tokenInfo.metrics?.marketRating?.toString(), + marketCap = getMarketCap(tokenInfo), + iconUrl = getTokenIconUrlFromDefaultHost(tokenId), + price = getCurrentPrice(tokenInfo), + trendPercentText = getTrendPercent(tokenInfo), + trendType = getTrendType(tokenInfo), + chartData = getChartData(chart), + isUnder100kMarketCap = tokenInfo.metrics?.marketCap?.let { it < BigDecimal(MARKET_CAP_100K) } == true, + stakingRate = null, + updateTimestamp = null, + ) + } + + private fun getMarketCap(tokenInfo: TokenMarketInfo): String? { + val value = tokenInfo.metrics?.marketCap?.takeIf { it != BigDecimal.ZERO } ?: return null + + return value.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ).compact( + threeDigitsMethod = true, + ) + } + } + + private fun getCurrentPrice(tokenInfo: TokenMarketInfo): MarketsListItemUM.Price { + val priceText = tokenInfo.quotes.currentPrice.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ).price() + } + + return MarketsListItemUM.Price( + text = priceText, + changeType = null, + ) + } + + private fun getChartData(chart: TokenChart?): MarketChartRawData? { + return chart?.let { ct -> + priceAndTimePointValuesConverter.convert( + MarketChartData.Data( + y = ct.priceY.toImmutableList(), + x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(), + ).sorted(), + ) + } + } + + @Suppress("MagicNumber") + private fun getTrendType(tokenInfo: TokenMarketInfo): PriceChangeType { + val scaled = tokenInfo.quotes.h24ChangePercent?.setScale(4, RoundingMode.HALF_UP) + return when { + scaled == null -> PriceChangeType.NEUTRAL + scaled > BigDecimal.ZERO -> PriceChangeType.UP + scaled < BigDecimal.ZERO -> PriceChangeType.DOWN + else -> PriceChangeType.NEUTRAL + } + } + + private fun getTrendPercent(tokenInfo: TokenMarketInfo): String { + return tokenInfo.quotes.h24ChangePercent?.format { percent() } ?: "0%" + } + + companion object { + private const val MARKET_CAP_100K = "100000" + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/TokenMarketInfoToParamsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/TokenMarketInfoToParamsConverter.kt new file mode 100644 index 0000000000..89e0a020ca --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/TokenMarketInfoToParamsConverter.kt @@ -0,0 +1,28 @@ +package com.tangem.features.feed.model.news.details.converter + +import androidx.compose.runtime.Stable +import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.utils.converter.Converter + +@Stable +internal class TokenMarketInfoToParamsConverter : Converter { + + override fun convert(value: TokenMarketInfo): TokenMarketParams { + val tokenId = CryptoCurrency.RawID(value.id) + return TokenMarketParams( + id = tokenId, + name = value.name, + symbol = value.symbol, + tokenQuotes = TokenMarketParams.Quotes( + currentPrice = value.quotes.currentPrice, + h24Percent = value.quotes.h24ChangePercent, + weekPercent = value.quotes.weekChangePercent, + monthPercent = value.quotes.monthChangePercent, + ), + imageUrl = getTokenIconUrlFromDefaultHost(tokenId), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsIndexManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsIndexManager.kt new file mode 100644 index 0000000000..b7f4c844b4 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsIndexManager.kt @@ -0,0 +1,30 @@ +package com.tangem.features.feed.model.news.details.factory + +import com.tangem.features.feed.ui.news.details.state.ArticleUM +import com.tangem.utils.extensions.indexOfFirstOrNull +import kotlinx.collections.immutable.ImmutableList + +internal object NewsDetailsIndexManager { + + /** + * Calculate new index of article, trying to save current selected article while is loading + */ + fun calculateNewIndex( + currentState: NewsDetailsState, + newArticles: ImmutableList, + defaultArticleId: Int, + ): Int { + val currentArticleId = if (currentState.articles.isEmpty()) { + defaultArticleId + } else { + currentState.articles.getOrNull(currentState.selectedArticleIndex)?.id + ?: defaultArticleId + } + return newArticles.indexOfFirstOrNull { it.id == currentArticleId } ?: 0 + } + + data class NewsDetailsState( + val articles: ImmutableList, + val selectedArticleIndex: Int, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsStateFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsStateFactory.kt new file mode 100644 index 0000000000..d52db614b1 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsStateFactory.kt @@ -0,0 +1,64 @@ +package com.tangem.features.feed.model.news.details.factory + +import com.tangem.core.navigation.share.ShareManager +import com.tangem.features.feed.ui.news.details.state.ArticleUM +import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM +import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM +import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM +import com.tangem.utils.Provider +import kotlinx.collections.immutable.toImmutableList + +internal class NewsDetailsStateFactory( + private val currentStateProvider: Provider, + private val shareManager: ShareManager, + private val onStateUpdate: (NewsDetailsUM) -> Unit, + private val onRetryClick: () -> Unit, +) { + + fun updateArticles(articles: List, selectedIndex: Int) { + val currentState = currentStateProvider() + val currentArticle = articles.getOrNull(selectedIndex) + onStateUpdate( + currentState.copy( + articles = articles.toImmutableList(), + articlesStateUM = ArticlesStateUM.Content, + selectedArticleIndex = selectedIndex, + onShareClick = { + currentArticle?.let { + shareManager.shareText(it.newsUrl) + } + }, + ), + ) + } + + fun updateSelectedArticleIndex(newIndex: Int) { + val currentState = currentStateProvider() + val currentArticle = currentState.articles.getOrNull(newIndex) + onStateUpdate( + currentState.copy( + selectedArticleIndex = newIndex, + onShareClick = { + currentArticle?.let { + shareManager.shareText(it.newsUrl) + } + }, + ), + ) + } + + fun updateRelatedTokens(relatedTokens: RelatedTokensUM) { + val currentState = currentStateProvider() + onStateUpdate(currentState.copy(relatedTokensUM = relatedTokens)) + } + + fun createErrorState() { + val currentState = currentStateProvider() + onStateUpdate(currentState.copy(articlesStateUM = ArticlesStateUM.LoadingError(onRetryClick))) + } + + fun createLoadingState() { + val currentState = currentStateProvider() + onStateUpdate(currentState.copy(articlesStateUM = ArticlesStateUM.Loading)) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/loader/NewsRelatedTokensLoader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/loader/NewsRelatedTokensLoader.kt new file mode 100644 index 0000000000..50294b3ffc --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/loader/NewsRelatedTokensLoader.kt @@ -0,0 +1,150 @@ +package com.tangem.features.feed.model.news.details.loader + +import arrow.core.getOrElse +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.* +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.news.RelatedToken +import com.tangem.features.feed.model.news.details.converter.RelatedTokenConverter +import com.tangem.features.feed.model.news.details.converter.TokenMarketInfoToParamsConverter +import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext + +internal class NewsRelatedTokensLoader( + private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, + private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, + private val dispatchers: CoroutineDispatcherProvider, + private val maxCount: Int, +) { + private val relatedTokensCache = mutableMapOf() + private val tokenMarketInfoToParamsConverter = TokenMarketInfoToParamsConverter() + + /** + * Loads data about related tokens. + * Returns the cached result if available, otherwise loads the data. + * + * @param articleId article ID for caching + * @param relatedTokens list of related tokens + * @param appCurrency application currency + * @param onTokenClick token click handler + * @return state of related tokens + */ + suspend fun load( + articleId: Int, + relatedTokens: List, + appCurrency: AppCurrency, + onTokenClick: (TokenMarketParams, AppCurrency) -> Unit, + ): RelatedTokensUM = withContext(dispatchers.default) { + val cached = getCachedRelatedTokens(articleId) + if (cached != null) { + return@withContext cached + } + + val tokensToLoad = relatedTokens.take(maxCount) + if (tokensToLoad.isEmpty()) { + val errorState = RelatedTokensUM.LoadingError + relatedTokensCache[articleId] = errorState + return@withContext errorState + } + + val tokenDataList = loadTokensData(tokensToLoad, appCurrency) + + if (tokenDataList.isEmpty()) { + val errorState = RelatedTokensUM.LoadingError + relatedTokensCache[articleId] = errorState + return@withContext errorState + } + + val resultState = createRelatedTokensState(tokenDataList, appCurrency, onTokenClick) + relatedTokensCache[articleId] = resultState + resultState + } + + private fun getCachedRelatedTokens(articleId: Int): RelatedTokensUM? { + return relatedTokensCache[articleId]?.takeIf { it !is RelatedTokensUM.Loading } + } + + private suspend fun CoroutineScope.loadTokensData( + relatedTokens: List, + appCurrency: AppCurrency, + ): List> { + val relatedTokenConverter = RelatedTokenConverter(appCurrency = appCurrency) + + return relatedTokens.map { token -> + async(dispatchers.default) { + loadSingleTokenData(token, appCurrency, relatedTokenConverter) + } + }.awaitAll().filterNotNull() + } + + private suspend fun loadSingleTokenData( + token: RelatedToken, + appCurrency: AppCurrency, + relatedTokenConverter: RelatedTokenConverter, + ): Pair? { + val tokenId = CryptoCurrency.RawID(token.id) + val tokenInfoResult = getTokenMarketInfoUseCase( + appCurrency = appCurrency, + tokenId = tokenId, + tokenSymbol = token.symbol, + ) + + return tokenInfoResult.fold( + ifLeft = { null }, + ifRight = { tokenInfo -> + val chart = loadTokenChart(tokenId, token.symbol, appCurrency) + val tokenItem = relatedTokenConverter.convert(tokenInfo to chart) + val tokenParams = tokenMarketInfoToParamsConverter.convert(tokenInfo) + tokenItem to tokenParams + }, + ) + } + + private suspend fun loadTokenChart( + tokenId: CryptoCurrency.RawID, + tokenSymbol: String, + appCurrency: AppCurrency, + ): TokenChart? { + val chartResult = getTokenPriceChartUseCase( + appCurrency = appCurrency, + interval = PriceChangeInterval.H24, + tokenId = tokenId, + tokenSymbol = tokenSymbol, + preview = true, + ) + return chartResult.getOrElse { null } + } + + private fun createRelatedTokensState( + tokenDataList: List>, + appCurrency: AppCurrency, + onTokenClick: (TokenMarketParams, AppCurrency) -> Unit, + ): RelatedTokensUM.Content { + val tokenItems = tokenDataList.map { it.first } + val onTokenClickHandler = createTokenClickHandler(tokenDataList, appCurrency, onTokenClick) + + return RelatedTokensUM.Content( + items = tokenItems.toImmutableList(), + onTokenClick = onTokenClickHandler, + ) + } + + private fun createTokenClickHandler( + tokenDataList: List>, + appCurrency: AppCurrency, + onTokenClick: (TokenMarketParams, AppCurrency) -> Unit, + ): (MarketsListItemUM) -> Unit { + return { item -> + val tokenData = tokenDataList.find { it.first.id == item.id } + tokenData?.second?.let { tokenParams -> + onTokenClick(tokenParams, appCurrency) + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt new file mode 100644 index 0000000000..9e6153728b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt @@ -0,0 +1,170 @@ +package com.tangem.features.feed.model.news.list + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.domain.news.model.NewsListConfig +import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase +import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase +import com.tangem.features.feed.components.news.list.DefaultNewsListComponent +import com.tangem.features.feed.model.news.list.analytics.NewsListAnalyticsEvent +import com.tangem.features.feed.model.news.list.statemanager.NewsListStateManager +import com.tangem.features.feed.model.news.list.loader.NewsCategoriesLoader +import com.tangem.features.feed.model.news.list.statemanager.NewsListBatchFlowManager +import com.tangem.features.feed.ui.news.list.state.NewsListState +import com.tangem.features.feed.ui.news.list.state.NewsListUM +import com.tangem.utils.Provider +import com.tangem.utils.SupportedLanguages +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +internal class NewsListModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val getNewsCategoriesUseCase: GetNewsCategoriesUseCase, + private val getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, + paramsContainer: ParamsContainer, +) : Model() { + + private val params = paramsContainer.require() + private val selectedCategoryId = MutableStateFlow(null) + private val currentLanguage = SupportedLanguages.getCurrentSupportedLanguageCode() + + private val categoriesLoader by lazy { + NewsCategoriesLoader( + getNewsCategoriesUseCase = getNewsCategoriesUseCase, + defaultAllNewsCategoryId = DEFAULT_ALL_NEWS_CATEGORIES_ID, + onCategoryClick = ::onCategoryClick, + ) + } + + private val batchFlowManager by lazy { + NewsListBatchFlowManager( + getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, + currentLanguage = Provider { currentLanguage }, + currentCategoryIds = Provider { + selectedCategoryId.value?.takeIf { it > 0 }?.let { listOf(it) }.orEmpty() + }, + modelScope = modelScope, + dispatchers = dispatchers, + ) + } + + private val newsListStateManager by lazy(LazyThreadSafetyMode.NONE) { + NewsListStateManager(analyticsEventHandler) + } + + private val _state = MutableStateFlow( + NewsListUM( + selectedCategoryId = DEFAULT_ALL_NEWS_CATEGORIES_ID, + filters = persistentListOf(), + newsListState = NewsListState.Loading, + listOfArticles = persistentListOf(), + onArticleClick = { articleId -> + params.onArticleClicked( + /* currentArticle */ articleId, + /* prefetchedArticles */ getCurrentFetchedArticlesIds(), + /* paginationConfig */ createNewsListConfig(), + ) + }, + onBackClick = params.onBackClick, + ), + ) + val state = _state.asStateFlow() + + init { + loadCategories() + observeNewsList() + batchFlowManager.reload() + } + + private fun loadCategories() { + modelScope.launch(dispatchers.default) { + val filterChips = categoriesLoader.load() + _state.update { currentState -> + currentState.copy(filters = filterChips) + } + } + } + + private fun observeNewsList() { + modelScope.launch(dispatchers.default) { + combine( + batchFlowManager.uiItems, + batchFlowManager.initialLoadingError, + batchFlowManager.paginationStatus, + ) { articles, loadingError, paginationStatus -> + newsListStateManager.calculateState( + articles = articles, + error = loadingError, + paginationStatus = paginationStatus, + onRetryClick = { + loadCategories() + batchFlowManager.reload() + }, + onLoadMore = { batchFlowManager.loadMore() }, + ) + }.collect { (listState, articles) -> + _state.update { currentState -> + currentState.copy( + listOfArticles = articles, + newsListState = listState, + ) + } + } + } + } + + private fun onCategoryClick(categoryId: Int) { + val newCategoryId = if (state.value.selectedCategoryId == categoryId) { + DEFAULT_ALL_NEWS_CATEGORIES_ID + } else { + categoryId + } + if (newCategoryId != DEFAULT_ALL_NEWS_CATEGORIES_ID) { + analyticsEventHandler.send(NewsListAnalyticsEvent.NewsCategoriesClick(newCategoryId)) + } + selectedCategoryId.value = newCategoryId + _state.update { currentState -> + currentState.copy( + selectedCategoryId = newCategoryId, + filters = updateFilterChips(newCategoryId), + ) + } + batchFlowManager.reload() + } + + private fun updateFilterChips(categoryId: Int?): ImmutableList { + return state.value.filters.map { chip -> + chip.copy(isSelected = chip.id == categoryId) + }.toImmutableList() + } + + private fun getCurrentFetchedArticlesIds(): List { + return state.value.listOfArticles.map { it.id } + } + + private fun createNewsListConfig(): NewsListConfig { + return NewsListConfig( + language = currentLanguage, + snapshot = null, + tokenIds = emptyList(), + categoryIds = selectedCategoryId.value?.takeIf { it > 0 }?.let { listOf(it) }.orEmpty(), + ) + } + + companion object { + private const val DEFAULT_ALL_NEWS_CATEGORIES_ID = -1 + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/analytics/NewsListAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/analytics/NewsListAnalyticsEvent.kt new file mode 100644 index 0000000000..407b1d06b4 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/analytics/NewsListAnalyticsEvent.kt @@ -0,0 +1,32 @@ +package com.tangem.features.feed.model.news.list.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE +import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR + +internal sealed class NewsListAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Markets", event = event, params = params) { + + data class NewsListLoadError( + private val code: Int?, + private val message: String, + ) : NewsListAnalyticsEvent( + event = "News List Load Error", + params = mapOf( + ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(), + ERROR_MESSAGE to message, + ), + ) + + data class NewsCategoriesClick( + private val categoryId: Int, + ) : NewsListAnalyticsEvent( + event = "News Categories Selected", + params = mapOf( + "Selected Categories" to categoryId.toString(), + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/loader/NewsCategoriesLoader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/loader/NewsCategoriesLoader.kt new file mode 100644 index 0000000000..849ae5a073 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/loader/NewsCategoriesLoader.kt @@ -0,0 +1,50 @@ +package com.tangem.features.feed.model.news.list.loader + +import com.tangem.common.ui.R +import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.news.ArticleCategory +import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +internal class NewsCategoriesLoader( + private val getNewsCategoriesUseCase: GetNewsCategoriesUseCase, + private val defaultAllNewsCategoryId: Int, + private val onCategoryClick: (Int) -> Unit, +) { + + suspend fun load(): ImmutableList { + val allCategoriesChip = createAllNewsChip() + val categoriesResult = getNewsCategoriesUseCase.invoke() + + return categoriesResult.fold( + ifLeft = { persistentListOf(allCategoriesChip) }, + ifRight = { categories -> + val categoryChips = categories.map { articleCategory -> + createCategoryChip(articleCategory) + } + (listOf(allCategoriesChip) + categoryChips).toPersistentList() + }, + ) + } + + private fun createAllNewsChip(): ChipUM { + return ChipUM( + id = defaultAllNewsCategoryId, + text = TextReference.Res(R.string.news_all_news), + isSelected = true, + onClick = { onCategoryClick(defaultAllNewsCategoryId) }, + ) + } + + private fun createCategoryChip(articleCategory: ArticleCategory): ChipUM { + return ChipUM( + id = articleCategory.id, + text = TextReference.Str(articleCategory.name), + isSelected = false, + onClick = { onCategoryClick(articleCategory.id) }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt new file mode 100644 index 0000000000..fa5cff5b5e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt @@ -0,0 +1,142 @@ +package com.tangem.features.feed.model.news.list.statemanager + +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.news.model.NewsListBatchingContext +import com.tangem.domain.news.model.NewsListConfig +import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase +import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter +import com.tangem.features.feed.model.converter.distinctBatchesContent +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchAction +import com.tangem.pagination.PaginationStatus +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +internal open class NewsListBatchFlowManager( + getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, + private val currentLanguage: Provider, + private val currentCategoryIds: Provider>, + protected val modelScope: CoroutineScope, + protected val dispatchers: CoroutineDispatcherProvider, +) { + private val actionsFlow = MutableSharedFlow>() + private val converter by lazy { + ShortArticleToArticleConfigUMConverter(isTrending = Provider { false }) + } + + private val batchFlow = getNewsListBatchFlowUseCase( + context = NewsListBatchingContext( + actionsFlow = actionsFlow, + coroutineScope = modelScope, + ), + batchSize = DEFAULT_BATCH_SIZE, + ) + + private val resultBatches = MutableStateFlow>>>(emptyList()) + + val rawArticlesFlow: StateFlow> = batchFlow.state + .map { batchListState -> + batchListState.data + .flatMap { batch -> batch.data } + .toImmutableList() + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = persistentListOf(), + ) + + val uiItems: StateFlow> + get() = batchFlow.state + .map { batchListState -> + batchListState.data + .flatMap { batch -> batch.data } + .let { articles -> converter.convert(articles) } + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = persistentListOf(), + ) + + val initialLoadingError: StateFlow = batchFlow.state + .map { state -> + val status = state.status + if (status is PaginationStatus.InitialLoadingError) { + status.throwable + } else { + null + } + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) + + val paginationStatus: StateFlow>> = batchFlow.state + .map { it.status } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = PaginationStatus.InitialLoading, + ) + + init { + batchFlow.state + .map { it.data } + .distinctBatchesContent() + .onEach { batches -> + resultBatches.value = batches.map { batch -> + Batch( + key = batch.key, + data = converter.convert(batch.data), + ) + } + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + fun reload() { + modelScope.launch(dispatchers.default) { + resultBatches.value = emptyList() + actionsFlow.emit( + BatchAction.Reload( + requestParams = createNewsListConfig(), + ), + ) + } + } + + fun loadMore() { + modelScope.launch(dispatchers.default) { + actionsFlow.emit(BatchAction.LoadMore()) + } + } + + private fun createNewsListConfig(): NewsListConfig { + return NewsListConfig( + language = currentLanguage(), + snapshot = null, + tokenIds = emptyList(), + categoryIds = currentCategoryIds(), + ) + } + + private companion object { + private const val DEFAULT_BATCH_SIZE = 20 + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListStateManager.kt new file mode 100644 index 0000000000..e16a20e9f2 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListStateManager.kt @@ -0,0 +1,58 @@ +package com.tangem.features.feed.model.news.list.statemanager + +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.features.feed.model.news.list.analytics.NewsListAnalyticsEvent +import com.tangem.features.feed.ui.news.list.state.NewsListState +import com.tangem.pagination.PaginationStatus +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal class NewsListStateManager( + private val analyticsEventHandler: AnalyticsEventHandler, +) { + + /** + * Calculates the state of the news list based on the current data. + * + * @param articles the current list of articles + * @param error flag indicating an initial loading error + * @param paginationStatus the current pagination status + * @param onRetryClick handler for the retry loading action + * @param onLoadMore handler for loading the next page + * @return a pair: the list state and the list of articles to display + */ + fun calculateState( + articles: ImmutableList, + error: Throwable?, + paginationStatus: PaginationStatus<*>, + onRetryClick: () -> Unit, + onLoadMore: () -> Unit, + ): Pair> { + if (error != null) { + sendErrorAnalytics(error) + } + return when { + error != null -> NewsListState.LoadingError(onRetryClick) to persistentListOf() + paginationStatus is PaginationStatus.InitialLoading && articles.isEmpty() -> { + NewsListState.Loading to persistentListOf() + } + articles.isEmpty() -> NewsListState.Loading to persistentListOf() + else -> NewsListState.Content(loadMore = onLoadMore) to articles + } + } + + private fun sendErrorAnalytics(throwable: Throwable) { + val (code, message) = when (throwable) { + is ApiResponseError.HttpException -> throwable.code.numericCode to throwable.message + else -> null to "" + } + analyticsEventHandler.send( + NewsListAnalyticsEvent.NewsListLoadError( + code = code, + message = message.orEmpty(), + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/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..a8efdab5d9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt @@ -0,0 +1,76 @@ +package com.tangem.features.feed.ui + +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.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.utils.WindowInsetsZero +import com.tangem.features.feed.components.FeedEntryChildFactory +import com.tangem.features.feed.ui.utils.contentFeedEntryStackAnimation +import com.tangem.features.feed.ui.utils.topBarFeedEntryStackAnimation + +@Composable +internal fun EntryContent( + bottomSheetState: State, + stackState: State>, + onHeaderSizeChange: (Dp) -> Unit, + isOpenedInBottomSheet: Boolean, +) { + val density = LocalDensity.current + val background = LocalMainBottomSheetColor.current.value + val animationContent = remember { contentFeedEntryStackAnimation() } + val animationAppBar = remember { topBarFeedEntryStackAnimation() } + + Surface(contentColor = background) { + Scaffold( + containerColor = background, + contentWindowInsets = WindowInsetsZero, + topBar = { + Children( + modifier = Modifier + .then( + if (!isOpenedInBottomSheet) { + Modifier.statusBarsPadding() + } else { + Modifier + }, + ) + .onGloballyPositioned { coordinates -> + if (coordinates.size.height > 0) { + with(density) { + onHeaderSizeChange(coordinates.size.height.toDp()) + } + } + }, + stack = stackState.value, + animation = animationAppBar, + ) { child -> + child.instance.Title(bottomSheetState) + } + }, + content = { contentPadding -> + Children( + stack = stackState.value, + animation = animationContent, + ) { 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..88ebc8dc1f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -1,19 +1,21 @@ package com.tangem.features.feed.ui.feed -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -21,6 +23,7 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onFirstVisible import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -33,6 +36,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,46 +45,45 @@ 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.conditional import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState import com.tangem.features.feed.ui.feed.state.* -import com.tangem.features.feed.ui.market.state.SortByTypeUM @Composable -internal fun FeedListHeader(searchBarUM: SearchBarUM, modifier: Modifier = Modifier) { +internal fun FeedListHeader( + isSearchBarClickable: Boolean, + feedListSearchBar: FeedListSearchBar, + modifier: Modifier = Modifier, +) { val background = LocalMainBottomSheetColor.current.value - SearchBar( + FeedSearchBar( + isSearchBarClickable = isSearchBarClickable, + 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, - ), + .padding(bottom = 8.dp), ) } @Composable internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value - AnimatedContent( modifier = modifier, targetState = state.globalState, + transitionSpec = { fadeIn() togetherWith fadeOut() }, ) { animatedState -> when (animatedState) { is GlobalFeedState.Loading -> { - FeeListLoading( + FeedListLoading( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) @@ -91,10 +94,11 @@ internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) { FeedListGlobalError( onRetryClick = animatedState.onRetryClicked, modifier = Modifier.drawBehind { drawRect(background) }, + currentDate = state.currentDate, ) } is GlobalFeedState.Content -> { - FeeListContent( + FeedListContent( modifier = Modifier, state = state, ) @@ -104,89 +108,106 @@ internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) { } @Composable -private fun FeeListContent(state: FeedListUM, modifier: Modifier = Modifier) { +private fun FeedSearchBar( + isSearchBarClickable: Boolean, + feedListSearchBar: FeedListSearchBar, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(36.dp)) + .background(color = TangemTheme.colors.field.focused) + .conditional(condition = isSearchBarClickable) { + 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 FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value Column( modifier = modifier .fillMaxSize() .verticalScroll(rememberScrollState()) + .padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) .drawBehind { drawRect(background) }, ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(WindowInsets.navigationBars.asPaddingValues()), - ) { - SpacerH(20.dp) + DateBlock(state.currentDate) + SpacerH(32.dp) - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - text = stringResourceSafe(R.string.feed_market_and_news), - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.primary1, - ) - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - text = state.currentDate, - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.tertiary, - ) + MarketBlock( + marketChart = state.marketChartConfig.marketCharts[SortByTypeUM.Rating], + feedListCallbacks = state.feedListCallbacks, + ) - SpacerH(32.dp) + NewsBlock( + news = state.news, + feedListCallbacks = state.feedListCallbacks, + trendingArticle = state.trendingArticle, + ) - AnimatedVisibility(state.marketChartConfig.marketCharts[SortByTypeUM.Rating] != null) { - val marketChart = remember(state.marketChartConfig.marketCharts[SortByTypeUM.Rating]) { - state.marketChartConfig.marketCharts[SortByTypeUM.Rating] - } - if (marketChart != null) { - MarketBlock( - marketChart = marketChart, - feedListCallbacks = state.feedListCallbacks, - ) - } - } - - NewsBlock( - news = state.news, - feedListCallbacks = state.feedListCallbacks, - trendingArticle = state.trendingArticle, - ) - - MarketPulseBlock( - marketChartConfig = state.marketChartConfig, - feedListCallbacks = state.feedListCallbacks, - ) - } + MarketPulseBlock( + marketChartConfig = state.marketChartConfig, + feedListCallbacks = state.feedListCallbacks, + ) } } @Composable -private fun MarketBlock(marketChart: MarketChartUM, feedListCallbacks: FeedListCallbacks) { - Column(modifier = Modifier.fillMaxWidth()) { - Header( - title = { - Text( - text = stringResourceSafe(R.string.markets_common_title), - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, - ) - }, - onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) }, - ) +private fun MarketBlock(marketChart: MarketChartUM?, feedListCallbacks: FeedListCallbacks) { + AnimatedContent( + targetState = marketChart, + label = "MarketBlockChartAnimation", + transitionSpec = { fadeIn() togetherWith fadeOut() }, + ) { currentChart -> + when (currentChart) { + is MarketChartUM.Content, + MarketChartUM.Loading, + is MarketChartUM.LoadingError, + -> { + Column(modifier = Modifier.fillMaxWidth()) { + Header( + title = { + Text( + text = stringResourceSafe(R.string.markets_common_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + }, + onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) }, + ) - SpacerH(12.dp) + SpacerH(12.dp) - Charts( - onItemClick = feedListCallbacks.onMarketItemClick, - modifier = Modifier.padding(horizontal = 16.dp), - marketChart = marketChart, - ) + Charts( + onItemClick = feedListCallbacks.onMarketItemClick, + modifier = Modifier.padding(horizontal = 16.dp), + marketChart = currentChart, + ) - SpacerH(32.dp) + SpacerH(32.dp) + } + } + null -> Unit + } } } @@ -245,36 +266,35 @@ private fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCallb } } -@Suppress("CanBeNonNullable") @Composable private fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) { - AnimatedContent(news) { newsUM -> - when (newsUM) { - is NewsUM.Content -> { - if (newsUM.content.isNotEmpty()) { + AnimatedContent(news.newsUMState) { newsUMState -> + when (newsUMState) { + NewsUMState.LOADING -> NewsLoadingBlock() + NewsUMState.CONTENT -> { + if (news.content.isNotEmpty()) { NewsContentBlock( feedListCallbacks = feedListCallbacks, - news = newsUM, + news = news, trendingArticle = trendingArticle, ) } } - NewsUM.Loading -> { - NewsLoadingBlock() - } - is NewsUM.Error -> { - NewsErrorBlock(onRetryClick = newsUM.onRetryClicked) - } + NewsUMState.ERROR -> NewsErrorBlock(onRetryClick = news.onRetryClicked) } } } +@Suppress("LongMethod") @Composable -private fun NewsContentBlock( - feedListCallbacks: FeedListCallbacks, - news: NewsUM.Content, - trendingArticle: ArticleConfigUM?, -) { +private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) { + val listState = rememberLazyListState() + val articlesReadStatus = remember(news.content) { + news.content.map { it.isViewed } + } + LaunchedEffect(articlesReadStatus) { + listState.requestScrollToItem(0) + } Column { Header( title = { @@ -307,46 +327,71 @@ private fun NewsContentBlock( append(stringResourceSafe(R.string.feed_tangem_ai)) } }, - style = TangemTheme.typography.h3, + style = TangemTheme.typography.subtitle1, ) } }, - onSeeAllClick = feedListCallbacks.onOpenAllNews, + onSeeAllClick = { feedListCallbacks.onOpenAllNews(false) }, ) 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(), + state = listState, ) { - items( + itemsIndexed( items = news.content, - key = ArticleConfigUM::id, - ) { article -> + key = { _, article -> article.id }, + contentType = { _, _ -> "article" }, + ) { index, article -> + val articleModifier = if (index == FOURTH_ITEM_INDEX) { + Modifier.onFirstVisible( + minFractionVisible = 0.5f, + callback = feedListCallbacks.onSliderScroll, + ) + } else { + Modifier + } ArticleCard( articleConfigUM = article, onArticleClick = { feedListCallbacks.onArticleClick(article.id) }, - modifier = Modifier - .height(164.dp) - .widthIn(max = 216.dp), + modifier = articleModifier + .heightIn(min = 164.dp) + .width(216.dp), colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), ) } + + item(contentType = "show_more") { + ShowMoreArticlesCard( + modifier = Modifier + .width(216.dp) + .heightIn(min = 164.dp) + .onFirstVisible( + minFractionVisible = 0.5f, + callback = feedListCallbacks.onSliderEndReached, + ), + onClick = { feedListCallbacks.onOpenAllNews(true) }, + ) + } } + SpacerH(32.dp) } } @@ -388,10 +433,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 -> @@ -439,16 +490,19 @@ private fun FilterChip(sortByTypeUM: SortByTypeUM, isSelected: Boolean, onClick: } @Composable -private fun FeedListGlobalError(onRetryClick: () -> Unit, modifier: Modifier = Modifier) { +private fun FeedListGlobalError(onRetryClick: () -> Unit, currentDate: String, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value - Box( - modifier = modifier - .fillMaxSize() - .drawBehind { drawRect(background) } - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - UnableToLoadData(onRetryClick = onRetryClick) + Column(modifier) { + DateBlock(currentDate) + Box( + modifier = Modifier + .fillMaxSize() + .drawBehind { drawRect(background) } + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData(onRetryClick = onRetryClick) + } } } @@ -468,20 +522,50 @@ 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), + ) + } + SpacerH(32.dp) } } +@Composable +private fun ColumnScope.DateBlock(currentDate: String) { + SpacerH(20.dp) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + text = stringResourceSafe(R.string.feed_market_and_news), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + text = currentDate, + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.tertiary, + ) +} + private const val DEFAULT_CHART_SIZE_IN_MARKET = 5 +private const val FOURTH_ITEM_INDEX = 3 private const val GRADIENT_START = 0f 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/FeedListLoading.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt index 2e24ff3ec2..0788fe844c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt @@ -17,7 +17,7 @@ import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.res.TangemThemePreview @Composable -internal fun FeeListLoading(modifier: Modifier = Modifier) { +internal fun FeedListLoading(modifier: Modifier = Modifier) { Column(modifier) { Column( modifier = Modifier diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt index 9cf0dea267..62d38e2004 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt @@ -3,15 +3,14 @@ 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 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.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.feed.state.* -import com.tangem.features.feed.ui.market.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 = {}, @@ -36,8 +32,14 @@ internal object FeedListPreviewDataProvider { onOpenAllNews = {}, onMarketItemClick = {}, onSortTypeClick = {}, + onSliderScroll = {}, + onSliderEndReached = {}, + ), + news = NewsUM( + content = articles.filter { it.isTrending.not() }.toImmutableList(), + onRetryClicked = {}, + newsUMState = NewsUMState.CONTENT, ), - news = NewsUM.Content(articles.filter { it.isTrending.not() }.toImmutableList()), trendingArticle = articles.first { it.isTrending }, marketChartConfig = MarketChartConfig( marketCharts = createMarketCharts(marketItems, includeErrorState = false), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt index 3b613ccc86..d9c50773a0 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt @@ -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 +import kotlinx.collections.immutable.persistentListOf internal data class FeedListUM( val currentDate: String, - val searchBar: SearchBarUM, + val feedListSearchBar: FeedListSearchBar, val feedListCallbacks: FeedListCallbacks, val news: NewsUM, val trendingArticle: ArticleConfigUM?, @@ -23,23 +23,40 @@ internal data class FeedListCallbacks( val onSearchClick: () -> Unit, val onMarketOpenClick: (sortBy: SortByTypeUM) -> Unit, val onArticleClick: (id: Int) -> Unit, - val onOpenAllNews: () -> Unit, + val onOpenAllNews: (isFromCarouselButton: Boolean) -> Unit, val onMarketItemClick: (MarketsListItemUM) -> Unit, - val onSortTypeClick: (SortByTypeUM) -> Unit, + val onSortTypeClick: (sortBy: SortByTypeUM) -> Unit, + val onSliderScroll: () -> Unit, + val onSliderEndReached: () -> Unit, ) -@Immutable -internal sealed interface NewsUM { - data object Loading : NewsUM - data class Content(val content: ImmutableList) : NewsUM - data class Error(val onRetryClicked: () -> Unit) : NewsUM +internal data class FeedListSearchBar( + val onBarClick: () -> Unit, + val placeholderText: TextReference, +) + +internal data class NewsUM( + val content: ImmutableList, + val onRetryClicked: () -> Unit, + val newsUMState: NewsUMState, +) + +internal enum class NewsUMState { + LOADING, + CONTENT, + ERROR, } internal data class MarketChartConfig( val marketCharts: ImmutableMap, val currentSortByType: SortByTypeUM = SortByTypeUM.TopGainers, ) { - fun getFilterPreset() = SortByTypeUM.entries.filter { it != SortByTypeUM.Rating }.toPersistentList() + fun getFilterPreset() = persistentListOf( + SortByTypeUM.TopGainers, + SortByTypeUM.TopLosers, + SortByTypeUM.ExperiencedBuyers, + SortByTypeUM.Trending, + ) } @Immutable 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/feed/state/TrendingNewsStateFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt deleted file mode 100644 index 15aee2ff6a..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.tangem.features.feed.ui.feed.state - -import com.tangem.common.ui.news.ArticleConfigUM -import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM -import com.tangem.core.ui.components.label.entity.LabelUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.WrappedList -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.utils.FormattedDate -import com.tangem.core.ui.utils.getFormattedDate -import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.news.ShortArticle -import com.tangem.domain.models.news.TrendingNews -import com.tangem.features.feed.impl.R -import com.tangem.utils.Provider -import com.tangem.utils.StringsSigns -import kotlinx.collections.immutable.toPersistentList -import kotlinx.collections.immutable.toPersistentSet -import org.joda.time.DateTime - -internal class TrendingNewsStateFactory( - private val currentStateProvider: Provider, - private val onStateUpdate: (FeedListUM) -> Unit, -) { - - fun updateTrendingNewsState(result: TrendingNews, onRetryClicked: () -> Unit) { - val currentState = currentStateProvider() - when (result) { - is TrendingNews.Data -> handleDataState(currentState, result.articles) - is TrendingNews.Error -> handleErrorState(currentState, onRetryClicked) - } - } - - private fun handleDataState(currentState: FeedListUM, articles: List) { - val (trendingArticle, commonArticles) = separateTrendingAndCommonArticles(articles) - - onStateUpdate( - currentState.copy( - trendingArticle = trendingArticle?.let { mapToArticleConfigUM(it, isTrending = true) }, - news = NewsUM.Content( - commonArticles.map { mapToArticleConfigUM(it, isTrending = false) }.toPersistentList(), - ), - ), - ) - } - - private fun handleErrorState(currentState: FeedListUM, onRetryClicked: () -> Unit) { - onStateUpdate( - currentState.copy( - trendingArticle = null, - news = NewsUM.Error(onRetryClicked = onRetryClicked), - ), - ) - } - - private fun separateTrendingAndCommonArticles( - articles: List, - ): Pair> { - val trendingArticleIndex = articles.indexOfFirst { it.isTrending } - return if (trendingArticleIndex != -1) { - val trendingArticle = articles[trendingArticleIndex] - val commonArticles = articles.toMutableList().apply { removeAt(trendingArticleIndex) } - trendingArticle to commonArticles - } else { - null to articles - } - } - - private fun mapToArticleConfigUM(article: ShortArticle, isTrending: Boolean): ArticleConfigUM { - return ArticleConfigUM( - id = article.id, - title = article.title, - score = article.score, - isTrending = isTrending, - tags = buildArticleTags(article), - createdAt = mapFormattedDate(article.createdAt), - isViewed = article.viewed, - ) - } - - private fun buildArticleTags(article: ShortArticle): kotlinx.collections.immutable.ImmutableSet { - val categoryLabels = article.categories.map { category -> - LabelUM(text = TextReference.Str(category.name)) - } - val tokenLabels = article.relatedTokens.map { token -> - LabelUM( - text = TextReference.Str(token.symbol), - leadingContent = LabelLeadingContentUM.Token( - iconUrl = getTokenIconUrlFromDefaultHost( - tokenId = CryptoCurrency.RawID(token.id), - ), - ), - ) - } - return (categoryLabels + tokenLabels).toPersistentSet() - } - - private fun mapFormattedDate(createdAt: String): TextReference { - val formattedDate = getFormattedDate( - createdAt = createdAt, - now = DateTime.now(), - ) - return when (formattedDate) { - is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date) - is FormattedDate.HoursAgo -> TextReference.PluralRes( - id = R.plurals.news_published_hours_ago, - count = formattedDate.hours, - formatArgs = wrappedList(formattedDate.hours), - ) - is FormattedDate.MinutesAgo -> TextReference.PluralRes( - id = R.plurals.news_published_minutes_ago, - count = formattedDate.minutes, - formatArgs = wrappedList(formattedDate.minutes), - ) - is FormattedDate.Today -> TextReference.Combined( - refs = WrappedList( - data = listOf( - TextReference.Res(R.string.common_today), - TextReference.Str(StringsSigns.COMA_SIGN), - TextReference.Str(StringsSigns.WHITE_SPACE), - TextReference.Str(formattedDate.time), - ), - ), - ) - } - } -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/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..60ea3e9f82 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt @@ -0,0 +1,331 @@ +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, + relatedNews = state.relatedNews, + ) + } + } +} + +@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..434ba73d53 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -0,0 +1,274 @@ +package com.tangem.features.feed.ui.market.detailed.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onFirstVisible +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.news.ArticleCard +import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.components.items.DescriptionItem +import com.tangem.core.ui.components.items.DescriptionPlaceholder +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM +import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM.RelatedNews + +private const val FOURTH_ITEM_INDEX = 3 + +@Suppress("CanBeNonNullable") // TODO will be removed after [REDACTED_JIRA] +internal fun LazyListScope.tokenMarketDetailsBody( + state: MarketsTokenDetailsUM.Body, + isAccountEnabled: Boolean, + portfolioBlock: @Composable ((Modifier) -> Unit)?, + relatedNews: RelatedNews, +) { + when (state) { + MarketsTokenDetailsUM.Body.Loading -> { + 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 (relatedNews.articles.isNotEmpty()) { + relatedNews(relatedNews) + } + + 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()) + } +} + +private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { + item("related-news") { + val listState = rememberLazyListState() + val articlesReadStatus = remember(relatedNews.articles) { + relatedNews.articles.map { it.isViewed } + } + LaunchedEffect(articlesReadStatus) { + listState.requestScrollToItem(0) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp, top = 20.dp) + .onFirstVisible( + minFractionVisible = 0.5f, + callback = relatedNews.onFirstVisible, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + modifier = Modifier.padding(start = 16.dp), + text = stringResourceSafe(R.string.news_related_news), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + + LazyRow( + verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + state = listState, + ) { + itemsIndexed( + items = relatedNews.articles, + key = { index, article -> article.id }, + ) { index, article -> + val articleModifier = if (index == FOURTH_ITEM_INDEX) { + Modifier.onFirstVisible( + minFractionVisible = 0.5f, + callback = relatedNews.onScroll, + ) + } else { + Modifier + } + + ArticleCard( + articleConfigUM = article, + onArticleClick = { relatedNews.onArticledClicked(article.id) }, + modifier = articleModifier + .height(164.dp) + .width(216.dp), + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), + ) + } + } + } + } +} + +@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..2c578fbe68 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt @@ -0,0 +1,145 @@ +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.* +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, + relatedNews = MarketsTokenDetailsUM.RelatedNews( + articles = persistentListOf(), + onArticledClicked = {}, + onFirstVisible = {}, + onScroll = {}, + ), + ) + + 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, + relatedNews = MarketsTokenDetailsUM.RelatedNews( + articles = persistentListOf(), + onArticledClicked = {}, + onFirstVisible = {}, + onScroll = {}, + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/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..e150d95894 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt @@ -0,0 +1,82 @@ +package com.tangem.features.feed.ui.market.detailed.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.charts.state.MarketChartDataProducer +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.markets.PriceChangeInterval +import kotlinx.collections.immutable.ImmutableList +import java.math.BigDecimal + +internal data class MarketsTokenDetailsUM( + 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, + val relatedNews: RelatedNews, +) { + + 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, + ) + + data class RelatedNews( + val articles: ImmutableList, + val onArticledClicked: (id: Int) -> Unit, + val onFirstVisible: () -> Unit, + val onScroll: () -> 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..32e675caff --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt @@ -0,0 +1,390 @@ +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.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +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" +private const val DELAY_FOR_FOCUS_REQUEST = 500L + +@Composable +internal fun TopBarWithSearch( + onBackClick: () -> Unit, + onSearchClick: () -> Unit, + marketsSearchBar: MarketsSearchBar, + bottomSheetState: BottomSheetState = BottomSheetState.EXPANDED, +) { + val background = LocalMainBottomSheetColor.current.value + val focusRequester = remember { FocusRequester() } + + val shouldShowAppBar = !marketsSearchBar.shouldAlwaysShowSearchBar && + !marketsSearchBar.searchBarUM.isActive && + marketsSearchBar.searchBarUM.query.isEmpty() + + AnimatedContent( + targetState = shouldShowAppBar, + label = "TopBarTransition", + ) { showAppBar -> + if (showAppBar) { + AppBarWithBackButtonAndIcon( + onBackClick = onBackClick, + backButtonEnabled = bottomSheetState == BottomSheetState.EXPANDED, + endButtonEnabled = bottomSheetState == BottomSheetState.EXPANDED, + 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 = 8.dp), + state = marketsSearchBar.searchBarUM, + colors = TangemSearchBarDefaults.defaultTextFieldColors.copy( + focusedContainerColor = TangemTheme.colors.field.focused, + unfocusedContainerColor = TangemTheme.colors.field.focused, + ), + focusRequester = focusRequester, + ) + + val shouldRequestFocus = + (marketsSearchBar.shouldAlwaysShowSearchBar || marketsSearchBar.searchBarUM.isActive) && + bottomSheetState == BottomSheetState.EXPANDED + if (shouldRequestFocus) { + LaunchedEffect(bottomSheetState) { + delay(DELAY_FOR_FOCUS_REQUEST) + 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, + ) + } + + AnimatedVisibility( + state.list !is ListUM.LoadingError && state.isInSearchMode.not() && + state.selectedSortBy != SortByTypeUM.YieldSupply, + ) { + val wrappedNotification = remember(state.list) { state.marketsNotificationUM } + val showMore = stringResourceSafe(R.string.common_show_more) + + when (wrappedNotification) { + 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 = wrappedNotification.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..917bd83e8c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt @@ -0,0 +1,216 @@ +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.SpacerH12 +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, + ), + ) + SpacerH12() + } +} + +@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/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt new file mode 100644 index 0000000000..2aad7230ef --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt @@ -0,0 +1,390 @@ +package com.tangem.features.feed.ui.news.details + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import coil.request.CachePolicy +import coil.request.ImageRequest +import com.tangem.common.ui.news.ArticleHeader +import com.tangem.core.ui.R +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.buttons.common.TangemButtonSize +import com.tangem.core.ui.components.pager.PagerIndicator +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.ui.news.details.components.NewsDetailsPlaceholder +import com.tangem.features.feed.ui.news.details.components.RelatedTokensBlock +import com.tangem.features.feed.ui.news.details.state.* + +@Composable +internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modifier) { + val background = LocalMainBottomSheetColor.current.value + AnimatedContent( + targetState = state.articlesStateUM, + modifier = modifier, + ) { animatedState -> + when (animatedState) { + ArticlesStateUM.Content -> { + Content(state = state, background = background) + } + ArticlesStateUM.Loading -> { + NewsDetailsPlaceholder(background = background) + } + is ArticlesStateUM.LoadingError -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(background), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData( + onRetryClick = animatedState.onRetryClicked, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 35.dp, horizontal = 10.dp), + ) + } + } + } + } +} + +@Composable +private fun Content(state: NewsDetailsUM, background: Color) { + val pagerState = rememberPagerState( + initialPage = state.selectedArticleIndex, + pageCount = { state.articles.size }, + ) + + if (state.articles.isNotEmpty()) { + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .collect { page -> + state.onArticleIndexChanged(page) + } + } + } + + LaunchedEffect(state.selectedArticleIndex) { + if (pagerState.currentPage != state.selectedArticleIndex) { + pagerState.scrollToPage(state.selectedArticleIndex) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(background), + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (state.articles.isNotEmpty()) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + val article = state.articles[page] + ArticleDetail( + article = article, + modifier = Modifier.fillMaxSize(), + onLikeClick = { state.onLikeClick(article.id) }, + relatedTokensUM = state.relatedTokensUM, + ) + } + if (state.articles.size > 1) { + PagerIndicator( + pagerState = pagerState, + modifier = Modifier + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars), + ) + } + } + } + } +} + +@Suppress("LongMethod") +@Composable +private fun ArticleDetail( + article: ArticleUM, + onLikeClick: () -> Unit, + relatedTokensUM: RelatedTokensUM, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val background = LocalMainBottomSheetColor.current.value + val pagerHeight = 32.dp + val contentPadding = pagerHeight + 56.dp + with(density) { + WindowInsets.navigationBars.getBottom(this).div(this.density) + }.dp + + Box(modifier = modifier) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = contentPadding), + ) { + item("content") { + ArticleHeader( + title = article.title, + createdAt = article.createdAt.resolveReference(), + score = article.score, + tags = article.tags, + modifier = Modifier + .padding(top = 16.dp) + .padding(horizontal = 16.dp), + ) + + if (article.shortContent.isNotEmpty()) { + QuickRecap( + content = article.shortContent, + modifier = Modifier + .padding(top = 32.dp) + .padding(horizontal = 16.dp), + ) + } + + Text( + text = article.content, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding(top = 16.dp) + .padding(horizontal = 16.dp), + ) + + SpacerH(24.dp) + + if (article.isLiked) { + PrimaryButtonIconStart( + modifier = Modifier.padding(horizontal = 16.dp), + iconResId = R.drawable.ic_heart_20, + text = stringResourceSafe(R.string.news_like), + size = TangemButtonSize.RoundedAction, + onClick = { onLikeClick() }, + ) + } else { + SecondaryButtonIconStart( + modifier = Modifier.padding(horizontal = 16.dp), + iconResId = R.drawable.ic_heart_20, + text = stringResourceSafe(R.string.news_like), + size = TangemButtonSize.RoundedAction, + onClick = { onLikeClick() }, + ) + } + + RelatedTokensBlock( + relatedTokensUM = relatedTokensUM, + onItemClick = when (relatedTokensUM) { + is RelatedTokensUM.Content -> relatedTokensUM.onTokenClick + else -> null + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + if (article.sources.isNotEmpty()) { + SpacerH(24.dp) + Row( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResourceSafe(R.string.news_sources), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = "${article.sources.size}", + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } + + if (article.sources.isNotEmpty()) { + item("sources") { + LazyRow( + modifier = Modifier.padding(vertical = 12.dp), + state = rememberLazyListState(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = article.sources, + key = SourceUM::id, + ) { source -> + SourceItem( + source = source, + modifier = Modifier.fillParentMaxHeight(), + ) + } + } + } + } + } + BottomFade( + modifier = Modifier + .align(Alignment.BottomCenter), + backgroundColor = background, + ) + } +} + +@Composable +private fun QuickRecap(content: String, modifier: Modifier = Modifier) { + Box( + modifier = modifier.height(IntrinsicSize.Min), + ) { + VerticalDivider( + modifier = Modifier.fillMaxHeight(), + thickness = 2.dp, + color = TangemTheme.colors.stroke.primary, + ) + Column( + modifier = Modifier + .padding(start = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_quick_recap_16), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResourceSafe(R.string.news_quick_recap), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.accent, + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = content, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + } + } +} + +@Composable +private fun SourceItem(source: SourceUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .sizeIn(maxWidth = 256.dp, minHeight = 132.dp) + .background(color = TangemTheme.colors.background.action, shape = RoundedCornerShape(12.dp)) + .clickable(onClick = source.onClick) + .padding(12.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 4.dp)) { + Icon( + painter = painterResource(id = R.drawable.ic_explore_16), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier.size(16.dp), + ) + SpacerW(4.dp) + Text( + text = source.source.name, + style = TangemTheme.typography.caption1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = TangemTheme.colors.text.tertiary, + ) + } + if (source.title.isNotEmpty()) { + SpacerH(4.dp) + Text( + text = source.title, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (source.imageUrl != null) { + SubcomposeAsyncImage( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop, + model = ImageRequest.Builder(context = LocalContext.current) + .data(source.imageUrl) + .crossfade(enable = false) + .allowHardware(true) + .memoryCachePolicy(CachePolicy.DISABLED) + .build(), + loading = { + RectangleShimmer( + modifier = Modifier.size(40.dp), + radius = 4.dp, + ) + }, + error = {}, + contentDescription = source.source.name, + ) + } + } + SpacerHMax() + Text( + text = source.publishedAt.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewNewsDetailsContent() { + TangemThemePreview { + NewsDetailsContent( + state = NewsDetailsUM( + articlesStateUM = ArticlesStateUM.Content, + articles = MockArticlesFactory.createMockArticles(), + selectedArticleIndex = 0, + onShareClick = {}, + onLikeClick = {}, + onBackClick = {}, + onArticleIndexChanged = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt new file mode 100644 index 0000000000..e985004e1b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt @@ -0,0 +1,57 @@ +package com.tangem.features.feed.ui.news.details.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerH + +@Composable +fun NewsDetailsPlaceholder(background: Color, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize().background(background).padding(16.dp), + ) { + RectangleShimmer(modifier = Modifier.size(width = 112.dp, height = 20.dp)) + SpacerH(8.dp) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(28.dp), + ) + SpacerH(4.dp) + RectangleShimmer(modifier = Modifier.size(height = 28.dp, width = 208.dp)) + SpacerH(20.dp) + RectangleShimmer(modifier = Modifier.size(height = 36.dp, width = 99.dp), radius = 12.dp) + SpacerH(32.dp) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 24.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 70.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 96.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 100.dp), + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt new file mode 100644 index 0000000000..d43ebbb5a9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt @@ -0,0 +1,73 @@ +package com.tangem.features.feed.ui.news.details.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.markets.MarketsListItem +import com.tangem.common.ui.markets.MarketsListItemPlaceholder +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.model.news.details.NewsDetailsModel.Companion.RELATED_TOKEN_MAX_COUNT +import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM + +@Composable +internal fun RelatedTokensBlock( + relatedTokensUM: RelatedTokensUM, + onItemClick: ((MarketsListItemUM) -> Unit)?, + modifier: Modifier = Modifier, +) { + val isVisible = remember(relatedTokensUM) { + when (relatedTokensUM) { + is RelatedTokensUM.Content -> relatedTokensUM.items.isNotEmpty() + RelatedTokensUM.Loading -> true + RelatedTokensUM.LoadingError -> false + } + } + + if (!isVisible) return + + Column(modifier = modifier) { + SpacerH(40.dp) + Text( + text = stringResourceSafe(R.string.news_related_tokens), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + SpacerH(12.dp) + + BlockCard( + colors = TangemBlockCardColors.copy( + containerColor = TangemTheme.colors.background.action, + ), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + when (relatedTokensUM) { + is RelatedTokensUM.Content -> { + relatedTokensUM.items.fastForEach { marketsListItemUM -> + MarketsListItem( + model = marketsListItemUM, + onClick = { onItemClick?.invoke(marketsListItemUM) }, + ) + } + } + RelatedTokensUM.Loading -> { + repeat(RELATED_TOKEN_MAX_COUNT) { + MarketsListItemPlaceholder() + } + } + RelatedTokensUM.LoadingError -> Unit + } + } + } + } +} \ No newline at end of file diff --git a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/MockArticlesFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt similarity index 72% rename from features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/MockArticlesFactory.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt index ac35bb6ee4..7f051d52fc 100644 --- a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/MockArticlesFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt @@ -1,22 +1,19 @@ -package com.tangem.features.news.details.impl +package com.tangem.features.feed.ui.news.details.state import com.tangem.core.ui.components.label.entity.LabelSize import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference -import com.tangem.features.news.details.impl.ui.ArticleUM -import com.tangem.features.news.details.impl.ui.SourceUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList -// TODO [REDACTED_TASK_KEY] remove mock data @Suppress("MaximumLineLength", "LongMethod") -object MockArticlesFactory { +internal object MockArticlesFactory { fun createMockArticles(): ImmutableList = listOf( ArticleUM( id = 1, title = "SEC delays decisions on ETH-staking ETFs and spot XRP/SOL funds", - createdAt = "20 Jun, 21:45", + createdAt = TextReference.Str("20 Jun, 21:45"), score = 6.5f, tags = listOf( LabelUM(text = TextReference.Str("Regulation"), size = LabelSize.BIG), @@ -27,23 +24,36 @@ object MockArticlesFactory { SourceUM( id = 1, title = "Deeper liquidity could drive crypto market beyond \$6T", - sourceName = "cointelegraph", - publishedAt = "1h ago", + source = Source( + id = 11, + name = "Coin-telegraph", + ), + publishedAt = TextReference.Str("1h ago"), url = "https://cointelegraph.com", + onClick = {}, + imageUrl = null, ), SourceUM( id = 2, title = "Top gainers and losers in crypto this week", - sourceName = "Investing", - publishedAt = "2h ago", + source = Source( + id = 10, + name = "Investing", + ), + publishedAt = TextReference.Str("2h ago"), url = "https://investing.com", + onClick = {}, + imageUrl = null, ), ).toPersistentList(), + newsUrl = "", + relatedTokens = persistentListOf(), + isLiked = true, ), ArticleUM( id = 2, title = "Bitcoin ETFs log 4th straight day of inflows (+\$550M)", - createdAt = "20 Jun, 20:15", + createdAt = TextReference.Str("20 Jun, 20:15"), score = 8.2f, tags = listOf( LabelUM(text = TextReference.Str("BTC"), size = LabelSize.BIG), @@ -55,16 +65,24 @@ object MockArticlesFactory { SourceUM( id = 3, title = "Bitcoin ETFs see massive inflows", - sourceName = "Bloomberg", - publishedAt = "3h ago", + source = Source( + id = 12, + name = "Bloomberg", + ), + publishedAt = TextReference.Str("3h ago"), url = "https://bloomberg.com", + onClick = {}, + imageUrl = null, ), ).toPersistentList(), + newsUrl = "", + relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 3, title = "Ethereum network upgrade scheduled for Q2 2025", - createdAt = "20 Jun, 18:30", + createdAt = TextReference.Str("20 Jun, 18:30"), score = 7.8f, tags = listOf( LabelUM(text = TextReference.Str("ETH"), size = LabelSize.BIG), @@ -76,16 +94,24 @@ object MockArticlesFactory { SourceUM( id = 4, title = "Ethereum core devs announce upgrade", - sourceName = "CoinDesk", - publishedAt = "5h ago", + source = Source( + id = 15, + name = "CoinDesk", + ), + publishedAt = TextReference.Str("5h ago"), url = "https://coindesk.com", + onClick = {}, + imageUrl = null, ), ).toPersistentList(), + newsUrl = "", + relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 4, title = "Solana surpasses Ethereum in daily transaction volume", - createdAt = "20 Jun, 16:00", + createdAt = TextReference.Str("20 Jun, 16:00"), score = 9.1f, tags = listOf( LabelUM(text = TextReference.Str("SOL"), size = LabelSize.BIG), @@ -97,16 +123,24 @@ object MockArticlesFactory { SourceUM( id = 5, title = "Solana transactions hit record", - sourceName = "The Block", - publishedAt = "7h ago", + source = Source( + id = 18, + name = "Times", + ), + publishedAt = TextReference.Str("7h ago"), url = "https://theblock.co", + onClick = {}, + imageUrl = null, ), ).toPersistentList(), + newsUrl = "", + relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 5, title = "DeFi protocol launches innovative yield farming strategy", - createdAt = "20 Jun, 14:20", + createdAt = TextReference.Str("20 Jun, 14:20"), score = 6.9f, tags = listOf( LabelUM(text = TextReference.Str("DeFi"), size = LabelSize.BIG), @@ -114,11 +148,14 @@ object MockArticlesFactory { shortContent = "New DeFi protocol introduced innovative yield farming approach.", content = "A newly launched protocol unveiled innovative yield farming mechanism.\n\nAPY rates range from 15% to 30%.", sources = persistentListOf(), + newsUrl = "", + relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 6, title = "Crypto regulation bill advances in US Senate", - createdAt = "20 Jun, 12:45", + createdAt = TextReference.Str("20 Jun, 12:45"), score = 8.7f, tags = listOf( LabelUM(text = TextReference.Str("Regulation"), size = LabelSize.BIG), @@ -127,11 +164,14 @@ object MockArticlesFactory { shortContent = "Comprehensive cryptocurrency regulation bill passed Senate Banking Committee.", content = "The US Senate Banking Committee advanced landmark crypto regulation bill.\n\nKey provisions include asset definitions.", sources = persistentListOf(), + newsUrl = "", + relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 7, title = "Major bank announces crypto custody services", - createdAt = "20 Jun, 10:30", + createdAt = TextReference.Str("20 Jun, 10:30"), score = 7.3f, tags = listOf( LabelUM(text = TextReference.Str("Adoption"), size = LabelSize.BIG), @@ -139,11 +179,14 @@ object MockArticlesFactory { shortContent = "World's largest bank announced cryptocurrency custody services.", content = "Major financial institution announced comprehensive crypto custody services.\n\nSupporting Bitcoin and Ethereum initially.", sources = persistentListOf(), + newsUrl = "", + relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 8, title = "NFT marketplace reports 300% increase in trading volume", - createdAt = "20 Jun, 08:15", + createdAt = TextReference.Str("20 Jun, 08:15"), score = 5.8f, tags = listOf( LabelUM(text = TextReference.Str("NFT"), size = LabelSize.BIG), @@ -151,11 +194,14 @@ object MockArticlesFactory { shortContent = "Leading NFT marketplace experienced dramatic surge in trading activity.", content = "Prominent NFT marketplace reported 300% increase in trading volume.\n\nNew features include lower fees.", sources = persistentListOf(), + newsUrl = "", + relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 9, title = "Layer 2 solution achieves 100,000 TPS milestone", - createdAt = "19 Jun, 22:00", + createdAt = TextReference.Str("19 Jun, 22:00"), score = 8.5f, tags = listOf( LabelUM(text = TextReference.Str("Technology"), size = LabelSize.BIG), @@ -164,11 +210,14 @@ object MockArticlesFactory { shortContent = "New Layer 2 scaling solution achieved 100,000 TPS in testing.", content = "Layer 2 solution processed 100,000 transactions per second.\n\nUsing zero-knowledge proof technology.", sources = persistentListOf(), + newsUrl = "", + relatedTokens = persistentListOf(), + isLiked = false, ), ArticleUM( id = 10, title = "Stablecoin market cap reaches new all-time high", - createdAt = "19 Jun, 19:30", + createdAt = TextReference.Str("19 Jun, 19:30"), score = 7.6f, tags = listOf( LabelUM(text = TextReference.Str("Stablecoins"), size = LabelSize.BIG), @@ -177,6 +226,9 @@ object MockArticlesFactory { shortContent = "Total stablecoin market capitalization surpassed \$180 billion.", content = "Stablecoin market cap reached \$180 billion all-time high.\n\nDriven by DeFi activity and institutional adoption.", sources = persistentListOf(), + newsUrl = "", + relatedTokens = persistentListOf(), + isLiked = false, ), ).toPersistentList() } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt new file mode 100644 index 0000000000..a3402431c2 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt @@ -0,0 +1,68 @@ +package com.tangem.features.feed.ui.news.details.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.news.RelatedToken +import kotlinx.collections.immutable.ImmutableList + +internal data class NewsDetailsUM( + val articlesStateUM: ArticlesStateUM, + val articles: ImmutableList, + val selectedArticleIndex: Int, + val onShareClick: () -> Unit, + val onLikeClick: (articleId: Int) -> Unit, + val onBackClick: () -> Unit, + val onArticleIndexChanged: (Int) -> Unit, + val relatedTokensUM: RelatedTokensUM = RelatedTokensUM.Loading, +) + +@Immutable +internal sealed interface ArticlesStateUM { + data object Loading : ArticlesStateUM + data object Content : ArticlesStateUM + data class LoadingError(val onRetryClicked: () -> Unit) : ArticlesStateUM +} + +internal data class ArticleUM( + val id: Int, + val title: String, + val createdAt: TextReference, + val score: Float, + val tags: ImmutableList, + val shortContent: String, + val content: String, + val sources: ImmutableList, + val newsUrl: String, + val relatedTokens: ImmutableList, + val isLiked: Boolean, +) + +internal data class SourceUM( + val id: Int, + val title: String, + val source: Source, + val publishedAt: TextReference, + val url: String, + val onClick: () -> Unit, + val imageUrl: String?, +) + +@Immutable +internal sealed interface RelatedTokensUM { + + data class Content( + val items: ImmutableList, + val onTokenClick: (MarketsListItemUM) -> Unit, + ) : RelatedTokensUM + + data object Loading : RelatedTokensUM + + data object LoadingError : RelatedTokensUM +} + +internal data class Source( + val id: Int, + val name: String, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt new file mode 100644 index 0000000000..5dd1413007 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt @@ -0,0 +1,141 @@ +package com.tangem.features.feed.ui.news.list + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.chip.Chip +import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.ui.news.list.components.NewsListLazyColumn +import com.tangem.features.feed.ui.news.list.state.NewsListState +import com.tangem.features.feed.ui.news.list.state.NewsListUM +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableSet + +@Composable +internal fun NewsListContent(state: NewsListUM, modifier: Modifier = Modifier) { + val background = LocalMainBottomSheetColor.current.value + val lazyListState = rememberLazyListState() + + Column( + modifier = modifier + .fillMaxSize() + .background(background), + ) { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = state.filters, + key = { it.id }, + ) { filter -> + Chip(state = filter) + } + } + + SpacerH(16.dp) + + NewsListLazyColumn( + newsListState = state.newsListState, + listOfArticles = state.listOfArticles, + lazyListState = lazyListState, + onArticleClick = state.onArticleClick, + ) + } +} + +@Suppress("LongMethod") +@Preview(showBackground = true) +@Composable +private fun NewsListContentPreview() { + val tags = listOf( + LabelUM(TextReference.Str("Regulation")), + LabelUM(TextReference.Str("BTC")), + LabelUM(TextReference.Str("ETH")), + ).toImmutableSet() + + val articles = persistentListOf( + ArticleConfigUM( + id = 1, + title = "SEC delays decisions on ETH-staking ETFs and spot XRP/SOL funds", + score = 6.5f, + createdAt = TextReference.Str("1h ago"), + isTrending = false, + tags = tags, + isViewed = false, + ), + ArticleConfigUM( + id = 2, + title = "Bitcoin ETFs log 4th straight day of inflows (+\$550M)", + score = 8.6f, + createdAt = TextReference.Str("8h ago"), + isTrending = false, + tags = tags, + isViewed = true, + ), + ArticleConfigUM( + id = 3, + title = "Bitcoin reclaims ~\$115K amid macro prints and ETF optimism", + score = 7.8f, + createdAt = TextReference.Str("22 Jun, 11:30"), + isTrending = false, + tags = tags, + isViewed = false, + ), + ) + + val filters = persistentListOf( + ChipUM( + id = 0, + text = TextReference.Str("All News"), + isSelected = true, + onClick = {}, + ), + ChipUM( + id = 1, + text = TextReference.Str("Regulation"), + isSelected = false, + onClick = {}, + ), + ChipUM( + id = 2, + text = TextReference.Str("ETFs"), + isSelected = false, + onClick = {}, + ), + ChipUM( + id = 3, + text = TextReference.Str("Institutions"), + isSelected = false, + onClick = {}, + ), + ) + + TangemThemePreview { + NewsListContent( + state = NewsListUM( + selectedCategoryId = 0, + filters = filters, + listOfArticles = articles, + newsListState = NewsListState.Content(loadMore = {}), + onArticleClick = {}, + onBackClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt new file mode 100644 index 0000000000..465c76d0f9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt @@ -0,0 +1,169 @@ +package com.tangem.features.feed.ui.news.list.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.news.ArticleCard +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.common.ui.news.DefaultLoadingArticle +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.components.list.InfiniteListHandler +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.ui.news.list.state.NewsListState +import kotlinx.collections.immutable.ImmutableList + +private const val LOAD_NEXT_PAGE_ON_END_INDEX = 40 + +@Composable +internal fun NewsListLazyColumn( + listOfArticles: ImmutableList, + newsListState: NewsListState, + lazyListState: LazyListState, + onArticleClick: (Int) -> Unit, +) { + val screenState by remember(listOfArticles, newsListState) { + derivedStateOf { + val isListEmpty = listOfArticles.isEmpty() + when { + isListEmpty && newsListState is NewsListState.Loading -> NewsListScreenState.InitialLoading + isListEmpty && newsListState is NewsListState.LoadingError -> NewsListScreenState.InitialError + else -> NewsListScreenState.Content + } + } + } + + AnimatedContent( + transitionSpec = { + fadeIn() togetherWith fadeOut() + }, + targetState = screenState, + label = "NewsListTransition", + ) { state -> + when (state) { + NewsListScreenState.Content -> { + Content( + listOfArticles = listOfArticles, + newsListState = newsListState, + lazyListState = lazyListState, + onArticleClick = onArticleClick, + ) + } + NewsListScreenState.InitialLoading -> { + LazyColumn( + state = rememberLazyListState(), + contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp), + userScrollEnabled = false, + ) { + items( + count = 10, + key = { "initial_loading_$it" }, + ) { + DefaultLoadingArticle( + modifier = Modifier + .fillMaxWidth() + .height(164.dp), + ) + SpacerH(12.dp) + } + } + } + NewsListScreenState.InitialError -> { + val errorState = newsListState as? NewsListState.LoadingError + LoadingErrorItem( + modifier = Modifier.fillMaxSize(), + onTryAgain = errorState?.onRetryClicked ?: {}, + ) + } + } + } +} + +@Composable +private fun Content( + listOfArticles: ImmutableList, + newsListState: NewsListState, + lazyListState: LazyListState, + onArticleClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + state = lazyListState, + contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp), + userScrollEnabled = true, + ) { + items( + items = listOfArticles, + key = ArticleConfigUM::id, + ) { article -> + ArticleCard( + modifier = Modifier.fillMaxWidth(), + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), + articleConfigUM = article, + onArticleClick = { + onArticleClick(article.id) + }, + ) + SpacerH(12.dp) + } + + if (newsListState is NewsListState.Loading) { + items( + count = 10, + key = { "loading_footer_$it" }, + ) { + DefaultLoadingArticle( + modifier = Modifier + .fillMaxWidth() + .height(164.dp), + ) + SpacerH(12.dp) + } + } + } + + if (newsListState is NewsListState.Content) { + InfiniteListHandler( + listState = lazyListState, + buffer = LOAD_NEXT_PAGE_ON_END_INDEX, + triggerLoadMoreCheckOnItemsCountChange = true, + onLoadMore = remember(newsListState) { + { + newsListState.loadMore() + true + } + }, + ) + } +} + +private enum class NewsListScreenState { + InitialLoading, + InitialError, + Content, +} + +@Composable +private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier.padding(vertical = 35.dp, horizontal = 10.dp), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData(onRetryClick = onTryAgain) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt new file mode 100644 index 0000000000..12fe9861f3 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt @@ -0,0 +1,23 @@ +package com.tangem.features.feed.ui.news.list.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.core.ui.components.chip.entity.ChipUM +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class NewsListUM( + val selectedCategoryId: Int, + val filters: ImmutableList, + val listOfArticles: ImmutableList, + val newsListState: NewsListState, + val onArticleClick: (Int) -> Unit, + val onBackClick: () -> Unit, +) + +@Immutable +sealed class NewsListState { + data class Content(val loadMore: () -> Unit) : NewsListState() + data object Loading : NewsListState() + data class LoadingError(val onRetryClicked: () -> Unit) : NewsListState() +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt new file mode 100644 index 0000000000..b1052994f0 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt @@ -0,0 +1,40 @@ +package com.tangem.features.feed.ui.utils + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.FormattedDate +import com.tangem.core.ui.utils.getFormattedDate +import com.tangem.features.feed.impl.R +import com.tangem.utils.StringsSigns +import org.joda.time.DateTime + +internal fun mapFormattedDate(createdAt: String): TextReference { + val formattedDate = getFormattedDate( + createdAt = createdAt, + now = DateTime.now(), + ) + return when (formattedDate) { + is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date) + is FormattedDate.HoursAgo -> TextReference.PluralRes( + id = R.plurals.news_published_hours_ago, + count = formattedDate.hours, + formatArgs = wrappedList(formattedDate.hours), + ) + is FormattedDate.MinutesAgo -> TextReference.PluralRes( + id = R.plurals.news_published_minutes_ago, + count = formattedDate.minutes, + formatArgs = wrappedList(formattedDate.minutes), + ) + is FormattedDate.Today -> TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(R.string.common_today), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str(formattedDate.time), + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt new file mode 100644 index 0000000000..638e28ca11 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt @@ -0,0 +1,71 @@ +package com.tangem.features.feed.ui.utils + +import androidx.compose.animation.core.tween +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.FaultyDecomposeApi +import com.arkivanov.decompose.extensions.compose.stack.animation.* +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.core.ui.utils.toPx +import com.tangem.features.feed.components.FeedEntryChildFactory + +private const val DELAY_FOR_TRANSITION = 400 + +@OptIn(FaultyDecomposeApi::class) +internal fun topBarFeedEntryStackAnimation(): StackAnimation< + FeedEntryChildFactory.Child, + ComposableModularBottomSheetContentComponent, + > = + stackAnimation { to, from, _ -> + val isSearchToTokenList = + (to.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true + val isFromSearchTokenList = + (from.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true + if (isSearchToTokenList || isFromSearchTokenList) { + fade() + } else { + slide() + } + } + +@OptIn(FaultyDecomposeApi::class) +internal fun contentFeedEntryStackAnimation(): StackAnimation< + FeedEntryChildFactory.Child, + ComposableModularBottomSheetContentComponent, + > = + stackAnimation { to, from, _ -> + val isSearchToTokenList = + (to.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true + val isFromSearchTokenList = + (from.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true + if (isSearchToTokenList || isFromSearchTokenList) { + fadeAndSlideInt() + } else { + slide() + } + } + +private fun fadeAndSlideInt(): StackAnimator = + stackAnimator(animationSpec = tween(DELAY_FOR_TRANSITION)) { factor, _, content -> + val alpha = 1f - kotlin.math.abs(factor) + val slidePx = 32.dp.toPx() + val yOffset = when { + factor > 0 -> (slidePx * factor).toInt() + factor < 0 -> (slidePx * factor * -1).toInt() + else -> 0 + } + content( + Modifier + .alpha(alpha) + .offsetY(yOffset), + ) + } + +private fun Modifier.offsetY(pixels: Int): Modifier = layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative(x = 0, y = pixels) + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/detekt-baseline-debug.xml b/features/hot-wallet/impl/detekt-baseline-debug.xml index 2e3bf90a57..0695c0d515 100644 --- a/features/hot-wallet/impl/detekt-baseline-debug.xml +++ b/features/hot-wallet/impl/detekt-baseline-debug.xml @@ -20,7 +20,6 @@ BooleanPropertyNaming:WalletHardwareBackupUM.kt$WalletHardwareBackupUM$val showPurchaseBlock: Boolean = false MultilineLambdaItParameter:AddExistingWalletImportModel.kt$AddExistingWalletImportModel${ Timber.e(it) setImportProgress(false) } MultilineLambdaItParameter:AddExistingWalletImportModel.kt$AddExistingWalletImportModel${ setImportProgress(false) when (it) { is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet") is SaveWalletError.WalletAlreadySaved -> { uiMessageSender.send( SnackbarMessage(resourceReference(R.string.hw_import_seed_phrase_already_imported)), ) } } } - MultilineLambdaItParameter:CreateHardwareWalletModel.kt$CreateHardwareWalletModel${ delay(HIDE_PROGRESS_DELAY) setLoading(false) when (it) { is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet") is SaveWalletError.WalletAlreadySaved -> { userWalletsListRepository.unlock( userWalletId = userWallet.walletId, unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), ).onRight { router.replaceAll(AppRoute.Wallet) } } } } MultilineLambdaItParameter:ForgetWalletModel.kt$ForgetWalletModel${ Timber.e("Unable to delete wallet: $it") uiMessageSender.send( message = SnackbarMessage(resourceReference(R.string.common_unknown_error)), ) return@launch } MultilineLambdaItParameter:ForgetWalletModel.kt$ForgetWalletModel${ val newValue = !it.firstCheckboxChecked it.copy( firstCheckboxChecked = newValue, isForgetButtonEnabled = newValue && it.secondCheckboxChecked, ) } MultilineLambdaItParameter:ForgetWalletModel.kt$ForgetWalletModel${ val newValue = !it.secondCheckboxChecked it.copy( secondCheckboxChecked = newValue, isForgetButtonEnabled = it.firstCheckboxChecked && newValue, ) } diff --git a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt index ba0446ea8a..f729fe489c 100644 --- a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt +++ b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt @@ -6,6 +6,7 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.pay.KycStartInfo +import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.repository.KycRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -22,6 +23,7 @@ class DefaultKycModel @Inject constructor( analytics: AnalyticsEventHandler, override val dispatchers: CoroutineDispatcherProvider, private val kycRepository: KycRepository, + private val tangemPayEligibilityManager: TangemPayEligibilityManager, ) : Model() { private val params: KycComponent.Params = paramsContainer.require() @@ -33,7 +35,10 @@ class DefaultKycModel @Inject constructor( analytics.send(TangemPayAnalyticsEvents.KycFlowOpened()) modelScope.launch { try { - kycRepository.getKycStartInfo(params.userWalletId).getOrNull()?.let { _uiState.emit(it) } + kycRepository.getKycStartInfo(params.userWalletId).getOrNull()?.let { state -> + _uiState.emit(state) + tangemPayEligibilityManager.reset() + } } catch (e: Exception) { Timber.e(e) } diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt index a877ba39b6..3698d18280 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/choosetoken/model/ChooseManagedTokensModel.kt @@ -59,7 +59,7 @@ internal class ChooseManagedTokensModel @Inject constructor( private val params: ChooseManagedTokensComponent.Params = paramsContainer.require() private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory - .create(mode = ManageTokensMode.None) + .create(mode = ManageTokensMode.Wallet(params.userWalletId)) private val manageTokensListManager = manageTokensListManagerFactory.create( scope = modelScope, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt index 71f9caee9a..bcdfa62787 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt @@ -70,6 +70,7 @@ internal class PreviewCustomTokenSelectorComponent( ), name = "Network $index", type = "N$index", + currencySymbol = "N$index", iconResId = R.drawable.ic_eth_16, isMainNetwork = false, isSelected = n.id == params.selectedNetwork?.id, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt index 59513c7d2e..bf0778fc87 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt @@ -169,6 +169,7 @@ internal class PreviewManageTokensComponent( ), name = "NETWORK$networkIndex", type = "N$networkIndex", + currencySymbol = "N$networkIndex", iconResId = R.drawable.ic_eth_16, isMainNetwork = networkIndex == 0, isSelected = false, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt index 790bffdd7c..7907f421fa 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt @@ -108,6 +108,7 @@ internal class PreviewOnboardingManageTokensComponent( ), name = "NETWORK$networkIndex", type = "N$networkIndex", + currencySymbol = "N$networkIndex", iconResId = R.drawable.ic_eth_16, isMainNetwork = networkIndex == 0, isSelected = false, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/CurrencyNetworkUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/CurrencyNetworkUM.kt index 8376d37b21..73d1c1ecf8 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/CurrencyNetworkUM.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/item/CurrencyNetworkUM.kt @@ -6,6 +6,7 @@ internal data class CurrencyNetworkUM( val network: Network, val name: String, val type: String, + val currencySymbol: String, val iconResId: Int, val isMainNetwork: Boolean, val onLongClick: () -> Unit, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt index c57d03a4e8..2b6df47f38 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt @@ -215,6 +215,7 @@ internal class CustomTokenSelectorModel @Inject constructor( val accountName = when (account) { is Account.CryptoPortfolio -> account.accountName + is Account.Payment -> TODO("[REDACTED_JIRA]") null -> null } diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt index b023409947..29cb995a99 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenSelectorContent.kt @@ -158,7 +158,7 @@ private fun NetworkItem(model: CurrencyNetworkUM, modifier: Modifier = Modifier) model = with(model) { ChainRowUM( name = name, - type = type, + type = currencySymbol, icon = CurrencyIconState.CoinIcon( url = null, fallbackResId = model.iconResId, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/CurrencyNetworksMapper.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/CurrencyNetworksMapper.kt index c92243274e..04c677fe6e 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/CurrencyNetworksMapper.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/mapper/CurrencyNetworksMapper.kt @@ -48,6 +48,7 @@ internal fun Network.toCurrencyNetworkModel( iconResId = id.getIconRes(isColored = true), isSelected = isSelected, type = standardType.name, + currencySymbol = currencySymbol, onLongClick = {}, isMainNetwork = false, onSelectedStateChange = onSelectedStateChange, @@ -66,6 +67,7 @@ internal fun SourceNetwork.toCurrencyNetworkModel( iconResId = id.getIconRes(isColored = isSelected || !isEditable), isSelected = isSelected || !isEditable, type = typeName, + currencySymbol = network.currencySymbol, onLongClick = { onLongTap(this) }, isMainNetwork = this is SourceNetwork.Main, onSelectedStateChange = { selected -> 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/detekt-baseline-debug.xml b/features/markets/impl/detekt-baseline-debug.xml deleted file mode 100644 index d9fe99460d..0000000000 --- a/features/markets/impl/detekt-baseline-debug.xml +++ /dev/null @@ -1,153 +0,0 @@ - - - - - BooleanPropertyNaming:AddToPortfolioBSContentUM.kt$AddToPortfolioBSContentUM$val continueButtonEnabled: Boolean - BooleanPropertyNaming:AvailableToAddData.kt$AvailableToAddData$val availableToAdd: Boolean get() = availableToAddWallets.isNotEmpty() - BooleanPropertyNaming:AvailableToAddData.kt$SelectedNetwork$val availableMoreNetwork: Boolean - BooleanPropertyNaming:AvailableToAddData.kt$SelectedPortfolio$val availableMorePortfolio: Boolean - BooleanPropertyNaming:DefaultAddToPortfolioComponent.kt$DefaultAddToPortfolioComponent$val scrollableContent = when (stack.active.configuration) { AddToPortfolioRoutes.PortfolioSelector -> false AddToPortfolioRoutes.AddToken, AddToPortfolioRoutes.Empty, is AddToPortfolioRoutes.NetworkSelector, AddToPortfolioRoutes.TokenActions, -> true } - BooleanPropertyNaming:MarketsList.kt$val mainScrolled by remember { derivedStateOf { mainLazyListState.firstVisibleItemScrollOffset > 0 } } - BooleanPropertyNaming:MarketsList.kt$val searchScrolledState by remember { derivedStateOf { searchLazyListState.firstVisibleItemScrollOffset > 0 } } - BooleanPropertyNaming:MarketsListAnalyticsEvent.kt$MarketsListAnalyticsEvent.TokenSearched$val tokenFound: Boolean - BooleanPropertyNaming:MarketsListUM.kt$ListUM.Content$val showUnder100kTokensNotification: Boolean - BooleanPropertyNaming:MarketsListUM.kt$ListUM.Content$val showUnder100kTokensNotificationWasHidden: Boolean - BooleanPropertyNaming:MarketsListUMStateManager.kt$MarketsListUMStateManager$private var sortByBottomSheetIsShown get() = state.value.sortByBottomSheet.isShown set(value) = state.update { it.copy(sortByBottomSheet = it.sortByBottomSheet.copy(isShown = value)) } - BooleanPropertyNaming:MarketsTokenDetailsContent.kt$val showPriceSubtitle by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 1 } } - BooleanPropertyNaming:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel$val allWalletsIsHot = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } - BooleanPropertyNaming:MarketsTokenDetailsUM.kt$MarketsTokenDetailsUM$val markerSet: Boolean - BooleanPropertyNaming:MetricsBlock.kt$var expanded by remember { mutableStateOf(false) } - BooleanPropertyNaming:PortfolioBSVisibilityModel.kt$PortfolioBSVisibilityModel$val addToPortfolioBSVisibility: Boolean = false - BooleanPropertyNaming:PortfolioBSVisibilityModel.kt$PortfolioBSVisibilityModel$val walletSelectorBSVisibility: Boolean = false - BooleanPropertyNaming:PortfolioItem.kt$var quickActionsShown by remember { mutableStateOf(value = false) } - BooleanPropertyNaming:PortfolioUIData.kt$PortfolioUIData$val needColdWalletInteraction: Boolean - BooleanPropertyNaming:QuickActionUM.kt$QuickActionUM$val longClickAvailable: Boolean = false - BooleanPropertyNaming:QuickActionUM.kt$QuickActionUM.Exchange$val showBadge: Boolean - BooleanPropertyNaming:TokenActionsHandler.kt$TokenActionsHandler$val demoCard = isDemoCardUseCase.invoke(userWallet.cardId) - BooleanPropertyNaming:TokenActionsHandler.kt$TokenActionsHandler$val needShowDemoWarning = demoCard && disabledActionsInDemoMode.contains(action) - CanBeNonNullable:TokenMarketDetailsBody.kt$portfolioBlock: @Composable ((Modifier) -> Unit)? - CastNullableToNonNullableType:PortfolioItem.kt$PortfolioTokenUMProvider$as - CastNullableToNonNullableType:PortfolioItem.kt$PortfolioTokenUMProvider.Companion$as - MaxChainedCallsOnSameLine:AddToPortfolioModel.kt$AddToPortfolioModel$selectedPortfolio.first().account.account.account.accountId - MaxChainedCallsOnSameLine:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager$batchFlow.state.value.data.map { it.data }.flatten().find { it.id == id } - MaxChainedCallsOnSameLine:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager$batchFlow.state.value.data.map { it.key }.toSet() - MaxChainedCallsOnSameLine:MarketsListModel.kt$MarketsListModel$marketsListUMStateManager.state.map { it.selectedSortBy }.distinctUntilChanged().drop(1).collectLatest { mainMarketsListManager.reload() } - MaxChainedCallsOnSameLine:MarketsPortfolioModel.kt$MarketsPortfolioModel$handledAction.cryptoCurrencyData.status.currency.network.name - MaxChainedCallsOnSameLine:TokenActionsModel.kt$TokenActionsModel$handledAction.cryptoCurrencyData.status.currency.network.name - MultilineLambdaItParameter:AddToPortfolioBSContentUMFactory.kt$AddToPortfolioBSContentUMFactory${ if (it != selectedWalletId) { onAnotherWalletSelect(it) onWalletSelectorVisibilityChange(false) } } - MultilineLambdaItParameter:AddToPortfolioBottomSheet.kt${ Content( modifier = Modifier.fillMaxWidth(), state = it, ) WalletSelectorBottomSheet(it.walletSelectorConfig) } - MultilineLambdaItParameter:AddToPortfolioManager.kt$AddToPortfolioManager${ it.toMutableMap().apply { this[userWalletId] = if (isAddAction) { this[userWalletId].orEmpty() + network } else { this[userWalletId].orEmpty() - network } } } - MultilineLambdaItParameter:AddToPortfolioModel.kt$AddToPortfolioModel${ Timber.e(it) params.callback.onDismiss() } - MultilineLambdaItParameter:AddToPortfolioModel.kt$AddToPortfolioModel${ tokenActionsData.emit(it) navigation.replaceAll(AddToPortfolioRoutes.TokenActions) } - MultilineLambdaItParameter:AddTokenModel.kt$AddTokenModel${ processError(error = it) uiState.value = um.toggleProgress(false) return@launch } - MultilineLambdaItParameter:AvailableToAddDataConverter.kt$AvailableToAddDataConverter${ val (walletId, _) = it val availableToAddWallet = getAvailableToAddWallet(it) walletId to availableToAddWallet } - MultilineLambdaItParameter:ChooseNetworkModel.kt$ChooseNetworkModel${ Timber.e( it, """ Failed to check currency unsupported state |- User wallet ID: $userWalletId |- Network ID: $rawNetworkId |- Is main network: $isMainNetwork """.trimIndent(), ) val message = SnackbarMessage( message = it.localizedMessage?.let(::stringReference) ?: resourceReference(R.string.common_error), ) messageSender.send(message) null } - MultilineLambdaItParameter:DefaultMarketsTokenDetailsComponent.kt$DefaultMarketsTokenDetailsComponent${ when (it) { is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(it.networks) TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable() else -> {} } } - MultilineLambdaItParameter:DefaultMarketsTokenListComponent.kt$DefaultMarketsTokenListComponent${ MarketsListWithBack( modifier = Modifier .statusBarsPadding() .imePadding() .padding(it), state = state, bottomSheetState = BottomSheetState.EXPANDED, onBackClick = router::pop, ) } - MultilineLambdaItParameter:EntryBottomSheetContent.kt${ when (it.configuration) { is MarketsEntryChildFactory.Child.TokenDetails -> { (it.instance as MarketsTokenDetailsComponent).BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, modifier = modifier, ) } is MarketsEntryChildFactory.Child.TokenList -> { (it.instance as MarketsTokenListComponent).BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, modifier = modifier, ) } } } - MultilineLambdaItParameter:InsightsBlock.kt${ InfoPoint( modifier = Modifier.align(Alignment.CenterStart), infoPointUM = it, ) } - MultilineLambdaItParameter:InsightsBlock.kt${ currentInterval = it state.onIntervalChanged(it) } - MultilineLambdaItParameter:LinksBlock.kt${ Chip( text = stringReference(it.title), iconResId = it.iconRes, onClick = { onLinkClick(it) }, ) } - MultilineLambdaItParameter:MarketTokenDetailsChart.kt${ when (it) { MarketChartLook.Type.Growing -> growingColor MarketChartLook.Type.Falling -> fallingColor MarketChartLook.Type.Neutral -> neutralColor } } - MultilineLambdaItParameter:MarketsList.kt${ if (it.size.height > 0) { with(density) { onHeaderSizeChange(it.size.height.toDp()) } } } - MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ Batch( key = it.key, data = converter.convertList(it.data), ) } - MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ currentSearchText().isNullOrEmpty().not() && it.status is PaginationStatus.EndOfPagination && it.data.isEmpty() } - MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ val first = it.data.firstOrNull() ?: return@filter false val chartByInterval = when (interval) { TrendInterval.H24 -> first.tokenCharts.h24 TrendInterval.D7 -> first.tokenCharts.week TrendInterval.M1 -> first.tokenCharts.month } chartByInterval != null } - MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ when (val status = it.status) { is PaginationStatus.Paginating -> { if (status.lastResult is BatchFetchResult.Success) { it.data.lastOrNull()?.key } else { null } } is PaginationStatus.EndOfPagination -> { it.data.lastOrNull()?.key } else -> null } } - MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ when (val status = it.status) { is PaginationStatus.Paginating -> { if (status.lastResult is BatchFetchResult.Success) { it.data.size == 1 } else { null } } is PaginationStatus.EndOfPagination -> { it.data.size == 1 } else -> null } } - MultilineLambdaItParameter:MarketsListItem.kt${ if (Random.nextBoolean()) { it.first.inc() to PriceChangeType.UP } else { it.first.dec() to PriceChangeType.DOWN } } - MultilineLambdaItParameter:MarketsListLazyColumn.kt${ (it.key as? String)?.split(TOKEN_LAZY_LIST_ID_SEPARATOR)?.first() ?.let { rawId -> CryptoCurrency.RawID(rawId) } } - MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ if (it) { analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(tokenFound = false)) } } - MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ if (it.isNotEmpty()) { activeListManager.getBatchKeysByItemIds(visibleItemIds.value) } else { null } } - MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ if (it.list !is ListUM.Content) { visibleItemIds.value = emptyList() } } - MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ mainMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) } - MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ searchMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) } - MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( list = if (it.list is ListUM.Content) { it.list.copy( triggerScrollReset = consumedEvent(), ) } else { it.list }, ) } - MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( price = it.price.copy( changeType = if (visibleItemIds.contains(it.id)) { it.price.changeType } else { null }, ), ) } - MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( searchBar = it.searchBar.copy( query = value, isActive = value.isNotEmpty(), ), ) } - MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( selectedInterval = value, list = if (it.list is ListUM.Content && it.selectedSortBy != SortByTypeUM.Rating && it.selectedInterval != value ) { it.list.copy(triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }) } else { it.list }, ) } - MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( selectedSortBy = sortByTypeUM, sortByBottomSheet = it.sortByBottomSheet.copy( isShown = false, content = (it.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( selectedOption = sortByTypeUM, ), ), ) } - MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ it.copy( selectedSortBy = value, sortByBottomSheet = it.sortByBottomSheet.copy( content = (it.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( selectedOption = value, ), ), list = if (it.list is ListUM.Content && it.selectedSortBy != value) { it.list.copy(triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }) } else { it.list }, ) } - MultilineLambdaItParameter:MarketsListUMStateManager.kt$MarketsListUMStateManager${ when { isInErrorState -> { it.copy( list = ListUM.LoadingError(onRetryClicked = onRetryButtonClicked), ) } isSearchNotFound -> { it.copy(list = ListUM.SearchNothingFound) } uiItems.isEmpty() -> { it.copy(list = ListUM.Loading) } else -> { it.updateItems( newItems = uiItems, stakingNotificationMaxApy = stakingNotificationMaxApy, ) } } } - MultilineLambdaItParameter:MarketsPortfolioModel.kt$MarketsPortfolioModel${ Timber.e( it, """ Failed to check currency unsupported state |- User wallet ID: $userWalletId |- Network ID: $rawNetworkId |- Is main network: $isMainNetwork """.trimIndent(), ) val message = SnackbarMessage( message = it.localizedMessage ?.let(::stringReference) ?: resourceReference(R.string.common_error), ) messageSender.send(message) null } - MultilineLambdaItParameter:MarketsPortfolioModel.kt$MarketsPortfolioModel${ onWalletSelect(it) // === Analytics === analyticsEventHandler.send( analyticsEventBuilder.addToPortfolioWalletChanged(), ) } - MultilineLambdaItParameter:MarketsPortfolioModel.kt$MarketsPortfolioModel${ when (it) { is NewAddToPortfolioManager.State.AvailableToAdd -> AddButtonState.Available NewAddToPortfolioManager.State.Init -> AddButtonState.Loading NewAddToPortfolioManager.State.NothingToAdd -> AddButtonState.Unavailable } } - MultilineLambdaItParameter:MarketsTokenDetailsContent.kt${ if (it.size.height > 0) { with(density) { onHeaderSizeChange(it.size.height.toDp()) } } } - MultilineLambdaItParameter:MarketsTokenDetailsContent.kt${ val nextColor = when (it) { PriceChangeType.UP, -> growColor PriceChangeType.DOWN -> fallColor PriceChangeType.NEUTRAL -> return@EventEffect } color.animateTo(nextColor, snap()) color.animateTo(generalColor, tween(durationMillis = 500)) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ BlockchainUtils.isSupportedNetworkId( blockchainId = it.networkId, excludedBlockchains = excludedBlockchains, hotExcludedBlockchains = hotWalletExcludedBlockchains, hasOnlyHotWallets = allWalletsIsHot, ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ ExchangesBottomSheetContent.Content( exchangeItems = ExchangeItemStateConverter.convertList(it).toImmutableList(), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ MarketsDateTimeFormatters.formatDateByIntervalWithMarker( interval = currentState.selectedInterval, markerTimestamp = it, ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ analyticsEventHandler.send( analyticsEventBuilder.intervalChanged( intervalType = MarketDetailsAnalyticsEvent.IntervalType.Insights, interval = it, ), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ analyticsEventHandler.send( analyticsEventBuilder.intervalChanged( intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance, interval = it, ), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ getChangePercentBetween( previousPrice = it, currentPrice = currentQuotes.value.currentPrice, ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ if (it.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) { it.copy( body = MarketsTokenDetailsUM.Body.Error( onLoadRetryClick = ::onLoadRetryClicked, ), ) } else { it.copy( body = MarketsTokenDetailsUM.Body.Nothing, ) } } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( body = MarketsTokenDetailsUM.Body.Loading, ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.DATA, ), body = if (it.body is MarketsTokenDetailsUM.Body.Nothing) { MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked) } else { it.body }, ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.ERROR, ), body = if (it.body is MarketsTokenDetailsUM.Body.Error) { MarketsTokenDetailsUM.Body.Nothing } else { it.body }, ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.LOADING, ), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( priceChangePercentText = priceChangePercent, selectedInterval = interval, priceChangeType = quotes.getPercentByInterval(interval)?.percentChangeType() ?: PriceChangeType.NEUTRAL, dateTimeText = getDefaultDateTimeString(interval), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( priceText = newInfo.quotes.currentPrice.format { fiat( fiatCurrencySymbol = currentAppCurrency.value.symbol, fiatCurrencyCode = currentAppCurrency.value.code, ).price() }, priceChangePercentText = newInfo.quotes.getFormattedPercentByInterval( interval = it.selectedInterval, ), priceChangeType = percent.percentChangeType(), body = MarketsTokenDetailsUM.Body.Content( description = descriptionConverter.convert(newInfo), infoBlocks = infoConverter.convert(newInfo), ), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( selectedInterval = interval, chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.LOADING, ), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( type = percent.percentChangeType().toChartType(), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( xAxisFormatter = xAxisFormatter, type = state.value.priceChangeType.toChartType(), ) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.urlData?.fullUrl?.let { url -> urlOpener.openUrl(url) } // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.securityScoreProviderClicked(it.name)) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ showBottomSheet(it) // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.readMoreClicked()) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ showBottomSheet(it) // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.securityScoreOpened()) } - MultilineLambdaItParameter:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ val percentChangeType = params.token.tokenQuotes.h24Percent.percentChangeType() it.copy( type = percentChangeType.toChartType(), xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(PriceChangeInterval.H24), yAxisFormatter = { value -> value.format { fiat( fiatCurrencyCode = currentAppCurrency.value.code, fiatCurrencySymbol = currentAppCurrency.value.symbol, ).price() } }, ) } - MultilineLambdaItParameter:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate${ PortfoliosWithThisCurrency( currencyRawId = currencyRawId, portfolios = it.toList(), ) } - MultilineLambdaItParameter:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate${ when (it) { AddButtonState.Loading -> MyPortfolioUM.Loading AddButtonState.Available -> MyPortfolioUM.AddFirstToken( onAddClick = onAddClick, addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, ) AddButtonState.Unavailable -> MyPortfolioUM.Unavailable } } - MultilineLambdaItParameter:PortfolioDataLoader.kt$PortfolioDataLoader${ PortfolioData.CryptoCurrencyData( userWallet = wallet, status = it, actions = emptyList(), ) } - MultilineLambdaItParameter:PortfolioDataLoader.kt$PortfolioDataLoader${ PortfolioData.CryptoCurrencyData( userWallet = wallet, status = status, actions = it.states, ) } - MultilineLambdaItParameter:PortfolioItem.kt${ val onClick = state.tokenItemState.onItemClick if (onClick != null) { hapticManager.perform(TangemHapticEffect.View.ContextClick) onClick.invoke(it) } } - MultilineLambdaItParameter:PortfolioTokenUMConverter.kt$PortfolioTokenUMConverter.Companion${ if (it == QuickActionUM.Receive) { tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.CopyAddress, cryptoCurrencyData = cryptoData, ) } } - MultilineLambdaItParameter:PortfolioTokenUMConverter.kt$PortfolioTokenUMConverter.Companion${ when (it) { QuickActionUM.Buy -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Buy, cryptoCurrencyData = cryptoData, ) is QuickActionUM.Exchange -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Exchange, cryptoCurrencyData = cryptoData, ) QuickActionUM.Receive -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Receive, cryptoCurrencyData = cryptoData, ) QuickActionUM.Stake -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Stake, cryptoCurrencyData = cryptoData, ) } } - MultilineLambdaItParameter:PricePerformanceBlock.kt${ currentInterval = it state.onIntervalChanged(it) } - MultilineLambdaItParameter:SecurityScoreConverter.kt$SecurityScoreConverter${ SecurityScoreBottomSheetContent.SecurityScoreProviderUM( name = it.providerName, lastAuditDate = it.lastAuditDate?.let { date -> MarketsDateTimeFormatters.formatAsDate(date.millis) }, score = it.securityScore, urlData = it.urlData?.let { urlData -> SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData( fullUrl = urlData.fullUrl, rootHost = urlData.rootHost, ) }, iconUrl = it.iconUrl, ) } - MultilineLambdaItParameter:TokenActionsContent.kt${ key(it.title) { ActionRow( state = it, onClick = { state.quickActions.onQuickActionClick(it) }, onLongClick = { state.quickActions.onQuickActionLongClick(it) }, ) } } - MultilineLambdaItParameter:TokenMarketBlockModel.kt$TokenMarketBlockModel${ it.onSome { res -> quotesState = QuotesState( currentPrice = res.fiatRate, h24Percent = res.priceChange, ) state.value = state.value.copy( currentPrice = res.fiatRate.format { fiat( // TODO get currency from quotes use case AND-8022 fiatCurrencyCode = currentAppCurrency.value.code, // TODO get currency from quotes use case AND-8022 fiatCurrencySymbol = currentAppCurrency.value.symbol, ).price() }, h24Percent = res.priceChange.format { percent() }, priceChangeType = PriceChangeType.fromBigDecimal(res.priceChange), ) } } - MultilineLambdaItParameter:TokenMarketInfoConverter.kt$TokenMarketInfoConverter${ pricePerformanceConverter.convert( value = it, currentPrice = value.quotes.currentPrice, ) } - MultilineLambdaItParameter:TokensPortfolioUMConverter.kt$TokensPortfolioUMConverter${ it.copy( isQuickActionsShown = if (it.matchWith(cryptoData)) { !it.isQuickActionsShown } else { false }, ) } - NamedArguments:AvailableToAddDataConverter.kt$AvailableToAddDataConverter$createCryptoCurrency(wallet, it, marketParams, this.account) - NamedArguments:MarketsListModel.kt$MarketsListModel$combine( mainMarketsListManager.uiItems, mainMarketsListManager.isInInitialLoadingErrorState, getStakingNotificationMaxApyUseCase(), getUserCountryUseCase.invoke(), ) { uiItems, isInInitialLoadingErrorState, stakingNotificationMaxApy, userCountry -> MarketsItemsData( items = uiItems, isInErrorState = isInInitialLoadingErrorState, isSearchNotFound = false, stakingNotificationMaxApy = stakingNotificationMaxApy, userCountry = userCountry, ) } - NamedArguments:MarketsListModel.kt$MarketsListModel$combine( searchMarketsListManager.uiItems, searchMarketsListManager.isInInitialLoadingErrorState, searchMarketsListManager.isSearchNotFoundState, getStakingNotificationMaxApyUseCase(), getUserCountryUseCase.invoke(), ) { uiItems, isInInitialLoadingErrorState, isSearchNotFoundState, stakingMaxApy, userCountry -> MarketsItemsData( items = uiItems, isInErrorState = isInInitialLoadingErrorState, isSearchNotFound = isSearchNotFoundState, stakingNotificationMaxApy = stakingMaxApy, userCountry = userCountry, ) } - NestedScopeFunctions:DescriptionConverter.kt$DescriptionConverter$let { fullDescription -> stringReference(fullDescription) } - NoNameShadowing:MarketsPortfolioModel.kt$MarketsPortfolioModel${ it.isMultiCurrency } - NoNameShadowing:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ if (it.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) { it.copy( body = MarketsTokenDetailsUM.Body.Error( onLoadRetryClick = ::onLoadRetryClicked, ), ) } else { it.copy( body = MarketsTokenDetailsUM.Body.Nothing, ) } } - NoNameShadowing:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.ERROR, ), body = if (it.body is MarketsTokenDetailsUM.Body.Error) { MarketsTokenDetailsUM.Body.Nothing } else { it.body }, ) } - NoNameShadowing:MyPortfolioUMFactory.kt$MyPortfolioUMFactory${ networkIds.contains(it.status.currency.network.backendId) } - NoNameShadowing:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$portfolio - NullableToStringCall:MarketsListItemUM.kt$MarketsListItemUM$marketCap.toString() - PropertyUsedBeforeDeclaration:MarketsListModel.kt$MarketsListModel$activeListManager - PropertyUsedBeforeDeclaration:MarketsListUMStateManager.kt$MarketsListUMStateManager$state - PropertyUsedBeforeDeclaration:MarketsPortfolioModel.kt$MarketsPortfolioModel$_state - PropertyUsedBeforeDeclaration:MarketsPortfolioModel.kt$MarketsPortfolioModel$currentAppCurrency - PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$accountHeader - PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$accountToken - PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$sampleToken - PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$walletHeader - PropertyUsedBeforeDeclaration:PreviewMyPortfolioUMProvider.kt$PreviewMyPortfolioUMProvider$walletPortfolioHeader - ReusedModifierInstance:EntryBottomSheetContent.kt$BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, modifier = modifier, ) - ReusedModifierInstance:PortfolioQuickActions.kt$Column(modifier = modifier) { actions.fastForEach { action -> LineSeparator() QuickActionItem( state = action, onClick = { onActionClick(action) }, onLongClick = { onActionLongClick(action) }.takeIf { action.longClickAvailable }, ) } } - ReusedModifierInstance:TokenActionsContent.kt$SecondaryButton( modifier = modifier.fillMaxWidth(), text = stringResourceSafe(R.string.common_later), onClick = state.onLaterClick, ) - SuspendFunWithFlowReturnType:AddToPortfolioModel.kt$AddToPortfolioModel$suspend - UnnecessaryEventHandlerParameter:SecurityScoreBottomSheet.kt$onLinkClick: (SecurityScoreBottomSheetContent.SecurityScoreProviderUM) -> Unit - UnnecessaryLet:PortfolioAnalyticsEvent.kt$PortfolioAnalyticsEvent.EventBuilder$let { put("Source", source) } - UnnecessaryLet:SecurityScoreBottomSheet.kt$let { Text( text = providerUM.lastAuditDate, style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, ) } - UnsafeCallOnNullableType:DefaultMarketsPortfolioComponent.kt$DefaultMarketsPortfolioComponent$model.newAddToPortfolioManager!! - UnsafeCallOnNullableType:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager$previousList!! - UnsafeCallOnNullableType:PricePerformanceConverter.kt$PricePerformanceConverter$high!! - UnsafeCallOnNullableType:PricePerformanceConverter.kt$PricePerformanceConverter$low!! - UseAnyOrNoneInsteadOfFind:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$find { account -> account.addedCurrency.isNotEmpty() } - UseEmptyCounterpart:MarketDetailsAnalyticsEvent.kt$MarketDetailsAnalyticsEvent$mapOf() - UseEmptyCounterpart:MarketsListAnalyticsEvent.kt$MarketsListAnalyticsEvent$mapOf() - UseEmptyCounterpart:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$setOf() - UseEmptyCounterpart:PortfolioAnalyticsEvent.kt$PortfolioAnalyticsEvent$mapOf() - UseOrEmpty:DescriptionConverter.kt$DescriptionConverter$value.fullDescription ?: "" - UseOrEmpty:Formatters.kt$percent?.format { percent() } ?: "" - UseOrEmpty:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel$percent?.format { percent() } ?: "" - UseOrEmpty:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$allActions[currencyStatus.currency]?.states ?: emptyList() - UseOrEmpty:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$portfolio.portfolios .find { it.accountsWithAdded.find { account -> account.addedCurrency.isNotEmpty() } != null } ?.userWallet ?.let { setOf(it.walletId to currency.currency.id) } ?: setOf() - VarCouldBeVal:MarketsListModel.kt$MarketsListModel$private var updateQuotesJob = JobHolder() - VarCouldBeVal:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel$private var quotesJob = JobHolder() - - diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt index 54aa223690..e6f564100f 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt @@ -62,9 +62,11 @@ internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor( init { componentScope.launch { - model.networksState.collectLatest { - when (it) { - is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(it.networks) + model.networksState.collectLatest { networksState -> + when (networksState) { + is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks( + networksState.networks, + ) TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable() else -> {} } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/analytics/MarketDetailsAnalyticsEvent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/analytics/MarketDetailsAnalyticsEvent.kt index 85d934fd13..c3674eb4cc 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/analytics/MarketDetailsAnalyticsEvent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/analytics/MarketDetailsAnalyticsEvent.kt @@ -6,7 +6,7 @@ import com.tangem.domain.markets.TokenMarketParams internal class MarketDetailsAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) { data class EventBuilder( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt index 6107b6d7a2..f2a44120b9 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt @@ -78,7 +78,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( private val getUserWalletsUseCase: GetWalletsUseCase, ) : Model() { - private var quotesJob = JobHolder() + private val quotesJob = JobHolder() private var userCountry: UserCountry? = null private val params = paramsContainer.require() private val analyticsEventBuilder = MarketDetailsAnalyticsEvent.EventBuilder(token = params.token) @@ -101,34 +101,34 @@ internal class MarketsTokenDetailsModel @Inject constructor( // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.linkClicked(linkTitle = link.title)) }, - onSecurityScoreInfoClick = { - showBottomSheet(it) + onSecurityScoreInfoClick = { content -> + showBottomSheet(content) // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.securityScoreOpened()) }, - onSecurityScoreProviderLinkClick = { - it.urlData?.fullUrl?.let { url -> + onSecurityScoreProviderLinkClick = { provider -> + provider.urlData?.fullUrl?.let { url -> urlOpener.openUrl(url) } // === Analytics === - analyticsEventHandler.send(analyticsEventBuilder.securityScoreProviderClicked(it.name)) + analyticsEventHandler.send(analyticsEventBuilder.securityScoreProviderClicked(provider.name)) }, // === Analytics === - onPricePerformanceIntervalChanged = { + onPricePerformanceIntervalChanged = { interval -> analyticsEventHandler.send( analyticsEventBuilder.intervalChanged( intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance, - interval = it, + interval = interval, ), ) }, - onInsightsIntervalChanged = { + onInsightsIntervalChanged = { interval -> analyticsEventHandler.send( analyticsEventBuilder.intervalChanged( intervalType = MarketDetailsAnalyticsEvent.IntervalType.Insights, - interval = it, + interval = interval, ), ) }, @@ -139,8 +139,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( ) private val descriptionConverter = DescriptionConverter( - onReadModeClicked = { - showBottomSheet(it) + onReadModeClicked = { content -> + showBottomSheet(content) // === Analytics === analyticsEventHandler.send(analyticsEventBuilder.readMoreClicked()) }, @@ -162,10 +162,10 @@ internal class MarketsTokenDetailsModel @Inject constructor( private val chartDataProducer = MarketChartDataProducer.build(dispatcher = dispatchers.default) { chartData = MarketChartData.NoData.Loading - updateLook { + updateLook { currentLook -> val percentChangeType = params.token.tokenQuotes.h24Percent.percentChangeType() - it.copy( + currentLook.copy( type = percentChangeType.toChartType(), xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(PriceChangeInterval.H24), yAxisFormatter = { value -> @@ -220,7 +220,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( ), selectedInterval = PriceChangeInterval.H24, onSelectedIntervalChange = ::onSelectedIntervalChange, - markerSet = false, + isMarkerSet = false, body = MarketsTokenDetailsUM.Body.Loading, triggerPriceChange = consumedEvent(), bottomSheetConfig = TangemBottomSheetConfig( @@ -237,11 +237,11 @@ internal class MarketsTokenDetailsModel @Inject constructor( currentQuotes = currentQuotes, lastUpdatedTimestamp = lastUpdatedTimestamp, currentTokenInfo = currentTokenInfo, - onPricePerformanceIntervalChanged = { + onPricePerformanceIntervalChanged = { interval -> analyticsEventHandler.send( analyticsEventBuilder.intervalChanged( intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance, - interval = it, + interval = interval, ), ) }, @@ -256,7 +256,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( modelScope.launch { currentAppCurrency .filter { it != params.appCurrency } - .collectLatest { + .collectLatest { _ -> initialLoad() } } @@ -286,9 +286,9 @@ internal class MarketsTokenDetailsModel @Inject constructor( private fun loadChart(interval: PriceChangeInterval) { modelScope.launch { - state.update { - it.copy( - chartState = it.chartState.copy( + state.update { currentState -> + currentState.copy( + chartState = currentState.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.LOADING, ), ) @@ -306,10 +306,10 @@ internal class MarketsTokenDetailsModel @Inject constructor( preview = false, ) - state.update { - it.copy( + state.update { currentState -> + currentState.copy( selectedInterval = interval, - chartState = it.chartState.copy( + chartState = currentState.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.LOADING, ), ) @@ -318,15 +318,15 @@ internal class MarketsTokenDetailsModel @Inject constructor( chart .onRight { updateTokenChart(it) } .onLeft { - state.update { - it.copy( - chartState = it.chartState.copy( + state.update { currentState -> + currentState.copy( + chartState = currentState.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.ERROR, ), - body = if (it.body is MarketsTokenDetailsUM.Body.Error) { + body = if (currentState.body is MarketsTokenDetailsUM.Body.Error) { MarketsTokenDetailsUM.Body.Nothing } else { - it.body + currentState.body }, ) } @@ -343,31 +343,31 @@ internal class MarketsTokenDetailsModel @Inject constructor( x = tokenChart.timeStamps.map { it.toBigDecimal() }.toImmutableList(), ).sorted() - updateLook { - it.copy( + updateLook { currentLook -> + currentLook.copy( xAxisFormatter = xAxisFormatter, type = state.value.priceChangeType.toChartType(), ) } } - state.update { - it.copy( - chartState = it.chartState.copy( + state.update { currentState -> + currentState.copy( + chartState = currentState.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.DATA, ), - body = if (it.body is MarketsTokenDetailsUM.Body.Nothing) { + body = if (currentState.body is MarketsTokenDetailsUM.Body.Nothing) { MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked) } else { - it.body + currentState.body }, ) } } private fun loadInfo() { - state.update { - it.copy( + state.update { currentState -> + currentState.copy( body = MarketsTokenDetailsUM.Body.Loading, ) } @@ -382,15 +382,15 @@ internal class MarketsTokenDetailsModel @Inject constructor( tokenMarketInfo.fold( ifRight = { result -> updateInfo(result) }, ifLeft = { - state.update { - if (it.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) { - it.copy( + state.update { currentState -> + if (currentState.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) { + currentState.copy( body = MarketsTokenDetailsUM.Body.Error( onLoadRetryClick = ::onLoadRetryClicked, ), ) } else { - it.copy( + currentState.copy( body = MarketsTokenDetailsUM.Body.Nothing, ) } @@ -408,8 +408,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( val percent = newInfo.quotes.getPercentByInterval(interval = state.value.selectedInterval) - state.update { - it.copy( + state.update { currentState -> + currentState.copy( priceText = newInfo.quotes.currentPrice.format { fiat( fiatCurrencySymbol = currentAppCurrency.value.symbol, @@ -417,7 +417,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( ).price() }, priceChangePercentText = newInfo.quotes.getFormattedPercentByInterval( - interval = it.selectedInterval, + interval = currentState.selectedInterval, ), priceChangeType = percent.percentChangeType(), body = MarketsTokenDetailsUM.Body.Content( @@ -427,14 +427,14 @@ internal class MarketsTokenDetailsModel @Inject constructor( ) } - val allWalletsIsHot = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } + val areAllWalletsHot = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } - val networks = newInfo.networks?.filter { + val networks = newInfo.networks?.filter { network -> BlockchainUtils.isSupportedNetworkId( - blockchainId = it.networkId, + blockchainId = network.networkId, excludedBlockchains = excludedBlockchains, hotExcludedBlockchains = hotWalletExcludedBlockchains, - hasOnlyHotWallets = allWalletsIsHot, + hasOnlyHotWallets = areAllWalletsHot, ) } @@ -445,8 +445,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( } chartDataProducer.runTransaction { - updateLook { - it.copy(type = percent.percentChangeType().toChartType()) + updateLook { currentLook -> + currentLook.copy(type = percent.percentChangeType().toChartType()) } } } @@ -481,8 +481,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( val quotes = currentQuotes.value val priceChangePercent = quotes.getFormattedPercentByInterval(interval) - state.update { - it.copy( + state.update { currentState -> + currentState.copy( priceChangePercentText = priceChangePercent, selectedInterval = interval, priceChangeType = quotes.getPercentByInterval(interval)?.percentChangeType() @@ -502,10 +502,10 @@ internal class MarketsTokenDetailsModel @Inject constructor( private fun onMarkerPointSelected(markerTimestamp: BigDecimal?, price: BigDecimal?) { val currentState = state.value - val dateTimeText = markerTimestamp?.let { + val dateTimeText = markerTimestamp?.let { timestamp -> MarketsDateTimeFormatters.formatDateByIntervalWithMarker( interval = currentState.selectedInterval, - markerTimestamp = it, + markerTimestamp = timestamp, ) } ?: getDefaultDateTimeString(currentState.selectedInterval) @@ -516,18 +516,18 @@ internal class MarketsTokenDetailsModel @Inject constructor( ).price() } - val percent = price?.let { + val percent = price?.let { selectedPrice -> getChangePercentBetween( - previousPrice = it, + previousPrice = selectedPrice, currentPrice = currentQuotes.value.currentPrice, ) } ?: currentQuotes.value.getPercentByInterval(currentState.selectedInterval) - val percentText = percent?.format { percent() } ?: "" + val percentText = percent?.format { percent() }.orEmpty() state.update { stateToUpdate -> stateToUpdate.copy( - markerSet = markerTimestamp != null, + isMarkerSet = markerTimestamp != null, dateTimeText = dateTimeText, priceText = priceText, priceChangePercentText = percentText, @@ -536,8 +536,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( } chartDataProducer.runTransaction { - updateLook { - it.copy( + updateLook { currentLook -> + currentLook.copy( type = percent.percentChangeType().toChartType(), ) } @@ -603,9 +603,9 @@ internal class MarketsTokenDetailsModel @Inject constructor( ifLeft = { ExchangesBottomSheetContent.Error(onRetryClick = { onListedOnClick(exchangesCount) }) }, - ifRight = { + ifRight = { exchanges -> ExchangesBottomSheetContent.Content( - exchangeItems = ExchangeItemStateConverter.convertList(it).toImmutableList(), + exchangeItems = ExchangeItemStateConverter.convertList(exchanges).toImmutableList(), ) }, ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt index f1e55176d1..ae495c8db0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt @@ -20,29 +20,26 @@ internal class DescriptionConverter( override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.Description? { if (needApplyFCARestrictions()) return null - return value.shortDescription?.let { desc -> - MarketsTokenDetailsUM.Description( - shortDescription = stringReference(desc), - fullDescription = value.fullDescription?.let { fullDescription -> - stringReference(fullDescription) - }, - onReadMoreClick = { - onReadModeClicked( - InfoBottomSheetContent( - title = resourceReference( - R.string.markets_token_details_about_token_title, - wrappedList( - value.name, - ), - ), - body = stringReference(value.fullDescription ?: ""), - generatedAINotificationUM = InfoBottomSheetContent.GeneratedAINotificationUM( - onClick = onGeneratedAINotificationClick, + val shortDesc = value.shortDescription ?: return null + return MarketsTokenDetailsUM.Description( + shortDescription = stringReference(shortDesc), + fullDescription = value.fullDescription?.let(::stringReference), + onReadMoreClick = { + onReadModeClicked( + InfoBottomSheetContent( + title = resourceReference( + R.string.markets_token_details_about_token_title, + wrappedList( + value.name, ), ), - ) - }, - ) - } + body = stringReference(value.fullDescription.orEmpty()), + generatedAINotificationUM = InfoBottomSheetContent.GeneratedAINotificationUM( + onClick = onGeneratedAINotificationClick, + ), + ), + ) + }, + ) } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt index 738710939a..c3aa321cb1 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt @@ -56,11 +56,13 @@ internal class PricePerformanceConverter( } private fun TokenMarketInfo.Range.calculateFraction(currentPrice: BigDecimal): Float { + val lowValue = low + val highValue = high return when { - low == null || high == null || high == BigDecimal.ZERO || currentPrice < low -> 0f - currentPrice > high || low == high -> 1f + lowValue == null || highValue == null || highValue == BigDecimal.ZERO || currentPrice < lowValue -> 0f + currentPrice > highValue || lowValue == highValue -> 1f else -> { - (currentPrice - low!!).divide(high!! - low!!, RoundingMode.HALF_UP) + (currentPrice - lowValue).divide(highValue - lowValue, RoundingMode.HALF_UP) .setScale(2, RoundingMode.HALF_UP) .toFloat().coerceAtMost(1f) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/SecurityScoreConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/SecurityScoreConverter.kt index aee019594a..d3bec1f927 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/SecurityScoreConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/SecurityScoreConverter.kt @@ -31,21 +31,20 @@ internal class SecurityScoreConverter( SecurityScoreBottomSheetContent( title = resourceReference(R.string.markets_token_details_security_score), description = resourceReference(R.string.markets_token_details_security_score_description), - providers = value.securityScoreProviderData.map { + providers = value.securityScoreProviderData.map { provider -> SecurityScoreBottomSheetContent.SecurityScoreProviderUM( - name = it.providerName, - lastAuditDate = it.lastAuditDate?.let { date -> + name = provider.providerName, + lastAuditDate = provider.lastAuditDate?.let { date -> MarketsDateTimeFormatters.formatAsDate(date.millis) }, - score = it.securityScore, - urlData = it.urlData?.let { - urlData -> + score = provider.securityScore, + urlData = provider.urlData?.let { urlData -> SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData( fullUrl = urlData.fullUrl, rootHost = urlData.rootHost, ) }, - iconUrl = it.iconUrl, + iconUrl = provider.iconUrl, ) }, onProviderLinkClick = onSecurityScoreProviderLinkClick, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt index 8f67567773..e7aa05eeb5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt @@ -64,9 +64,9 @@ internal class TokenMarketInfoConverter( insights = insights, securityScore = securityScore, metrics = value.metrics?.let { metricsConverter.convert(it) }, - pricePerformance = value.pricePerformance?.let { + pricePerformance = value.pricePerformance?.let { performance -> pricePerformanceConverter.convert( - value = it, + value = performance, currentPrice = value.quotes.currentPrice, ) }, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt index 78f0a49fb3..bc1aeb77e3 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt @@ -21,7 +21,7 @@ internal fun TokenQuotes.getFormattedPercentByInterval(interval: PriceChangeInte PriceChangeInterval.ALL_TIME -> allTimeChangePercent } - return percent?.format { percent() } ?: "" + return percent?.format { percent() }.orEmpty() } internal fun TokenQuotes.getPercentByInterval(interval: PriceChangeInterval): BigDecimal? { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/state/QuotesStateUpdater.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/state/QuotesStateUpdater.kt index 74d10c5742..116fb390b4 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/state/QuotesStateUpdater.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/state/QuotesStateUpdater.kt @@ -53,7 +53,7 @@ internal class QuotesStateUpdater( val priceChangeType = percent.percentChangeType() // wait until marker is removed - state.first { it.markerSet.not() } + state.first { it.isMarkerSet.not() } currentQuotes.value = newQuotes lastUpdatedTimestamp.value = DateTime.now().millis diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt index fc3737ddd0..a732ce35a6 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt @@ -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 @@ -103,10 +107,10 @@ private fun Content( .fillMaxSize(), ) { TopBar( - modifier = Modifier.onGloballyPositioned { - if (it.size.height > 0) { + modifier = Modifier.onGloballyPositioned { coordinates -> + if (coordinates.size.height > 0) { with(density) { - onHeaderSizeChange(it.size.height.toDp()) + onHeaderSizeChange(coordinates.size.height.toDp()) } } }, @@ -170,7 +174,7 @@ private fun TopBar( onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - val showPriceSubtitle by remember { + val shouldShowPriceSubtitle by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 1 } @@ -179,7 +183,7 @@ private fun TopBar( TangemTopAppBar( modifier = modifier, title = tokenName, - subtitle = if (showPriceSubtitle) tokenPrice else null, + subtitle = if (shouldShowPriceSubtitle) tokenPrice else null, startButton = TopAppBarButtonUM.Back( onBackClicked = onBackClick, enabled = isBackButtonEnabled, @@ -236,8 +240,8 @@ private fun TokenPriceText( val color = remember(generalColor) { Animatable(generalColor) } - EventEffect(triggerPriceChange) { - val nextColor = when (it) { + EventEffect(triggerPriceChange) { changeType -> + val nextColor = when (changeType) { PriceChangeType.UP, -> growColor PriceChangeType.DOWN -> fallColor @@ -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/details/impl/ui/components/InsightsBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt index 8b5e829a64..d40e37b970 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt @@ -48,12 +48,12 @@ internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) { PriceChangeInterval.MONTH, ), initialSelectedItem = PriceChangeInterval.H24, - onClick = { - currentInterval = it - state.onIntervalChanged(it) + onClick = { interval -> + currentInterval = interval + state.onIntervalChanged(interval) }, modifier = Modifier.width(IntrinsicSize.Min), - ) { + ) { interval -> Box( Modifier .fillMaxSize() @@ -65,7 +65,7 @@ internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) { ) { Text( modifier = Modifier.align(Alignment.Center), - text = it.getText().resolveReference(), + text = interval.getText().resolveReference(), style = TangemTheme.typography.caption1, color = TangemTheme.colors.text.primary1, ) @@ -82,10 +82,10 @@ internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) { GridItems( items = infoPoints, - itemContent = { + itemContent = { infoPoint -> InfoPoint( modifier = Modifier.align(Alignment.CenterStart), - infoPointUM = it, + infoPointUM = infoPoint, ) }, ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt index 5825ba908b..6d10ec8957 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt @@ -96,11 +96,11 @@ private fun SubBlock( horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { - links.fastForEach { + links.fastForEach { link -> Chip( - text = stringReference(it.title), - iconResId = it.iconRes, - onClick = { onLinkClick(it) }, + text = stringReference(link.title), + iconResId = link.iconRes, + onClick = { onLinkClick(link) }, ) } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt index 076b704bb8..0ad6b22ee0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt @@ -30,8 +30,8 @@ internal fun MarketTokenDetailsChart( val chartState = rememberMarketChartState( dataProducer = state.dataProducer, - colorMapper = { - when (it) { + colorMapper = { chartType -> + when (chartType) { MarketChartLook.Type.Growing -> growingColor MarketChartLook.Type.Falling -> fallingColor MarketChartLook.Type.Neutral -> neutralColor diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MetricsBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MetricsBlock.kt index 9d33b61034..98e74fd06f 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MetricsBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MetricsBlock.kt @@ -29,7 +29,7 @@ const val MAX_METRICS_COUNT = 6 @Composable internal fun MetricsBlock(state: MetricsUM, modifier: Modifier = Modifier) { - var expanded by remember { mutableStateOf(false) } + var isExpanded by remember { mutableStateOf(false) } InformationBlock( modifier = modifier, @@ -44,11 +44,11 @@ internal fun MetricsBlock(state: MetricsUM, modifier: Modifier = Modifier) { }, action = { if (state.metrics.size > MAX_METRICS_COUNT) { - ShowLessMoreButton(expanded = expanded, onClick = { expanded = !expanded }) + ShowLessMoreButton(expanded = isExpanded, onClick = { isExpanded = !isExpanded }) } }, content = { - val metrics = if (expanded) { + val metrics = if (isExpanded) { state.metrics } else { state.metrics.take(MAX_METRICS_COUNT).toImmutableList() diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt index efede75d0e..17e8d19e3f 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt @@ -53,12 +53,12 @@ internal fun PricePerformanceBlock(state: PricePerformanceUM, modifier: Modifier PriceChangeInterval.ALL_TIME, ), initialSelectedItem = PriceChangeInterval.H24, - onClick = { - currentInterval = it - state.onIntervalChanged(it) + onClick = { interval -> + currentInterval = interval + state.onIntervalChanged(interval) }, modifier = Modifier.width(IntrinsicSize.Min), - ) { + ) { interval -> Box( Modifier .fillMaxSize() @@ -70,7 +70,7 @@ internal fun PricePerformanceBlock(state: PricePerformanceUM, modifier: Modifier ) { Text( modifier = Modifier.align(Alignment.Center), - text = it.getText().resolveReference(), + text = interval.getText().resolveReference(), style = TangemTheme.typography.caption1, color = TangemTheme.colors.text.primary1, ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBottomSheet.kt index e6ef8e94b6..b8c4242c9a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBottomSheet.kt @@ -70,7 +70,7 @@ internal fun SecurityScoreBottomSheet(config: TangemBottomSheetConfig) { ) { SecurityScoreProviderRow( providerUM = provider, - onLinkClick = content.onProviderLinkClick, + onLinkClick = { content.onProviderLinkClick(provider) }, ) } } @@ -85,7 +85,7 @@ internal fun SecurityScoreBottomSheet(config: TangemBottomSheetConfig) { @Composable private fun SecurityScoreProviderRow( providerUM: SecurityScoreBottomSheetContent.SecurityScoreProviderUM, - onLinkClick: (SecurityScoreBottomSheetContent.SecurityScoreProviderUM) -> Unit, + onLinkClick: () -> Unit, ) { Row( modifier = Modifier @@ -118,7 +118,7 @@ private fun SecurityScoreProviderRow( style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.primary1, ) - providerUM.lastAuditDate?.let { + if (providerUM.lastAuditDate != null) { Text( text = providerUM.lastAuditDate, style = TangemTheme.typography.caption2, @@ -136,7 +136,7 @@ private fun SecurityScoreProviderRow( enabled = providerUM.urlData != null, indication = ripple(bounded = false), interactionSource = remember { MutableInteractionSource() }, - onClick = { onLinkClick(providerUM) }, + onClick = onLinkClick, ), ) { ScoreStarsBlock( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt index c94c78f605..13124c707d 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt @@ -16,6 +16,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM import com.tangem.features.markets.impl.R +@Suppress("CanBeNonNullable") internal fun LazyListScope.tokenMarketDetailsBody( state: MarketsTokenDetailsUM.Body, isAccountEnabled: Boolean, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/preview/MarketsTokenDetailsPreview.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/preview/MarketsTokenDetailsPreview.kt index 46ad847bf6..83be0ff031 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/preview/MarketsTokenDetailsPreview.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/preview/MarketsTokenDetailsPreview.kt @@ -39,7 +39,7 @@ internal object MarketsTokenDetailsPreview { onDismissRequest = {}, content = TangemBottomSheetConfigContent.Empty, ), - markerSet = false, + isMarkerSet = false, triggerPriceChange = consumedEvent(), ) @@ -123,7 +123,7 @@ internal object MarketsTokenDetailsPreview { onDismissRequest = {}, content = TangemBottomSheetConfigContent.Empty, ), - markerSet = true, + isMarkerSet = true, triggerPriceChange = consumedEvent(), ) } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt index a4b669739a..94f581efd7 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt @@ -17,7 +17,7 @@ internal data class MarketsTokenDetailsUM( val priceChangePercentText: String?, val priceChangeType: PriceChangeType, val selectedInterval: PriceChangeInterval, - val markerSet: Boolean, + val isMarkerSet: Boolean, val chartState: ChartState, val onSelectedIntervalChange: (PriceChangeInterval) -> Unit, val bottomSheetConfig: TangemBottomSheetConfig, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt index a19e160096..7814672735 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt @@ -37,20 +37,21 @@ internal fun EntryBottomSheetContent( Children( stack = stackState.value, animation = stackAnimation(slide()), - ) { - when (it.configuration) { + modifier = modifier, + ) { child -> + when (child.configuration) { is MarketsEntryChildFactory.Child.TokenDetails -> { - (it.instance as MarketsTokenDetailsComponent).BottomSheetContent( + (child.instance as MarketsTokenDetailsComponent).BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, - modifier = modifier, + modifier = Modifier, ) } is MarketsEntryChildFactory.Child.TokenList -> { - (it.instance as MarketsTokenListComponent).BottomSheetContent( + (child.instance as MarketsTokenListComponent).BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, - modifier = modifier, + modifier = Modifier, ) } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/api/AvailableToAddData.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/api/AvailableToAddData.kt index 557fd53518..3ca633edf5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/api/AvailableToAddData.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/api/AvailableToAddData.kt @@ -12,7 +12,7 @@ import kotlinx.serialization.Serializable internal data class AvailableToAddData( val availableToAddWallets: Map, ) { - val availableToAdd: Boolean + val isAvailableToAdd: Boolean get() = availableToAddWallets.isNotEmpty() val isSinglePortfolio: Boolean get() = availableToAddWallets.size == 1 && availableToAddWallets.values.first().accounts.size == 1 @@ -48,11 +48,11 @@ internal data class SelectedPortfolio( val userWallet: UserWallet, val account: AvailableToAddAccount, val isAccountMode: Boolean, - val availableMorePortfolio: Boolean, + val hasMorePortfoliosAvailable: Boolean, ) internal data class SelectedNetwork( val selectedNetwork: TokenMarketInfo.Network, val cryptoCurrency: CryptoCurrency, - val availableMoreNetwork: Boolean, + val hasMoreNetworksAvailable: Boolean, ) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/DefaultAddToPortfolioComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/DefaultAddToPortfolioComponent.kt index 69b9573e71..81c2f24a63 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/DefaultAddToPortfolioComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/DefaultAddToPortfolioComponent.kt @@ -131,7 +131,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( end = 16.dp, bottom = 16.dp, ) - val scrollableContent = when (stack.active.configuration) { + val isScrollableContent = when (stack.active.configuration) { AddToPortfolioRoutes.PortfolioSelector -> false AddToPortfolioRoutes.AddToken, AddToPortfolioRoutes.Empty, @@ -139,7 +139,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( AddToPortfolioRoutes.TokenActions, -> true } - if (scrollableContent) { + if (isScrollableContent) { Column( modifier = paddingModifier.verticalScroll(rememberScrollState()), ) { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt index 4bbeec4cee..a9a11d6eb7 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/converter/AvailableToAddDataConverter.kt @@ -30,7 +30,14 @@ internal class AvailableToAddDataConverter @Inject constructor( ): AvailableToAddData { suspend fun AccountStatus.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount? { val currencies = availableNetworks - .mapNotNull { createCryptoCurrency(wallet, it, marketParams, this.account) } + .mapNotNull { network -> + createCryptoCurrency( + userWallet = wallet, + network = network, + marketParams = marketParams, + account = this.account, + ) + } if (currencies.isEmpty()) return null @@ -74,9 +81,9 @@ internal class AvailableToAddDataConverter @Inject constructor( } val availableToAddWallets: Map = balances - .map { - val (walletId, _) = it - val availableToAddWallet = getAvailableToAddWallet(it) + .map { entry -> + val (walletId, _) = entry + val availableToAddWallet = getAvailableToAddWallet(entry) walletId to availableToAddWallet } .filter { (_, wallet) -> wallet.availableToAddAccounts.isNotEmpty() } @@ -101,6 +108,7 @@ internal class AvailableToAddDataConverter @Inject constructor( ): CryptoCurrency? { val derivationIndex = when (account) { is Account.CryptoPortfolio -> account.derivationIndex + is Account.Payment -> TODO("[REDACTED_JIRA]") } return getTokenMarketCryptoCurrency( userWalletId = userWallet.walletId, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioModel.kt index 40c339c730..2ae6b5b028 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioModel.kt @@ -101,7 +101,7 @@ internal class AddToPortfolioModel @Inject constructor( val data = featureDataFlow.value // you must control it via [AddToPortfolioManager.state] - if (!data.availableToAdd) { + if (!data.isAvailableToAdd) { finishFlow() return@channelFlow } @@ -182,8 +182,8 @@ internal class AddToPortfolioModel @Inject constructor( messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) setupTokenActionsFlow(selectedPortfolio, addedToken) - .onEach { - tokenActionsData.emit(it) + .onEach { cryptoCurrencyData -> + tokenActionsData.emit(cryptoCurrencyData) navigation.replaceAll(AddToPortfolioRoutes.TokenActions) } .onEmpty { finishFlow() } @@ -192,8 +192,8 @@ internal class AddToPortfolioModel @Inject constructor( callbackDelegate.onLaterClick.receiveAsFlow().first() finishFlow() } - .catch { - Timber.e(it) + .catch { error -> + Timber.e(error) params.callback.onDismiss() } .launchIn(modelScope) @@ -213,14 +213,15 @@ internal class AddToPortfolioModel @Inject constructor( } } - private suspend fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow { - val selectedAccount = selectedPortfolio.first().account.account.account.accountId + private fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow = flow { + val selectedPortfolioValue = selectedPortfolio.first() + val selectedAccount = selectedPortfolioValue.account.account.account.accountId portfolioSelectorController.selectAccount(selectedAccount) val changedPortfolio = setupPortfolioFlow(data) .drop(1) .onEach { portfolio -> navigation.pushNew(routeToNetworkSelector(portfolio)) } val changedNetwork = setupNetworkFlow(changedPortfolio) - return combine( + combine( flow = changedPortfolio, flow2 = changedNetwork, transform = { newPortfolio, newNetwork -> @@ -228,7 +229,7 @@ internal class AddToPortfolioModel @Inject constructor( selectedNetwork.tryEmit(newNetwork) navigation.popToFirst() }, - ) + ).collect { emit(it) } } private fun setupTokenActionsFlow( @@ -276,7 +277,7 @@ internal class AddToPortfolioModel @Inject constructor( isAccountMode = isAccountMode, userWallet = availableToAddWallets.userWallet, account = availableToAddAccount, - availableMorePortfolio = !data.isSinglePortfolio, + hasMorePortfoliosAvailable = !data.isSinglePortfolio, ) }, ) @@ -293,7 +294,7 @@ internal class AddToPortfolioModel @Inject constructor( account = selectedPortfolio.account, ) ?: return@transform null, selectedNetwork = selectedNetwork, - availableMoreNetwork = !selectedPortfolio.account.isSingleNetwork, + hasMoreNetworksAvailable = !selectedPortfolio.account.isSingleNetwork, ) }, ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt index b0f322b6f5..c00c3f15da 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenModel.kt @@ -78,8 +78,8 @@ internal class AddTokenModel @Inject constructor( val account = selectedPortfolio.account.account.account val accountId = account.accountId manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency) - .onLeft { - processError(error = it) + .onLeft { error -> + processError(error = error) uiState.value = um.toggleProgress(false) return@launch } @@ -96,6 +96,7 @@ internal class AddTokenModel @Inject constructor( is Account.CryptoPortfolio -> if (!account.isMainAccount) { analyticsEventHandler.send(analyticsEventBuilder.addToNotMainAccount()) } + is Account.Payment -> TODO("[REDACTED_JIRA]") } params.callbacks.onTokenAdded(status.status) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt index adcb476815..1d95ddde68 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt @@ -29,7 +29,7 @@ internal class AddTokenUiBuilder @Inject constructor( return AddTokenUM.Network( icon = selectedNetwork.cryptoCurrency.network.iconResId, name = stringReference(selectedNetwork.cryptoCurrency.network.name), - editable = selectedNetwork.availableMoreNetwork, + editable = selectedNetwork.hasMoreNetworksAvailable, onClick = { params.callbacks.onChangeNetworkClick() }, ) } @@ -54,7 +54,7 @@ internal class AddTokenUiBuilder @Inject constructor( icon = accountIcon, name = portfolioName, isAccountMode = selectedPortfolio.isAccountMode, - isMultiChoice = selectedPortfolio.availableMorePortfolio, + isMultiChoice = selectedPortfolio.hasMorePortfoliosAvailable, onClick = { params.callbacks.onChangePortfolioClick() }, ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/ChooseNetworkModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/ChooseNetworkModel.kt index fa3e0c3ab8..1d1ecc80d0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/ChooseNetworkModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/ChooseNetworkModel.kt @@ -82,9 +82,9 @@ internal class ChooseNetworkModel @Inject constructor( userWalletId = userWalletId, networkId = rawNetworkId, isMainNetwork = isMainNetwork, - ).getOrElse { + ).getOrElse { error -> Timber.e( - it, + error, """ Failed to check currency unsupported state |- User wallet ID: $userWalletId @@ -94,7 +94,7 @@ internal class ChooseNetworkModel @Inject constructor( ) val message = SnackbarMessage( - message = it.localizedMessage?.let(::stringReference) ?: resourceReference(R.string.common_error), + message = error.localizedMessage?.let(::stringReference) ?: resourceReference(R.string.common_error), ) messageSender.send(message) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt index 9c82131fe6..e396ef008e 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt @@ -45,7 +45,7 @@ internal class DefaultAddToPortfolioManager @AssistedInject constructor( availableNetworks = availableNetworks, marketParams = token, ) - if (data.availableToAdd) { + if (data.isAvailableToAdd) { State.AvailableToAdd(data) } else { State.NothingToAdd diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/TokenActionsContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/TokenActionsContent.kt index 97e8645655..e0b24bf672 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/TokenActionsContent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/TokenActionsContent.kt @@ -63,12 +63,12 @@ internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Mod .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) .background(TangemTheme.colors.background.action), ) { - state.quickActions.actions.fastForEach { - key(it.title) { + state.quickActions.actions.fastForEach { action -> + key(action.title) { ActionRow( - state = it, - onClick = { state.quickActions.onQuickActionClick(it) }, - onLongClick = { state.quickActions.onQuickActionLongClick(it) }, + state = action, + onClick = { state.quickActions.onQuickActionClick(action) }, + onLongClick = { state.quickActions.onQuickActionLongClick(action) }, ) } } @@ -77,7 +77,7 @@ internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Mod SpacerH16() SecondaryButton( - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), text = stringResourceSafe(R.string.common_later), onClick = state.onLaterClick, ) @@ -102,7 +102,7 @@ private fun ActionRow( modifier = modifier .fillMaxWidth() .combinedClickable( - onLongClick = onLongClickInternal.takeIf { state.longClickAvailable }, + onLongClick = onLongClickInternal.takeIf { state.isLongClickAvailable }, onClick = { hapticManager.perform(TangemHapticEffect.View.SegmentTick) onClick() @@ -123,7 +123,7 @@ private fun ActionRow( .size(36.dp) .drawWithContent { drawContent() - if (state is QuickActionUM.Exchange && state.showBadge) { + if (state is QuickActionUM.Exchange && state.shouldShowBadge) { drawBadge(containerColor = containerColor, offset = 4.dp) } }, @@ -191,7 +191,7 @@ private class TokenActionsContentPreviewProvider : PreviewParameterProvider addToPortfolioComponentFactory.create( context = childByContext(componentContext), params = AddToPortfolioComponent.Params( - addToPortfolioManager = model.newAddToPortfolioManager!!, + addToPortfolioManager = requireNotNull(model.newAddToPortfolioManager) { + "newAddToPortfolioManager must be initialized" + }, callback = model.addToPortfolioCallback, ), ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt index a547351b4c..ac6125d3c0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt @@ -6,7 +6,7 @@ import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContent internal class PortfolioAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) { data class EventBuilder( @@ -22,11 +22,11 @@ internal class PortfolioAnalyticsEvent( ) fun popupToChooseAccount() = PortfolioAnalyticsEvent( - event = "Popup to choose account", + event = "Choose Account Opened", ) fun addToNotMainAccount() = PortfolioAnalyticsEvent( - event = "Button - Add (token not to main Account)", + event = "Button - Add To Account", ) fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent(event = "Wallet Selected") @@ -52,7 +52,7 @@ internal class PortfolioAnalyticsEvent( }, params = buildMap { put("Token", token.symbol) - source?.let { put("Source", source) } + if (source != null) put("Source", source) put("blockchain", blockchainName) }, ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/loader/PortfolioDataLoader.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/loader/PortfolioDataLoader.kt index d5522e7218..0dadd3bcf4 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/loader/PortfolioDataLoader.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/loader/PortfolioDataLoader.kt @@ -80,11 +80,11 @@ internal class PortfolioDataLoader @Inject constructor( YieldSupplyAvailability.Unavailable } getCryptoCurrencyActionsUseCase(wallet, status, yieldSupplyAvailability) - .map { + .map { actionStates -> PortfolioData.CryptoCurrencyData( userWallet = wallet, status = status, - actions = it.states, + actions = actionStates.states, ) } } @@ -93,18 +93,18 @@ internal class PortfolioDataLoader @Inject constructor( combine(actionsFlows) { actions -> walletsWithStatuses.mapValues { entry -> entry.value.mapNotNull { status -> - actions.firstOrNull { - it.userWallet == entry.key && it.status == status + actions.firstOrNull { data -> + data.userWallet == entry.key && data.status == status } } } }.onEmpty { emit( walletsWithStatuses.mapValues { (wallet, statuses) -> - statuses.map { + statuses.map { status -> PortfolioData.CryptoCurrencyData( userWallet = wallet, - status = it, + status = status, actions = emptyList(), ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt index 0b6b839c45..bf294a2895 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt @@ -58,7 +58,7 @@ internal class AddToPortfolioBSContentUMFactory( artworks: Map, ): TangemBottomSheetConfig { return (currentState ?: TangemBottomSheetConfig.Empty).copy( - isShown = portfolioUIData.portfolioBSVisibilityModel.addToPortfolioBSVisibility, + isShown = portfolioUIData.portfolioBSVisibilityModel.isAddToPortfolioBSVisible, onDismissRequest = { onAddToPortfolioVisibilityChange(false) }, content = if (selectedWallet != null && alreadyAddedNetworks != null) { AddToPortfolioBSContentUM( @@ -76,8 +76,8 @@ internal class AddToPortfolioBSContentUMFactory( alreadyAddedNetworks = alreadyAddedNetworks, onNetworkSwitchClick = onNetworkSwitchClick, ).convert(value = token), - isScanCardNotificationVisible = portfolioUIData.needColdWalletInteraction, - continueButtonEnabled = portfolioUIData.addToPortfolioData.isUserAddedNetworks( + isScanCardNotificationVisible = portfolioUIData.shouldRequireColdWalletInteraction, + isContinueButtonEnabled = portfolioUIData.addToPortfolioData.isUserAddedNetworks( userWalletId = selectedWallet.walletId, ), onContinueButtonClick = { @@ -90,7 +90,7 @@ internal class AddToPortfolioBSContentUMFactory( ) }, walletSelectorConfig = createWalletSelectorBSConfig( - isShow = portfolioUIData.portfolioBSVisibilityModel.walletSelectorBSVisibility, + isShow = portfolioUIData.portfolioBSVisibilityModel.isWalletSelectorBSVisible, portfolioData = portfolioData, selectedWalletId = selectedWallet.walletId, artworks = artworks, @@ -136,9 +136,9 @@ internal class AddToPortfolioBSContentUMFactory( val balance = portfolioData.walletsWithBalance[userWallet.walletId] UserWalletItemUMConverter( - onClick = { - if (it != selectedWalletId) { - onAnotherWalletSelect(it) + onClick = { walletId -> + if (walletId != selectedWalletId) { + onAnotherWalletSelect(walletId) onWalletSelectorVisibilityChange(false) } }, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioManager.kt index c617f718cd..15718128de 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioManager.kt @@ -124,8 +124,8 @@ internal class AddToPortfolioManager @Inject constructor( return } - update { - it.toMutableMap().apply { + update { currentMap -> + currentMap.toMutableMap().apply { this[userWalletId] = if (isAddAction) { this[userWalletId].orEmpty() + network } else { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt index a314e9a0e1..529f20adbc 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt @@ -77,8 +77,8 @@ internal class MarketsPortfolioModel @Inject constructor( private val newMarketsPortfolioDelegateFactory: NewMarketsPortfolioDelegate.Factory, ) : Model() { - val state: StateFlow get() = _state private val _state: MutableStateFlow = MutableStateFlow(value = MyPortfolioUM.Loading) + val state: StateFlow get() = _state private val params = paramsContainer.require() private val analyticsEventBuilder = PortfolioAnalyticsEvent.EventBuilder( @@ -99,6 +99,16 @@ internal class MarketsPortfolioModel @Inject constructor( override fun onDismiss() = bottomSheetNavigation.dismiss() } + private val currentAppCurrency = getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + private val tokenActionsHandler = tokenActionsIntentsFactory.create( currentAppCurrency = Provider { currentAppCurrency.value }, updateTokenReceiveBSConfig = { updateBlock -> @@ -109,10 +119,11 @@ internal class MarketsPortfolioModel @Inject constructor( } }, onHandleQuickAction = { handledAction -> + val currencyNetwork = handledAction.cryptoCurrencyData.status.currency.network analyticsEventHandler.send( analyticsEventBuilder.quickActionClick( actionUM = handledAction.action, - blockchainName = handledAction.cryptoCurrencyData.status.currency.network.name, + blockchainName = currencyNetwork.name, ), ) if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) { @@ -121,16 +132,6 @@ internal class MarketsPortfolioModel @Inject constructor( }, ) - private val currentAppCurrency = getSelectedAppCurrencyUseCase() - .map { maybeAppCurrency -> - maybeAppCurrency.getOrElse { AppCurrency.Default } - } - .stateIn( - scope = modelScope, - started = SharingStarted.Eagerly, - initialValue = AppCurrency.Default, - ) - private val factory = MyPortfolioUMFactory( onAddClick = { onAddToPortfolioBSVisibilityChange(isShow = true) @@ -145,8 +146,8 @@ internal class MarketsPortfolioModel @Inject constructor( onAddToPortfolioVisibilityChange = ::onAddToPortfolioBSVisibilityChange, onWalletSelectorVisibilityChange = ::onWalletSelectorVisibilityChange, onNetworkSwitchClick = ::onNetworkSwitchClick, - onAnotherWalletSelect = { - onWalletSelect(it) + onAnotherWalletSelect = { walletId -> + onWalletSelect(walletId) // === Analytics === analyticsEventHandler.send( analyticsEventBuilder.addToPortfolioWalletChanged(), @@ -186,8 +187,8 @@ internal class MarketsPortfolioModel @Inject constructor( scope = modelScope, token = params.token, tokenActionsHandler = tokenActionsHandler, - buttonState = newAddToPortfolioManager.state.map { - when (it) { + buttonState = newAddToPortfolioManager.state.map { managerState -> + when (managerState) { is NewAddToPortfolioManager.State.AvailableToAdd -> AddButtonState.Available NewAddToPortfolioManager.State.Init -> AddButtonState.Loading NewAddToPortfolioManager.State.NothingToAdd -> AddButtonState.Unavailable @@ -230,8 +231,8 @@ internal class MarketsPortfolioModel @Inject constructor( Timber.e("Failed to load selected wallet: $e") error("Failed to load selected wallet") } - .onEach { - selectedMultiWalletIdFlow.value = it.takeIf { it.isMultiCurrency }?.walletId + .onEach { wallet -> + selectedMultiWalletIdFlow.value = wallet.takeIf { it.isMultiCurrency }?.walletId } .launchIn(modelScope) } @@ -277,7 +278,10 @@ internal class MarketsPortfolioModel @Inject constructor( portfolioBSVisibilityModel = portfolioBSVisibilityModel, selectedWalletId = selectedWalletId, addToPortfolioData = addToPortfolioData, - needColdWalletInteraction = needColdWalletInteraction(selectedWalletId, addToPortfolioData), + shouldRequireColdWalletInteraction = needColdWalletInteraction( + selectedWalletId, + addToPortfolioData, + ), ) }, ) @@ -333,9 +337,9 @@ internal class MarketsPortfolioModel @Inject constructor( userWalletId = userWalletId, networkId = rawNetworkId, isMainNetwork = isMainNetwork, - ).getOrElse { + ).getOrElse { error -> Timber.e( - it, + error, """ Failed to check currency unsupported state |- User wallet ID: $userWalletId @@ -345,7 +349,7 @@ internal class MarketsPortfolioModel @Inject constructor( ) val message = SnackbarMessage( - message = it.localizedMessage + message = error.localizedMessage ?.let(::stringReference) ?: resourceReference(R.string.common_error), ) @@ -401,13 +405,13 @@ internal class MarketsPortfolioModel @Inject constructor( private fun onAddToPortfolioBSVisibilityChange(isShow: Boolean) { portfolioBSVisibilityModelFlow.update { - it.copy(addToPortfolioBSVisibility = isShow, walletSelectorBSVisibility = false) + it.copy(isAddToPortfolioBSVisible = isShow, isWalletSelectorBSVisible = false) } } private fun onWalletSelectorVisibilityChange(isShow: Boolean) { portfolioBSVisibilityModelFlow.update { - it.copy(addToPortfolioBSVisibility = true, walletSelectorBSVisibility = isShow) + it.copy(isAddToPortfolioBSVisible = true, isWalletSelectorBSVisible = isShow) } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt index 9fe46dc75b..d5c027adb8 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt @@ -143,8 +143,8 @@ internal class MyPortfolioUMFactory( ): List { val networkIds = networks.map(TokenMarketInfo.Network::networkId) - return mapNotNull { - it.takeIf { networkIds.contains(it.status.currency.network.backendId) } + return mapNotNull { currencyData -> + currencyData.takeIf { networkIds.contains(it.status.currency.network.backendId) } } } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt index fd1f0fecea..eecadfc1a5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt @@ -98,8 +98,8 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor( } } - private fun addFirstTokenFlow(): Flow = buttonState.map { - when (it) { + private fun addFirstTokenFlow(): Flow = buttonState.map { state -> + when (state) { AddButtonState.Loading -> MyPortfolioUM.Loading AddButtonState.Available -> MyPortfolioUM.AddFirstToken( onAddClick = onAddClick, @@ -120,7 +120,7 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor( }.flatten() val allAddedTokenActions = - portfolio.portfolios.map { portfolio -> portfolio.actionsFoAccountCurrencies() }.flatten() + portfolio.portfolios.map { portfolioItem -> portfolioItem.actionsFoAccountCurrencies() }.flatten() return combine( flow = combine(allAddedTokenActions) { it.toMap() }.distinctUntilChanged(), @@ -153,12 +153,12 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor( val currency = allAddedCurrency.first() // find userWallet than have this single added token portfolio.portfolios - .find { it.accountsWithAdded.find { account -> account.addedCurrency.isNotEmpty() } != null } + .find { it.accountsWithAdded.any { account -> account.addedCurrency.isNotEmpty() } } ?.userWallet ?.let { setOf(it.walletId to currency.currency.id) } - ?: setOf() + .orEmpty() } - else -> setOf() + else -> emptySet() } return MutableStateFlow(initValue) .also { this.expandedHolder = it } @@ -166,10 +166,10 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor( private fun portfolioWithThisCurrencyFLow(): Flow = allAccountSupplier().map { list -> list.map { it.addedAccountsFlow() } }.flatMapLatest { flows -> - combine(flows) { + combine(flows) { portfolios -> PortfoliosWithThisCurrency( currencyRawId = currencyRawId, - portfolios = it.toList(), + portfolios = portfolios.toList(), ) } }.distinctUntilChanged() @@ -237,8 +237,7 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor( } accountWithAdded.addedCurrency.forEach { currencyStatus -> - val actions = allActions[currencyStatus.currency]?.states - ?: emptyList() + val actions = allActions[currencyStatus.currency]?.states.orEmpty() val value = PortfolioData.CryptoCurrencyData( userWallet = userWallet, status = currencyStatus, @@ -273,6 +272,7 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor( name = this.accountName.toUM().value, icon = when (this) { is Account.CryptoPortfolio -> this.icon.toUM() + is Account.Payment -> TODO("[REDACTED_JIRA]") }, ), ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioBSVisibilityModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioBSVisibilityModel.kt index b2d43ea69a..acf1b7934c 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioBSVisibilityModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioBSVisibilityModel.kt @@ -3,12 +3,12 @@ package com.tangem.features.markets.portfolio.impl.model /** * Model for portfolio bottom sheet visibility * - * @property addToPortfolioBSVisibility visibility of add to portfolio bottom sheet - * @property walletSelectorBSVisibility visibility of wallet selector bottom sheet + * @property isAddToPortfolioBSVisible visibility of add to portfolio bottom sheet + * @property isWalletSelectorBSVisible visibility of wallet selector bottom sheet * [REDACTED_AUTHOR] */ internal data class PortfolioBSVisibilityModel( - val addToPortfolioBSVisibility: Boolean = false, - val walletSelectorBSVisibility: Boolean = false, + val isAddToPortfolioBSVisible: Boolean = false, + val isWalletSelectorBSVisible: Boolean = false, ) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt index b64ee6bf1f..17b0a6cdf5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioTokenUMConverter.kt @@ -95,8 +95,8 @@ internal class PortfolioTokenUMConverter( ) } }, - onQuickActionLongClick = { - if (it == QuickActionUM.Receive) { + onQuickActionLongClick = { quickAction -> + if (quickAction == QuickActionUM.Receive) { tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.CopyAddress, cryptoCurrencyData = cryptoData, @@ -111,7 +111,9 @@ internal class PortfolioTokenUMConverter( if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { when (action) { is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy - is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(showBadge = action.showBadge) + is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange( + shouldShowBadge = action.showBadge, + ) is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake is TokenActionsState.ActionState.YieldMode -> QuickActionUM.YieldMode(apy = action.apy) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt index f0dfad20dd..855e596731 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/PortfolioUIData.kt @@ -8,7 +8,7 @@ import com.tangem.domain.models.wallet.UserWalletId * @property portfolioBSVisibilityModel portfolio bottom sheet visibility model * @property selectedWalletId selected wallet id * @property addToPortfolioData add to portfolio data - * @property needColdWalletInteraction flag that indicates if user has missed derivations and has a cold wallet + * @property shouldRequireColdWalletInteraction flag that indicates if user has missed derivations and has a cold wallet * [REDACTED_AUTHOR] */ @@ -16,5 +16,5 @@ internal data class PortfolioUIData( val portfolioBSVisibilityModel: PortfolioBSVisibilityModel, val selectedWalletId: UserWalletId?, val addToPortfolioData: AddToPortfolioManager.AddToPortfolioData, - val needColdWalletInteraction: Boolean, + val shouldRequireColdWalletInteraction: Boolean, ) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt index 854f6ccdcb..853080d794 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt @@ -20,8 +20,6 @@ import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.model.TokenActionsState -import com.tangem.domain.tokens.model.details.NavigationAction -import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase import com.tangem.features.markets.impl.R import com.tangem.features.markets.portfolio.impl.loader.PortfolioData import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM @@ -43,7 +41,6 @@ internal class TokenActionsHandler @AssistedInject constructor( private val isDemoCardUseCase: IsDemoCardUseCase, private val messageSender: UiMessageSender, private val shareManager: ShareManager, - private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase, ) { private val disabledActionsInDemoMode = buildSet { @@ -73,14 +70,14 @@ internal class TokenActionsHandler @AssistedInject constructor( } private fun handleDemoMode(action: TokenActionsBSContentUM.Action, userWallet: UserWallet.Cold): Boolean { - val demoCard = isDemoCardUseCase.invoke(userWallet.cardId) - val needShowDemoWarning = demoCard && disabledActionsInDemoMode.contains(action) + val isDemoCard = isDemoCardUseCase.invoke(userWallet.cardId) + val shouldShowDemoWarning = isDemoCard && disabledActionsInDemoMode.contains(action) - if (needShowDemoWarning) { + if (shouldShowDemoWarning) { showDemoModeWarning() } - return needShowDemoWarning + return shouldShowDemoWarning } private fun showDemoModeWarning() { @@ -177,7 +174,7 @@ internal class TokenActionsHandler @AssistedInject constructor( AppRoute.Staking( userWalletId = cryptoCurrencyData.userWallet.walletId, cryptoCurrency = cryptoCurrencyData.status.currency, - yieldId = option.integrationId, + integrationId = option.integrationId, ), ) } @@ -186,41 +183,13 @@ internal class TokenActionsHandler @AssistedInject constructor( val yieldSupplyApy = cryptoCurrencyData.actions.filterIsInstance() .firstOrNull()?.apy ?: return - val (userWalletId, cryptoCurrencyStatus) = cryptoCurrencyData.let { currencyData -> - currencyData.userWallet.walletId to currencyData.status - } - val tokenEnterStatus = yieldSupplyEnterStatusUseCase(userWalletId, cryptoCurrencyStatus).getOrNull() - val isActiveYield = cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true - - when { - tokenEnterStatus != null -> router.push( - AppRoute.CurrencyDetails( - userWalletId = userWalletId, - currency = cryptoCurrencyStatus.currency, - navigationAction = NavigationAction.YieldSupply( - isActive = isActiveYield, - ), - ), - ) - isActiveYield -> { - router.push( - AppRoute.YieldSupplyActive( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - apy = yieldSupplyApy, - ), - ) - } - else -> { - router.push( - AppRoute.YieldSupplyPromo( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - apy = yieldSupplyApy, - ), - ) - } - } + router.push( + AppRoute.YieldSupplyEntry( + userWalletId = cryptoCurrencyData.userWallet.walletId, + cryptoCurrency = cryptoCurrencyData.status.currency, + apy = yieldSupplyApy, + ), + ) } @AssistedFactory diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokensPortfolioUMConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokensPortfolioUMConverter.kt index 9e5aba6a0a..47fe6e3cf3 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokensPortfolioUMConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokensPortfolioUMConverter.kt @@ -93,10 +93,10 @@ internal class TokensPortfolioUMConverter( private fun toggleQuickActions(cryptoData: PortfolioData.CryptoCurrencyData) { updateTokens { tokenList -> - tokenList.map { - it.copy( - isQuickActionsShown = if (it.matchWith(cryptoData)) { - !it.isQuickActionsShown + tokenList.map { token -> + token.copy( + isQuickActionsShown = if (token.matchWith(cryptoData)) { + !token.isQuickActionsShown } else { false }, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt index c12a36531c..f2d2d22dd2 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt @@ -54,13 +54,13 @@ internal fun AddToPortfolioBottomSheet(config: TangemBottomSheetConfig) { containerColor = TangemTheme.colors.background.tertiary, addBottomInsets = false, titleText = resourceReference(R.string.common_add_to_portfolio), - ) { + ) { contentState -> Content( modifier = Modifier.fillMaxWidth(), - state = it, + state = contentState, ) - WalletSelectorBottomSheet(it.walletSelectorConfig) + WalletSelectorBottomSheet(contentState.walletSelectorConfig) } } @@ -130,7 +130,7 @@ private fun Content(state: AddToPortfolioBSContentUM, modifier: Modifier = Modif .onGloballyPositioned { continueButtonAreaHeight = it.size.height }, - enabled = state.continueButtonEnabled, + enabled = state.isContinueButtonEnabled, isTangemIconVisible = state.isScanCardNotificationVisible, onClick = state.onContinueButtonClick, ) @@ -355,11 +355,11 @@ private fun PreviewContentTestOnDevice( isScanCardNotificationVisible = !contentState.isScanCardNotificationVisible, ) }, - continueButtonEnabled = true, + isContinueButtonEnabled = true, selectedWallet = content.selectedWallet.copy( onClick = { contentState = contentState.copy( - continueButtonEnabled = !contentState.continueButtonEnabled, + isContinueButtonEnabled = !contentState.isContinueButtonEnabled, ) }, ), diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt index 4f47bfddab..d7800514fc 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/PortfolioItem.kt @@ -31,11 +31,11 @@ internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifie val tokenItemState = remember(state.tokenItemState) { when (state.tokenItemState) { is TokenItemState.Content -> state.tokenItemState.copy( - onItemClick = { + onItemClick = { cryptoCurrency -> val onClick = state.tokenItemState.onItemClick if (onClick != null) { hapticManager.perform(TangemHapticEffect.View.ContextClick) - onClick.invoke(it) + onClick.invoke(cryptoCurrency) } }, ) @@ -73,10 +73,10 @@ internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifie @Composable private fun Preview(@PreviewParameter(PortfolioTokenUMProvider::class) tokenUM: PortfolioTokenUM) { TangemThemePreview { - var quickActionsShown by remember { mutableStateOf(value = false) } + var areQuickActionsShown by remember { mutableStateOf(value = false) } val onItemClick = { - quickActionsShown = quickActionsShown.not() + areQuickActionsShown = areQuickActionsShown.not() } PortfolioItem( @@ -87,7 +87,7 @@ private fun Preview(@PreviewParameter(PortfolioTokenUMProvider::class) tokenUM: is TokenItemState.Unreachable -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() }) else -> tokenUM.tokenItemState }, - isQuickActionsShown = quickActionsShown, + isQuickActionsShown = areQuickActionsShown, ), lastInList = true, ) @@ -111,7 +111,8 @@ private class PortfolioTokenUMProvider : CollectionPreviewParameterProvider LineSeparator() QuickActionItem( state = action, onClick = { onActionClick(action) }, - onLongClick = { onActionLongClick(action) }.takeIf { action.longClickAvailable }, + onLongClick = { onActionLongClick(action) }.takeIf { action.isLongClickAvailable }, ) } } @@ -174,7 +175,7 @@ private fun AnimatedVisibilityScope.QuickActionIcon(state: QuickActionUM) { .size(TangemTheme.dimens.size32) .drawWithContent { drawContent() - if (state is QuickActionUM.Exchange && state.showBadge) { + if (state is QuickActionUM.Exchange && state.shouldShowBadge) { drawBadge(containerColor = containerColor, offset = 4.dp) } }, @@ -215,7 +216,7 @@ private fun Preview() { PortfolioQuickActions( actions = persistentListOf( QuickActionUM.Buy, - QuickActionUM.Exchange(showBadge = true), + QuickActionUM.Exchange(shouldShowBadge = true), QuickActionUM.Receive, ), isVisible = isVisible, @@ -236,7 +237,7 @@ private fun PreviewRtl() { PortfolioQuickActions( actions = persistentListOf( QuickActionUM.Buy, - QuickActionUM.Exchange(showBadge = true), + QuickActionUM.Exchange(shouldShowBadge = true), QuickActionUM.Receive, ), isVisible = true, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt index 16b7cf769f..bdc1b092e3 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt @@ -54,7 +54,7 @@ internal class PreviewAddToPortfolioBSContentProvider : PreviewParameterProvider ), isScanCardNotificationVisible = true, isWalletBlockVisible = true, - continueButtonEnabled = true, + isContinueButtonEnabled = true, onContinueButtonClick = {}, walletSelectorConfig = TangemBottomSheetConfig.Empty, ), @@ -78,7 +78,7 @@ internal class PreviewAddToPortfolioBSContentProvider : PreviewParameterProvider ), isScanCardNotificationVisible = true, isWalletBlockVisible = false, - continueButtonEnabled = false, + isContinueButtonEnabled = false, onContinueButtonClick = {}, walletSelectorConfig = TangemBottomSheetConfig.Empty, ), diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt index 6f8a95f4fc..4e1b5c5bd9 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt @@ -17,6 +17,76 @@ import java.util.UUID internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider { + val sampleToken + get() = PortfolioTokenUM( + tokenItemState = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = CurrencyIconState.Locked, + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "My wallet")), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = "486,65 \$"), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "733,71097 MATIC"), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = stringReference(value = "XRP Ledger token"), + ), + onItemClick = {}, + onItemLongClick = {}, + ), + isQuickActionsShown = false, + quickActions = PortfolioTokenUM.QuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(shouldShowBadge = true), + QuickActionUM.Receive, + ), + onQuickActionClick = {}, + onQuickActionLongClick = {}, + ), + isBalanceHidden = false, + walletId = UserWalletId(""), + ) + + val walletHeader + get() = WalletHeader( + id = UUID.randomUUID().toString(), + name = stringReference("Wallet 1"), + ) + + val walletPortfolioHeader + get() = PortfolioHeader( + state = AccountTitleUM.Text(title = stringReference("Wallet 1")), + id = UUID.randomUUID().toString(), + ) + + val accountHeader + get() = PortfolioHeader( + state = AccountTitleUM.Account( + icon = AccountIconPreviewData.randomAccountIcon(), + name = stringReference("Main Account"), + prefixText = TextReference.EMPTY, + ), + id = UUID.randomUUID().toString(), + ) + val coinIconState + get() = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = com.tangem.core.ui.R.drawable.img_polygon_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ) + val accountToken + get() = sampleToken.copy( + tokenItemState = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = coinIconState, + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Polygon")), + fiatAmountState = FiatAmountState.Content(text = "321 $"), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "5,412 MATIC"), + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(value = "Token")), + onItemClick = {}, + onItemLongClick = {}, + ), + ) + override val values: Sequence get() = sequenceOf( MyPortfolioUM.Tokens( @@ -77,74 +147,4 @@ internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider Unit, val walletSelectorConfig: TangemBottomSheetConfig, ) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/QuickActionUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/QuickActionUM.kt index 777dd019c1..040f3d243e 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/QuickActionUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/QuickActionUM.kt @@ -12,7 +12,7 @@ internal sealed class QuickActionUM( val title: TextReference, val description: TextReference, @DrawableRes val icon: Int, - val longClickAvailable: Boolean = false, + val isLongClickAvailable: Boolean = false, ) { data object Buy : QuickActionUM( title = resourceReference(R.string.common_buy), @@ -21,7 +21,7 @@ internal sealed class QuickActionUM( ) data class Exchange( - val showBadge: Boolean, + val shouldShowBadge: Boolean, ) : QuickActionUM( title = resourceReference(R.string.common_exchange), description = resourceReference(R.string.exсhange_token_description), @@ -32,7 +32,7 @@ internal sealed class QuickActionUM( title = resourceReference(R.string.common_receive), description = resourceReference(R.string.receive_token_description), icon = R.drawable.ic_arrow_down_24, - longClickAvailable = true, + isLongClickAvailable = true, ) data object Stake : QuickActionUM( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt index db7d46227c..1cdaebeff0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt @@ -79,8 +79,8 @@ internal class TokenMarketBlockModel @Inject constructor( currencyID = params.cryptoCurrency.id, interval = PriceChangeInterval.H24, refresh = true, - ).collect { - it.onSome { res -> + ).collect { quotesOption -> + quotesOption.onSome { res -> quotesState = QuotesState( currentPrice = res.fiatRate, h24Percent = res.priceChange, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt index 14e1c4dc72..16c594bee1 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt @@ -107,12 +107,12 @@ internal class DefaultMarketsTokenListComponent @AssistedInject constructor( Scaffold( contentWindowInsets = WindowInsetsZero, containerColor = TangemTheme.colors.background.primary, - ) { + ) { paddingValues -> MarketsListWithBack( modifier = Modifier .statusBarsPadding() .imePadding() - .padding(it), + .padding(paddingValues), state = state, bottomSheetState = BottomSheetState.EXPANDED, onBackClick = router::pop, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt index 996e95d31b..ee943d293b 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt @@ -6,7 +6,7 @@ import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM internal sealed class MarketsListAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent(category = "Markets", event = event, params = params) { class BottomSheetOpened : MarketsListAnalyticsEvent(event = "Markets Screen Opened") @@ -39,10 +39,10 @@ internal sealed class MarketsListAnalyticsEvent( class YieldModeMoreInfoClicked : MarketsListAnalyticsEvent(event = "Yield Mode More Info") - data class TokenSearched(val tokenFound: Boolean) : MarketsListAnalyticsEvent( + data class TokenSearched(val wasTokenFound: Boolean) : MarketsListAnalyticsEvent( event = "Token Searched", params = mapOf( - "Result" to if (tokenFound) "Yes" else "No", + "Result" to if (wasTokenFound) "Yes" else "No", ), ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt index d33935747e..c898c68a73 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt @@ -52,7 +52,7 @@ internal class MarketsListModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { - private var updateQuotesJob = JobHolder() + private val updateQuotesJob = JobHolder() private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> maybeAppCurrency.getOrElse { AppCurrency.Default } @@ -64,6 +64,8 @@ internal class MarketsListModel @Inject constructor( private val visibleItemIds = MutableStateFlow>(emptyList()) + private lateinit var activeListManager: MarketsListBatchFlowManager + private val marketsListUMStateManager = MarketsListUMStateManager( currentVisibleIds = Provider { visibleItemIds.value }, onLoadMoreUiItems = { activeListManager.loadMore() }, @@ -95,7 +97,9 @@ internal class MarketsListModel @Inject constructor( dispatchers = dispatchers, ) - private var activeListManager: MarketsListBatchFlowManager = mainMarketsListManager + init { + activeListManager = mainMarketsListManager + } private val _tokenSelected = MutableSharedFlow>() @@ -173,8 +177,8 @@ internal class MarketsListModel @Inject constructor( } } - state.onEach { - if (it.list !is ListUM.Content) { + state.onEach { currentState -> + if (currentState.list !is ListUM.Content) { visibleItemIds.value = emptyList() } }.launchIn(modelScope) @@ -188,8 +192,8 @@ internal class MarketsListModel @Inject constructor( }.launchIn(modelScope) // load charts when new batch is being loaded - mainMarketsListManager.onLastBatchLoadedSuccess.onEach { - mainMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) + mainMarketsListManager.onLastBatchLoadedSuccess.onEach { batchKey -> + mainMarketsListManager.loadCharts(setOf(batchKey), marketsListUMStateManager.selectedInterval) modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) }.launchIn(modelScope) @@ -210,15 +214,19 @@ internal class MarketsListModel @Inject constructor( // reload list when sorting type has changed modelScope.launch { - marketsListUMStateManager.state.map { it.selectedSortBy }.distinctUntilChanged().drop(1).collectLatest { - mainMarketsListManager.reload() - } + marketsListUMStateManager.state + .map { state -> state.selectedSortBy } + .distinctUntilChanged() + .drop(1) + .collectLatest { + mainMarketsListManager.reload() + } } // listen current visible batch and update charts modelScope.launch { - visibleItemIds.mapNotNull { - if (it.isNotEmpty()) { + visibleItemIds.mapNotNull { itemIds -> + if (itemIds.isNotEmpty()) { activeListManager.getBatchKeysByItemIds(visibleItemIds.value) } else { null @@ -252,20 +260,20 @@ internal class MarketsListModel @Inject constructor( } modelScope.launch { - searchMarketsListManager.onLastBatchLoadedSuccess.collectLatest { - searchMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) + searchMarketsListManager.onLastBatchLoadedSuccess.collectLatest { batchKey -> + searchMarketsListManager.loadCharts(setOf(batchKey), marketsListUMStateManager.selectedInterval) modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) } } - searchMarketsListManager.isSearchNotFoundState.onEach { - if (it) { - analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(tokenFound = false)) + searchMarketsListManager.isSearchNotFoundState.onEach { isNotFound -> + if (isNotFound) { + analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(wasTokenFound = false)) } }.launchIn(modelScope) searchMarketsListManager.onFirstBatchLoadedSuccess.onEach { - analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(tokenFound = true)) + analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(wasTokenFound = true)) }.launchIn(modelScope) // analytics diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt index 4847ff333a..e96284ced9 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt @@ -68,17 +68,17 @@ internal class MarketsListBatchFlowManager( val onLastBatchLoadedSuccess = batchFlow.state .distinctUntilChanged { old, new -> old.status == new.status && old.data.size == new.data.size } - .mapNotNull { - when (val status = it.status) { + .mapNotNull { state -> + when (val status = state.status) { is PaginationStatus.Paginating -> { if (status.lastResult is BatchFetchResult.Success) { - it.data.lastOrNull()?.key + state.data.lastOrNull()?.key } else { null } } is PaginationStatus.EndOfPagination -> { - it.data.lastOrNull()?.key + state.data.lastOrNull()?.key } else -> null } @@ -86,22 +86,22 @@ internal class MarketsListBatchFlowManager( val onFirstBatchLoadedSuccess = batchFlow.state .distinctUntilChanged { old, new -> old.status == new.status && old.data.size == new.data.size } - .mapNotNull { - when (val status = it.status) { + .mapNotNull { state -> + when (val status = state.status) { is PaginationStatus.Paginating -> { if (status.lastResult is BatchFetchResult.Success) { - it.data.size == 1 + state.data.size == 1 } else { null } } is PaginationStatus.EndOfPagination -> { - it.data.size == 1 + state.data.size == 1 } else -> null } } - .filter { it } + .filter { isFirstBatch -> isFirstBatch } val isInInitialLoadingErrorState = batchFlow.state .map { it.status is PaginationStatus.InitialLoadingError } @@ -113,10 +113,10 @@ internal class MarketsListBatchFlowManager( ) val isSearchNotFoundState = batchFlow.state - .map { + .map { state -> currentSearchText().isNullOrEmpty().not() && - it.status is PaginationStatus.EndOfPagination && - it.data.isEmpty() + state.status is PaginationStatus.EndOfPagination && + state.data.isEmpty() } .distinctUntilChanged() .stateIn( @@ -175,22 +175,21 @@ internal class MarketsListBatchFlowManager( forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key val outItems = if (isInitialLoading) { - newList.map { + newList.map { batch -> Batch( - key = it.key, - data = converter.convertList(it.data), + key = batch.key, + data = converter.convertList(batch.data), ) } } else { - previousList!! if (previousList.size != newList.size) { val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) val newBatches = newList.filter { keysToAdd.contains(it.key) } - items + newBatches.map { + items + newBatches.map { batch -> Batch( - key = it.key, - data = converter.convertList(it.data), + key = batch.key, + data = converter.convertList(batch.data), ) } } else { @@ -264,8 +263,8 @@ internal class MarketsListBatchFlowManager( modelScope.launch { val currentData = batchFlow.state.value.data val alreadyLoadedChartsBatchKeys = currentData - .filter { - val first = it.data.firstOrNull() ?: return@filter false + .filter { batch -> + val first = batch.data.firstOrNull() ?: return@filter false val chartByInterval = when (interval) { TrendInterval.H24 -> first.tokenCharts.h24 TrendInterval.D7 -> first.tokenCharts.week @@ -273,7 +272,7 @@ internal class MarketsListBatchFlowManager( } chartByInterval != null } - .map { it.key } + .map { batch -> batch.key } .toSet() val batchesKeysToLoad = batchKeys.minus(alreadyLoadedChartsBatchKeys) @@ -302,9 +301,12 @@ internal class MarketsListBatchFlowManager( }, ) + val batchKeys = batchFlow.state.value.data + .map { it.key } + .toSet() actionsFlow.emit( BatchAction.UpdateBatches( - keys = batchFlow.state.value.data.map { it.key }.toSet(), + keys = batchKeys, updateRequest = TokenMarketUpdateRequest.UpdateQuotes( currencyId = currentAppCurrency().code, ), @@ -332,7 +334,10 @@ internal class MarketsListBatchFlowManager( } fun getTokenById(id: CryptoCurrency.RawID): TokenMarket? { - return batchFlow.state.value.data.map { it.data }.flatten().find { it.id == id } + val allTokens = batchFlow.state.value.data + .map { it.data } + .flatten() + return allTokens.find { it.id == id } } private fun SortByTypeUM.toRequestOrder(): TokenMarketListConfig.Order { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt index 33f89173a9..8c33b39d79 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt @@ -29,15 +29,19 @@ internal class MarketsListUMStateManager( private val onShowTokensUnder100kClicked: () -> Unit, ) { - private var sortByBottomSheetIsShown + val state = MutableStateFlow(createInitialState()) + val isInSearchStateFlow = state.map { it.searchBar.isActive }.distinctUntilChanged() + val searchQueryFlow = state.map { it.searchBar.query }.distinctUntilChanged() + + private var isSortByBottomSheetShown get() = state.value.sortByBottomSheet.isShown set(value) = state.update { it.copy(sortByBottomSheet = it.sortByBottomSheet.copy(isShown = value)) } var searchQuery get() = state.value.searchBar.query - private set(value) = state.update { - it.copy( - searchBar = it.searchBar.copy( + private set(value) = state.update { currentState -> + currentState.copy( + searchBar = currentState.searchBar.copy( query = value, isActive = value.isNotEmpty(), ), @@ -50,42 +54,42 @@ internal class MarketsListUMStateManager( var selectedSortByType get() = state.value.selectedSortBy - set(value) = state.update { - it.copy( + set(value) = state.update { currentState -> + currentState.copy( selectedSortBy = value, - sortByBottomSheet = it.sortByBottomSheet.copy( - content = (it.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( + sortByBottomSheet = currentState.sortByBottomSheet.copy( + content = (currentState.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( selectedOption = value, ), ), - list = if (it.list is ListUM.Content && it.selectedSortBy != value) { - it.list.copy(triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }) + list = if (currentState.list is ListUM.Content && currentState.selectedSortBy != value) { + currentState.list.copy( + triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }, + ) } else { - it.list + currentState.list }, ) } var selectedInterval get() = state.value.selectedInterval - set(value) = state.update { - it.copy( + set(value) = state.update { currentState -> + currentState.copy( selectedInterval = value, - list = if (it.list is ListUM.Content && - it.selectedSortBy != SortByTypeUM.Rating && - it.selectedInterval != value + list = if (currentState.list is ListUM.Content && + currentState.selectedSortBy != SortByTypeUM.Rating && + currentState.selectedInterval != value ) { - it.list.copy(triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }) + currentState.list.copy( + triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() }, + ) } else { - it.list + currentState.list }, ) } - val state = MutableStateFlow(state()) - val isInSearchStateFlow = state.map { it.searchBar.isActive }.distinctUntilChanged() - val searchQueryFlow = state.map { it.searchBar.query }.distinctUntilChanged() - fun onUiItemsChanged( isInErrorState: Boolean, isSearchNotFound: Boolean, @@ -127,7 +131,7 @@ internal class MarketsListUMStateManager( return currentState.copy( list = generalContentState(itemsWithFilteredPriceChange) .copy( - showUnder100kTokensNotificationWasHidden = currentState.showUnder100kButtonAlreadyPressed(), + wasUnder100kTokensNotificationHidden = currentState.showUnder100kButtonAlreadyPressed(), ), marketsNotificationUM = marketsNotificationUM, ) @@ -144,8 +148,8 @@ internal class MarketsListUMStateManager( return currentState.copy( list = generalContentState(filtered).copy( - showUnder100kTokensNotificationWasHidden = false, - showUnder100kTokensNotification = true, + wasUnder100kTokensNotificationHidden = false, + shouldShowUnder100kTokensNotification = true, onShowTokensUnder100kClicked = { onShowTokensUnder100kClicked() state.update { s -> @@ -153,8 +157,8 @@ internal class MarketsListUMStateManager( s.copy( list = s.list.copy( items = searchUiItemsCached, - showUnder100kTokensNotification = false, - showUnder100kTokensNotificationWasHidden = true, + shouldShowUnder100kTokensNotification = false, + wasUnder100kTokensNotificationHidden = true, ), ) } ?: s @@ -170,17 +174,17 @@ internal class MarketsListUMStateManager( } private fun MarketsListUM.showUnder100kButtonAlreadyPressed(): Boolean { - return this.list is ListUM.Content && this.isInSearchMode && this.list.showUnder100kTokensNotificationWasHidden + return this.list is ListUM.Content && this.isInSearchMode && this.list.wasUnder100kTokensNotificationHidden } // Show price change animation for visible items only private fun ImmutableList.filterPriceChangeByVisibility(): ImmutableList { val visibleItemIds = currentVisibleIds() - return map { - it.copy( - price = it.price.copy( - changeType = if (visibleItemIds.contains(it.id)) { - it.price.changeType + return map { item -> + item.copy( + price = item.price.copy( + changeType = if (visibleItemIds.contains(item.id)) { + item.price.changeType } else { null }, @@ -194,15 +198,43 @@ internal class MarketsListUMStateManager( items = newItems, loadMore = onLoadMoreUiItems, visibleIdsChanged = visibleItemsChanged, - showUnder100kTokensNotification = false, + shouldShowUnder100kTokensNotification = false, onShowTokensUnder100kClicked = {}, triggerScrollReset = consumedEvent(), onItemClick = onTokenClick, - showUnder100kTokensNotificationWasHidden = false, + wasUnder100kTokensNotificationHidden = false, ) } - private fun state(): MarketsListUM = MarketsListUM( + private fun onBottomSheetOptionClicked(sortByTypeUM: SortByTypeUM) { + state.update { currentState -> + currentState.copy( + selectedSortBy = sortByTypeUM, + sortByBottomSheet = currentState.sortByBottomSheet.copy( + isShown = false, + content = (currentState.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( + selectedOption = sortByTypeUM, + ), + ), + ) + } + } + + private fun consumeTriggerResetScrollEvent() { + state.update { currentState -> + currentState.copy( + list = if (currentState.list is ListUM.Content) { + currentState.list.copy( + triggerScrollReset = consumedEvent(), + ) + } else { + currentState.list + }, + ) + } + } + + private fun createInitialState(): MarketsListUM = MarketsListUM( list = ListUM.Loading, searchBar = SearchBarUM( placeholderText = resourceReference(R.string.markets_search_header_title), @@ -214,10 +246,10 @@ internal class MarketsListUMStateManager( selectedSortBy = SortByTypeUM.Rating, selectedInterval = MarketsListUM.TrendInterval.H24, onIntervalClick = { selectedInterval = it }, - onSortByButtonClick = { sortByBottomSheetIsShown = true }, + onSortByButtonClick = { isSortByBottomSheetShown = true }, sortByBottomSheet = TangemBottomSheetConfig( isShown = false, - onDismissRequest = { sortByBottomSheetIsShown = false }, + onDismissRequest = { isSortByBottomSheetShown = false }, content = SortByBottomSheetContentUM( selectedOption = SortByTypeUM.Rating, onOptionClicked = ::onBottomSheetOptionClicked, @@ -225,32 +257,4 @@ internal class MarketsListUMStateManager( ), marketsNotificationUM = null, ) - - private fun onBottomSheetOptionClicked(sortByTypeUM: SortByTypeUM) { - state.update { - it.copy( - selectedSortBy = sortByTypeUM, - sortByBottomSheet = it.sortByBottomSheet.copy( - isShown = false, - content = (it.sortByBottomSheet.content as SortByBottomSheetContentUM).copy( - selectedOption = sortByTypeUM, - ), - ), - ) - } - } - - private fun consumeTriggerResetScrollEvent() { - state.update { - it.copy( - list = if (it.list is ListUM.Content) { - it.list.copy( - triggerScrollReset = consumedEvent(), - ) - } else { - it.list - }, - ) - } - } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt index 0a7e31ebfc..79b42c8c55 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt @@ -81,10 +81,10 @@ internal fun MarketsList( end = TangemTheme.dimens.spacing16, bottom = 8.dp, ) - .onGloballyPositioned { - if (it.size.height > 0) { + .onGloballyPositioned { layoutCoordinates -> + if (layoutCoordinates.size.height > 0) { with(density) { - onHeaderSizeChange(it.size.height.toDp()) + onHeaderSizeChange(layoutCoordinates.size.height.toDp()) } } } @@ -312,23 +312,23 @@ private fun ItemsList( val searchLazyListState = rememberLazyListState() val mainLazyListState = rememberLazyListState() - val mainScrolled by remember { + val isMainScrolled by remember { derivedStateOf { mainLazyListState.firstVisibleItemScrollOffset > 0 } } - val searchScrolledState by remember { + val isSearchScrolled by remember { derivedStateOf { searchLazyListState.firstVisibleItemScrollOffset > 0 } } - LaunchedEffect(mainScrolled, isInSearchMode, searchScrolledState) { + LaunchedEffect(isMainScrolled, isInSearchMode, isSearchScrolled) { scrolledState.value = if (isInSearchMode) { - searchScrolledState + isSearchScrolled } else { - mainScrolled + isMainScrolled } } @@ -392,8 +392,8 @@ private fun Preview() { item.copy(id = CryptoCurrency.RawID(index.toString())) } .toImmutableList(), - showUnder100kTokensNotification = false, - showUnder100kTokensNotificationWasHidden = false, + shouldShowUnder100kTokensNotification = false, + wasUnder100kTokensNotificationHidden = false, loadMore = {}, visibleIdsChanged = {}, onShowTokensUnder100kClicked = {}, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt index e0daa6f742..1854695357 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt @@ -295,11 +295,11 @@ private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::cl Button( onClick = { - prices = prices.map { + prices = prices.map { priceInfo -> if (Random.nextBoolean()) { - it.first.inc() to PriceChangeType.UP + priceInfo.first.inc() to PriceChangeType.UP } else { - it.first.dec() to PriceChangeType.DOWN + priceInfo.first.dec() to PriceChangeType.DOWN } } state1 = state1.copy( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt index bbdcbf2697..b38a9cd3d9 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt @@ -101,7 +101,7 @@ internal fun MarketsListLazyColumn( ) } - if (isInSearchMode && state.showUnder100kTokensNotification) { + if (isInSearchMode && state.shouldShowUnder100kTokensNotification) { item(key = "show tokens under 100k".hashCode()) { ShowTokensUnder100kItem( onShowTokensClick = state.onShowTokensUnder100kClicked, @@ -126,7 +126,7 @@ internal fun MarketsListLazyColumn( triggerLoadMoreCheckOnItemsCountChange = true, onLoadMore = remember(state) { { - if (state is ListUM.Content && state.showUnder100kTokensNotification.not()) { + if (state is ListUM.Content && state.shouldShowUnder100kTokensNotification.not()) { state.loadMore() true } else { @@ -197,8 +197,8 @@ private fun SearchNothingFoundText(modifier: Modifier = Modifier) { private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) { val visibleItems by remember { derivedStateOf { - listState.layoutInfo.visibleItemsInfo.mapNotNull { - (it.key as? String)?.split(TOKEN_LAZY_LIST_ID_SEPARATOR)?.first() + listState.layoutInfo.visibleItemsInfo.mapNotNull { item -> + (item.key as? String)?.split(TOKEN_LAZY_LIST_ID_SEPARATOR)?.first() ?.let { rawId -> CryptoCurrency.RawID(rawId) } } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt index a83ea2e7d6..58b80368b4 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt @@ -36,7 +36,7 @@ data class MarketsListItemUM( ) fun getComposeKey(): String { - return id.value + TOKEN_LAZY_LIST_ID_SEPARATOR + marketCap.toString() + updateTimestamp + return id.value + TOKEN_LAZY_LIST_ID_SEPARATOR + marketCap.orEmpty() + updateTimestamp } companion object { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt index 8404856a87..d0ade846a6 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt @@ -46,8 +46,8 @@ sealed class ListUM { data class Content( val items: ImmutableList, - val showUnder100kTokensNotification: Boolean, - val showUnder100kTokensNotificationWasHidden: Boolean, + val shouldShowUnder100kTokensNotification: Boolean, + val wasUnder100kTokensNotificationHidden: Boolean, val loadMore: () -> Unit, val visibleIdsChanged: (List) -> Unit, val onShowTokensUnder100kClicked: () -> Unit, diff --git a/features/news/news-details/api/build.gradle.kts b/features/news/news-details/api/build.gradle.kts deleted file mode 100644 index b68f6815c6..0000000000 --- a/features/news/news-details/api/build.gradle.kts +++ /dev/null @@ -1,18 +0,0 @@ -plugins { - alias(deps.plugins.android.library) - alias(deps.plugins.kotlin.android) - id("configuration") -} - -android { - namespace = "com.tangem.features.news.details.api" -} - -dependencies { - /* Project - Core */ - implementation(projects.core.decompose) - implementation(projects.core.ui) - - /* Compose */ - implementation(deps.compose.runtime) -} diff --git a/features/news/news-details/api/src/main/kotlin/com/tangem/features/news/details/api/NewsDetailsComponent.kt b/features/news/news-details/api/src/main/kotlin/com/tangem/features/news/details/api/NewsDetailsComponent.kt deleted file mode 100644 index 167f65b88f..0000000000 --- a/features/news/news-details/api/src/main/kotlin/com/tangem/features/news/details/api/NewsDetailsComponent.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.features.news.details.api - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent - -interface NewsDetailsComponent : ComposableContentComponent { - - data class Params(val selectedArticleId: Int = 0) - - interface Factory : ComponentFactory -} \ No newline at end of file diff --git a/features/news/news-details/impl/build.gradle.kts b/features/news/news-details/impl/build.gradle.kts deleted file mode 100644 index a9953868af..0000000000 --- a/features/news/news-details/impl/build.gradle.kts +++ /dev/null @@ -1,43 +0,0 @@ -plugins { - alias(deps.plugins.android.library) - alias(deps.plugins.kotlin.android) - alias(deps.plugins.kotlin.kapt) - alias(deps.plugins.hilt.android) - id("configuration") -} - -android { - namespace = "com.tangem.features.news.details.impl" -} - -dependencies { - /* AndroidX */ - implementation(deps.lifecycle.compose) - implementation(deps.androidx.activity.compose) - - /** Compose */ - implementation(deps.compose.foundation) - implementation(deps.compose.ui) - implementation(deps.compose.ui.tooling) - implementation(deps.compose.material3) - - /** Core modules */ - implementation(projects.core.ui) - implementation(projects.core.utils) - implementation(projects.core.decompose) - implementation(projects.common.ui) - implementation(projects.common.routing) - - /** Feature modules */ - implementation(projects.features.news.newsDetails.api) - implementation(projects.domain.models) - - /** Other dependencies */ - implementation(deps.kotlin.immutable.collections) - implementation(deps.arrow.core) - implementation(deps.timber) - - /** DI */ - implementation(deps.hilt.android) - kapt(deps.hilt.kapt) -} diff --git a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/DefaultNewsDetailsComponent.kt b/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/DefaultNewsDetailsComponent.kt deleted file mode 100644 index d68562beb6..0000000000 --- a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/DefaultNewsDetailsComponent.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.tangem.features.news.details.impl - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.Modifier -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.features.news.details.api.NewsDetailsComponent -import com.tangem.features.news.details.impl.ui.NewsDetailsContent -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -internal class DefaultNewsDetailsComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted params: NewsDetailsComponent.Params, -) : NewsDetailsComponent, AppComponentContext by context { - - private val model: NewsDetailsModel = getOrCreateModel(params) - - @Composable - override fun Content(modifier: Modifier) { - val uiState by model.uiState.collectAsState() - NewsDetailsContent( - state = uiState, - onBackClick = model::onBackClick, - modifier = modifier, - ) - } - - @AssistedFactory - interface Factory : NewsDetailsComponent.Factory { - override fun create( - context: AppComponentContext, - params: NewsDetailsComponent.Params, - ): DefaultNewsDetailsComponent - } -} \ No newline at end of file diff --git a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/NewsDetailsModel.kt b/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/NewsDetailsModel.kt deleted file mode 100644 index 3ff89dde9d..0000000000 --- a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/NewsDetailsModel.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.tangem.features.news.details.impl - -import com.tangem.core.decompose.di.ModelScoped -import com.tangem.core.decompose.model.Model -import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.decompose.navigation.Router -import com.tangem.features.news.details.api.NewsDetailsComponent -import com.tangem.features.news.details.impl.ui.NewsDetailsUM -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.extensions.indexOfFirstOrNull -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject - -@ModelScoped -internal class NewsDetailsModel @Inject constructor( - paramsContainer: ParamsContainer, - override val dispatchers: CoroutineDispatcherProvider, - private val router: Router, -) : Model() { - - private val mockedArticles = MockArticlesFactory.createMockArticles() - - private val params = paramsContainer.require() - - private val _uiState = MutableStateFlow( - NewsDetailsUM( - /* [REDACTED_TODO_COMMENT] */ - articles = mockedArticles, - selectedArticleIndex = mockedArticles.indexOfFirstOrNull { it.id == params.selectedArticleId } ?: 0, - onShareClick = { /* [REDACTED_TODO_COMMENT] */ }, - onLikeClick = { /* [REDACTED_TODO_COMMENT] */ }, - ), - ) - val uiState: StateFlow = _uiState.asStateFlow() - - fun onBackClick() { - router.pop() - } -} \ No newline at end of file diff --git a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/di/NewsDetailsModule.kt b/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/di/NewsDetailsModule.kt deleted file mode 100644 index f0fcac783e..0000000000 --- a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/di/NewsDetailsModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.features.news.details.impl.di - -import com.tangem.features.news.details.api.NewsDetailsComponent -import com.tangem.features.news.details.impl.DefaultNewsDetailsComponent -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal interface NewsDetailsModule { - - @Binds - @Singleton - fun bindNewsDetailsComponentFactory(factory: DefaultNewsDetailsComponent.Factory): NewsDetailsComponent.Factory -} \ No newline at end of file diff --git a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsContent.kt b/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsContent.kt deleted file mode 100644 index a149f57bf4..0000000000 --- a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsContent.kt +++ /dev/null @@ -1,278 +0,0 @@ -package com.tangem.features.news.details.impl.ui - -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -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.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.tangem.common.ui.news.ArticleHeader -import com.tangem.core.ui.R -import com.tangem.core.ui.components.SecondaryButtonIconStart -import com.tangem.core.ui.components.appbar.TangemTopAppBar -import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM -import com.tangem.core.ui.components.buttons.common.TangemButtonSize -import com.tangem.core.ui.components.pager.PagerIndicator -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.news.details.impl.MockArticlesFactory - -// TODO [REDACTED_TASK_KEY] make internal -@Composable -fun NewsDetailsContent(state: NewsDetailsUM, onBackClick: () -> Unit, modifier: Modifier = Modifier) { - val pagerState = rememberPagerState( - initialPage = state.selectedArticleIndex, - pageCount = { state.articles.size }, - ) - - Column( - modifier = modifier - .fillMaxSize() - .background(TangemTheme.colors.background.secondary) - .systemBarsPadding(), - ) { - Column { - TangemTopAppBar( - title = null, - startButton = TopAppBarButtonUM.Icon( - iconRes = R.drawable.ic_back_24, - onClicked = onBackClick, - ), - endButton = TopAppBarButtonUM.Icon( - iconRes = R.drawable.ic_share_24, - onClicked = state.onShareClick, - ), - ) - Box( - modifier = Modifier.fillMaxSize(), - ) { - if (state.articles.isNotEmpty()) { - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - ) { page -> - ArticleDetail( - article = state.articles[page], - modifier = Modifier.fillMaxSize(), - onLikeClick = state.onLikeClick, - ) - } - - if (state.articles.size > 1) { - Column( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth(), - ) { - PagerIndicator( - pagerState = pagerState, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(bottom = 16.dp), - ) - } - } - } - } - } - } -} - -@Suppress("LongMethod") -@Composable -private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onLikeClick: () -> Unit) { - val density = LocalDensity.current - val pagerHeight = 48.dp - val contentPadding = 56.dp - LazyColumn( - modifier = modifier.padding(horizontal = 16.dp), - contentPadding = PaddingValues( - bottom = contentPadding + pagerHeight + WindowInsets.navigationBars.getBottom(density).dp, - ), - ) { - item { - ArticleHeader( - title = article.title, - createdAt = article.createdAt, - score = article.score, - tags = article.tags, - modifier = Modifier.padding(top = 16.dp), - ) - - if (article.shortContent.isNotEmpty()) { - QuickRecap( - content = article.shortContent, - modifier = Modifier.padding(top = 32.dp), - ) - } - - Text( - text = article.content, - style = TangemTheme.typography.body1, - color = TangemTheme.colors.text.primary1, - modifier = Modifier.padding(top = 16.dp), - ) - - Spacer(modifier = Modifier.height(24.dp)) - - SecondaryButtonIconStart( - iconResId = R.drawable.ic_heart_20, - text = "Like", // TODO [REDACTED_TASK_KEY] export to strings - size = TangemButtonSize.RoundedAction, - onClick = onLikeClick, - ) - - // TODO [REDACTED_TASK_KEY] add related tokens block - - if (article.sources.isNotEmpty()) { - Spacer(modifier = Modifier.height(24.dp)) - Row { - Text( - text = "Sources", // TODO [REDACTED_TASK_KEY] export to strings - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "${article.sources.size}", - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.tertiary, - ) - } - } - } - - if (article.sources.isNotEmpty()) { - item { - val sourcesPagerState = rememberPagerState( - pageCount = { article.sources.size }, - ) - Spacer(modifier = Modifier.height(12.dp)) - HorizontalPager( - state = sourcesPagerState, - modifier = Modifier - .fillMaxWidth(), - pageSpacing = 12.dp, - contentPadding = PaddingValues(horizontal = 0.dp), - ) { page -> - SourceItem( - source = article.sources[page], - modifier = Modifier.fillMaxWidth(), - ) - } - Spacer(modifier = Modifier.height(12.dp)) - } - } - } -} - -@Composable -private fun QuickRecap(content: String, modifier: Modifier = Modifier) { - Box( - modifier = modifier.height(IntrinsicSize.Min), - ) { - VerticalDivider( - modifier = Modifier.fillMaxHeight(), - thickness = 2.dp, - color = TangemTheme.colors.stroke.primary, - ) - Column( - modifier = Modifier - .padding(start = 16.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_quick_recap_16), - contentDescription = null, - tint = TangemTheme.colors.icon.accent, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Quick recap", // TODO [REDACTED_TASK_KEY] export to strings - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.accent, - ) - } - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = content, - style = TangemTheme.typography.body1, - color = TangemTheme.colors.text.primary1, - ) - } - } -} - -@Composable -private fun SourceItem(source: SourceUM, modifier: Modifier = Modifier) { - Column( - modifier = modifier - .background( - color = TangemTheme.colors.background.primary, - shape = RoundedCornerShape(12.dp), - ) - .padding(12.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 4.dp), - ) { - Icon( - painter = painterResource(id = R.drawable.ic_explore_16), - contentDescription = null, - tint = TangemTheme.colors.icon.informative, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = source.sourceName, - style = TangemTheme.typography.caption1, - color = TangemTheme.colors.text.tertiary, - ) - } - if (source.title.isNotEmpty()) { - Text( - text = source.title, - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.primary1, - modifier = Modifier.padding(bottom = 12.dp), - ) - } - Text( - text = source.publishedAt, - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) - } -} - -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun PreviewNewsDetailsContent() { - TangemThemePreview { - NewsDetailsContent( - state = NewsDetailsUM( - articles = MockArticlesFactory.createMockArticles(), - selectedArticleIndex = 0, - onShareClick = { }, - onLikeClick = { }, - ), - onBackClick = { }, - ) - } -} \ No newline at end of file diff --git a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsUM.kt b/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsUM.kt deleted file mode 100644 index 889fcc202a..0000000000 --- a/features/news/news-details/impl/src/main/kotlin/com/tangem/features/news/details/impl/ui/NewsDetailsUM.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.tangem.features.news.details.impl.ui - -import com.tangem.core.ui.components.label.entity.LabelUM -import kotlinx.collections.immutable.ImmutableList - -// TODO [REDACTED_TASK_KEY] make internal -data class NewsDetailsUM( - val articles: ImmutableList, - val selectedArticleIndex: Int, - val onShareClick: () -> Unit, - val onLikeClick: () -> Unit, -) - -// TODO [REDACTED_TASK_KEY] make internal -data class ArticleUM( - val id: Int, - val title: String, - val createdAt: String, - val score: Float, - val tags: ImmutableList, - val shortContent: String, - val content: String, - val sources: ImmutableList, -) - -// TODO [REDACTED_TASK_KEY] make internal -data class SourceUM( - val id: Int, - val title: String, - val sourceName: String, - val publishedAt: String, - val url: String, -) \ No newline at end of file diff --git a/features/nft/impl/detekt-baseline-debug.xml b/features/nft/impl/detekt-baseline-debug.xml index e079f03d2a..97fee15b2b 100644 --- a/features/nft/impl/detekt-baseline-debug.xml +++ b/features/nft/impl/detekt-baseline-debug.xml @@ -8,7 +8,6 @@ BooleanPropertyNaming:NFTCollectionsModel.kt$NFTCollectionsModel$val assetsFulfillQuery = if (query.isEmpty()) { true } else { when (val assets = it.assets) { is NFTCollection.Assets.Empty, is NFTCollection.Assets.Failed, is NFTCollection.Assets.Loading, -> false is NFTCollection.Assets.Value -> { assets.items.any { asset -> asset.name?.lowercase()?.contains(query.lowercase()) == true } } } } BooleanPropertyNaming:NFTCollectionsModel.kt$NFTCollectionsModel$val collectionFulfillQuery = query.isEmpty() || it.name?.lowercase()?.contains(query.lowercase()) == true BooleanPropertyNaming:UpdateDataStateTransformer.kt$UpdateDataStateTransformer$val custom = derivationPath is Network.DerivationPath.Custom - CanBeNonNullable:NFTReceive.kt$bottomSheetConfig: TangemBottomSheetConfig? MultilineLambdaItParameter:ChangeCollectionExpandedStateTransformer.kt$ChangeCollectionExpandedStateTransformer${ val collectionId = collection.collectionIdProvider() if (it.id == collectionId && it is NFTCollectionUM) { if (!it.isExpanded) { onFirstExpanded() } it.copy(isExpanded = !it.isExpanded) } else { it } } MultilineLambdaItParameter:NFTCollectionsContent.kt${ key(it.id) { NFTCollectionWarning( modifier = Modifier .padding(top = TangemTheme.dimens.spacing16), state = it, ) } } MultilineLambdaItParameter:NFTCollectionsModel.kt$NFTCollectionsModel${ ChangeCollectionExpandedStateTransformer( collection = collection, collectionIdProvider = collectionIdProvider, onFirstExpanded = { onFirstExpanded(collection) }, ).transform(it) } diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt index 431de33406..e0256c93ba 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt @@ -84,7 +84,7 @@ internal class UpdateDataStateTransformer( if (isAccountMode) { val result = mutableListOf() walletNFTCollections.collections.forEach { (account, nfts) -> - if (nfts.isEmpty()) return@forEach + if (nfts.hasNoContentCollections()) return@forEach result.add(account.toAccountPortfolioUM()) result.addAll(createNFTsUM(nfts)) } @@ -101,10 +101,17 @@ internal class UpdateDataStateTransformer( name = this.accountName.toUM().value, icon = when (this) { is Account.CryptoPortfolio -> this.icon.toUM() + is Account.Payment -> TODO("[REDACTED_JIRA]") }, ), ) + private fun List.hasNoContentCollections() = this + .all { it.contentCollections().isEmpty() } + + private fun NFTCollections.contentCollections() = (this.content as? NFTCollections.Content.Collections) + ?.collections.orEmpty() + private fun NFTCollectionsStateUM.createNFTsUM(nftCollections: List): Sequence = nftCollections .map { it.content } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt index 4c9424cf98..159ccf4bd9 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt @@ -390,7 +390,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( modelScope.launch { val cardInfo = getWalletMetaInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch - sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo)) + sendFeedbackEmailUseCase(FeedbackEmailType.BackupProblem(cardInfo)) } } 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/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt index a25fa8ec5c..51fe76f5af 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt @@ -48,22 +48,28 @@ internal class UpdateAccountTokenListTransformer( tokensList = accountListItemConverter.convertList(accountList).toPersistentList(), ) } else { - TokenListUMData.TokenList( - tokensList = persistentListOf( - TokensListItemUM.GroupTitle( - id = "available_tokens_title", - text = resourceReference(R.string.exchange_tokens_available_tokens_header), - ), - ) + accountList.flatMap { (_, currencyList) -> - currencyList.asSequence().map { (isAvailable, status) -> - if (isAvailable) { - availableConverter.convert(status) - } else { - unavailableConverter.convert(status) - } - }.map(TokensListItemUM::Token).toPersistentList() - }.toPersistentList(), - ) + val tokensList = accountList.flatMap { (_, currencyList) -> + currencyList.asSequence().map { (isAvailable, status) -> + if (isAvailable) { + availableConverter.convert(status) + } else { + unavailableConverter.convert(status) + } + }.map(TokensListItemUM::Token).toPersistentList() + }.toPersistentList() + + if (tokensList.isNotEmpty()) { + TokenListUMData.TokenList( + tokensList = persistentListOf( + TokensListItemUM.GroupTitle( + id = "available_tokens_title", + text = resourceReference(R.string.exchange_tokens_available_tokens_header), + ), + ) + tokensList, + ) + } else { + TokenListUMData.EmptyList + } }, isBalanceHidden = isBalanceHidden, warning = warning, diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/analytics/ReferralEvents.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/analytics/ReferralEvents.kt index 364b76ce0f..46154e0e95 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/analytics/ReferralEvents.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/analytics/ReferralEvents.kt @@ -14,8 +14,8 @@ sealed class ReferralEvents(event: String) : AnalyticsEvent(REFERRAL_PROGRAM_CAT sealed class ReferralEventsAccounts(event: String) : AnalyticsEvent(REFERRAL_PROGRAM_ACCOUNT_CATEGORY, event) { - class ListChooseAccount : ReferralEventsAccounts(event = "List - choose account") + class ListChooseAccount : ReferralEventsAccounts(event = "List - Choose Account") } private const val REFERRAL_PROGRAM_CATEGORY = "Referral Program" -private const val REFERRAL_PROGRAM_ACCOUNT_CATEGORY = "Referral program - Account" \ No newline at end of file +private const val REFERRAL_PROGRAM_ACCOUNT_CATEGORY = "Referral program / Account" \ No newline at end of file diff --git a/features/send-v2/api/build.gradle.kts b/features/send-v2/api/build.gradle.kts index 454fcee782..a44ab7f1f9 100644 --- a/features/send-v2/api/build.gradle.kts +++ b/features/send-v2/api/build.gradle.kts @@ -21,6 +21,9 @@ dependencies { /** Common */ implementation(projects.common.ui) + /** Domain */ + implementation(projects.domain.transaction) + /** Domain models */ api(projects.domain.models) implementation(projects.domain.appCurrency.models) diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt index 4dbf452662..c8e3780fe2 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt @@ -1,3 +1,5 @@ package com.tangem.features.send.v2.api -interface SendFeatureToggles \ No newline at end of file +interface SendFeatureToggles { + val isGaslessTransactionsEnabled: Boolean +} \ No newline at end of file diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendNotificationsComponent.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendNotificationsComponent.kt index 90823b9c00..5ec0bf75e3 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendNotificationsComponent.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendNotificationsComponent.kt @@ -28,7 +28,6 @@ interface SendNotificationsComponent { val analyticsCategoryName: String, val userWalletId: UserWalletId, val cryptoCurrencyStatus: CryptoCurrencyStatus, - val feeCryptoCurrencyStatus: CryptoCurrencyStatus, val appCurrency: AppCurrency, val notificationData: NotificationData, val callback: ModelCallback, @@ -41,6 +40,7 @@ interface SendNotificationsComponent { val isIgnoreReduce: Boolean, val fee: Fee?, val feeError: GetFeeError?, + val feeCryptoCurrencyStatus: CryptoCurrencyStatus, ) } diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/analytics/CommonSendAnalyticEvents.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/analytics/CommonSendAnalyticEvents.kt index 79d9472a6e..401205f025 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/analytics/CommonSendAnalyticEvents.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/analytics/CommonSendAnalyticEvents.kt @@ -187,6 +187,7 @@ sealed class CommonSendAnalyticEvents( enum class CommonSendSource(val analyticsName: String) { Send("Send"), + Swap("Swap"), SendWithSwap("Send&Swap"), WalletConnect("WalletConnect"), NFT("NFT"), diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt index 89ddb338b6..f903e9ca35 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/entity/FeeSelectorUM.kt @@ -1,13 +1,17 @@ package com.tangem.features.send.v2.api.entity import androidx.compose.runtime.Immutable +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.features.send.v2.api.R import com.tangem.features.send.v2.api.entity.FeeItem.* import kotlinx.collections.immutable.ImmutableList @@ -23,7 +27,10 @@ sealed class FeeSelectorUM { override val isPrimaryButtonEnabled = false } - data class Error(val error: GetFeeError) : FeeSelectorUM() { + data class Error( + val error: GetFeeError, + val isHidden: Boolean = false, + ) : FeeSelectorUM() { override val isPrimaryButtonEnabled = false } @@ -43,7 +50,7 @@ sealed class FeeSelectorUM { is Custom, -> AnalyticsParam.FeeType.Custom is Fast -> AnalyticsParam.FeeType.Max - is Market -> AnalyticsParam.FeeType.Normal + is Market, is FeeItem.Loading -> AnalyticsParam.FeeType.Normal is Slow -> AnalyticsParam.FeeType.Min } } @@ -61,6 +68,10 @@ data class FeeExtraInfo( val isFeeApproximate: Boolean, val isFeeConvertibleToFiat: Boolean, val isTronToken: Boolean, + val feeCryptoCurrencyStatus: CryptoCurrencyStatus, + val availableFeeCurrencies: ImmutableList? = null, + val transactionFeeExtended: TransactionFeeExtended? = null, + val isNotEnoughFunds: Boolean = false, ) sealed class FeeNonce { @@ -81,6 +92,16 @@ sealed class FeeItem { return this::class == other::class } + fun isLoading(): Boolean { + return this is Loading + } + + object Loading : FeeItem() { + override val title: TextReference = resourceReference(R.string.common_fee_selector_option_market) + override val iconRes: Int = R.drawable.ic_bird_24 + override val fee: Fee = Fee.Common(Amount(BigDecimal.ZERO, Blockchain.Unknown)) + } + data class Suggested( override val title: TextReference, override val fee: Fee, diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt index 2c1ae24ec6..77877cf76e 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/params/FeeSelectorParams.kt @@ -5,13 +5,17 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback import com.tangem.features.send.v2.api.entity.FeeSelectorUM sealed class FeeSelectorParams { abstract val state: FeeSelectorUM + abstract val userWalletId: UserWalletId + abstract val onLoadFeeExtended: (suspend (CryptoCurrencyStatus?) -> Either)? abstract val onLoadFee: suspend () -> Either abstract val cryptoCurrencyStatus: CryptoCurrencyStatus abstract val feeCryptoCurrencyStatus: CryptoCurrencyStatus @@ -22,6 +26,10 @@ sealed class FeeSelectorParams { data class FeeSelectorBlockParams( override val state: FeeSelectorUM, + override val userWalletId: UserWalletId, + override val onLoadFeeExtended: ( + suspend (CryptoCurrencyStatus?) -> Either + )? = null, override val onLoadFee: suspend () -> Either, override val cryptoCurrencyStatus: CryptoCurrencyStatus, override val feeCryptoCurrencyStatus: CryptoCurrencyStatus, @@ -29,10 +37,15 @@ sealed class FeeSelectorParams { override val feeDisplaySource: FeeDisplaySource, override val analyticsCategoryName: String, override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, + val bottomSheetShown: (Boolean) -> Unit = {}, ) : FeeSelectorParams() data class FeeSelectorDetailsParams( override val state: FeeSelectorUM, + override val userWalletId: UserWalletId, + override val onLoadFeeExtended: ( + suspend (CryptoCurrencyStatus?) -> Either + )? = null, override val onLoadFee: suspend () -> Either, override val cryptoCurrencyStatus: CryptoCurrencyStatus, override val feeCryptoCurrencyStatus: CryptoCurrencyStatus, diff --git a/features/send-v2/impl/build.gradle.kts b/features/send-v2/impl/build.gradle.kts index a4297a69f1..66e97a0ebc 100644 --- a/features/send-v2/impl/build.gradle.kts +++ b/features/send-v2/impl/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { /** Common */ implementation(projects.common.ui) implementation(projects.common.routing) + implementation(projects.common) /** Domain */ implementation(projects.domain.models) @@ -70,6 +71,7 @@ dependencies { implementation(projects.domain.swap.models) implementation(projects.domain.account) implementation(projects.domain.account.status) + implementation(projects.domain.transaction) /** Compose libraries */ diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt index f85c289e04..8b63d8547e 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt @@ -1,5 +1,12 @@ package com.tangem.features.send.v2 +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.send.v2.api.SendFeatureToggles +import javax.inject.Inject -internal class DefaultSendFeatureToggles : SendFeatureToggles \ No newline at end of file +internal class DefaultSendFeatureToggles @Inject constructor( + private val featureTogglesManager: FeatureTogglesManager, +) : SendFeatureToggles { + override val isGaslessTransactionsEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("GASLESS_TRANSACTIONS_ENABLED") +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/FeeBlock.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/FeeBlockSuccess.kt similarity index 98% rename from features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/FeeBlock.kt rename to features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/FeeBlockSuccess.kt index 473a7b235e..c9db856ad1 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/FeeBlock.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/FeeBlockSuccess.kt @@ -19,7 +19,7 @@ import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.impl.R @Composable -internal fun FeeBlock(feeSelectorUM: FeeSelectorUM) { +fun FeeBlockSuccess(feeSelectorUM: FeeSelectorUM) { if (feeSelectorUM !is FeeSelectorUM.Content) return val feeExtraInfo = feeSelectorUM.feeExtraInfo val feeFiatRateUM = feeSelectorUM.feeFiatRateUM diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendFeatureModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendFeatureModule.kt index 16b42fd31b..a349ed7129 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendFeatureModule.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendFeatureModule.kt @@ -8,25 +8,20 @@ import com.tangem.features.send.v2.sendnft.DefaultNFTSendComponent import com.tangem.features.send.v2.subcomponents.notifications.DefaultSendNotificationsComponent import dagger.Binds import dagger.Module -import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -@InstallIn(SingletonComponent::class) -@Module -internal object SendFeatureModule { - - @Provides - fun provideSendFeatureToggles(): SendFeatureToggles { - return DefaultSendFeatureToggles() - } -} +internal object SendFeatureModule @Module @InstallIn(SingletonComponent::class) internal interface SendFeatureModuleBinds { + @Binds + @Singleton + fun bindSendFeatureToggles(impl: DefaultSendFeatureToggles): SendFeatureToggles + @Binds @Singleton fun provideSendComponentFactory(impl: DefaultSendComponent.Factory): SendComponent.Factory diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt index dd4e3c17dd..baf95a222c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorBlockComponent.kt @@ -14,9 +14,10 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.extensions.conditional import com.tangem.features.send.v2.api.FeeSelectorBlockComponent import com.tangem.features.send.v2.api.FeeSelectorComponent +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.params.FeeSelectorParams -import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel +import com.tangem.features.send.v2.feeselector.model.FeeSelectorBlockModel import com.tangem.features.send.v2.feeselector.ui.FeeSelectorBlockContent import com.tangem.utils.extensions.isSingleItem import dagger.assisted.Assisted @@ -31,9 +32,10 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor( @Assisted private val params: FeeSelectorParams.FeeSelectorBlockParams, @Assisted onResult: (feeSelectorUM: FeeSelectorUM) -> Unit, private val feeSelectorComponentFactory: FeeSelectorComponent.Factory, + private val sendFeatureToggles: SendFeatureToggles, ) : FeeSelectorBlockComponent, AppComponentContext by appComponentContext { - private val model: FeeSelectorModel = getOrCreateModel(params = params) + private val model: FeeSelectorBlockModel = getOrCreateModel(params = params) private val bottomSheetSlot = childSlot( source = model.feeSelectorBottomSheet, @@ -45,6 +47,7 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor( params = FeeSelectorParams.FeeSelectorDetailsParams( state = model.uiState.value, onLoadFee = params.onLoadFee, + onLoadFeeExtended = params.onLoadFeeExtended, feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, cryptoCurrencyStatus = params.cryptoCurrencyStatus, callback = model, @@ -52,6 +55,7 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor( feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen, analyticsCategoryName = params.analyticsCategoryName, analyticsSendSource = params.analyticsSendSource, + userWalletId = params.userWalletId, ), onDismiss = { model.feeSelectorBottomSheet.dismiss() @@ -61,6 +65,10 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor( ) init { + bottomSheetSlot.subscribe { + params.bottomSheetShown(it.child != null) + } + model.uiState .onEach { onResult(it) } .launchIn(componentScope) @@ -77,11 +85,14 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor( val isScreenSource = params.feeDisplaySource == FeeSelectorParams.FeeDisplaySource.Screen val isNotSingleFee = (state as? FeeSelectorUM.Content)?.feeItems?.isSingleItem() == false + val isGaslessAvailable = state is FeeSelectorUM.Content && + (state as FeeSelectorUM.Content).feeExtraInfo.transactionFeeExtended != null FeeSelectorBlockContent( state = state, onReadMoreClick = model::onReadMoreClicked, + isGaslessFeatureEnabled = sendFeatureToggles.isGaslessTransactionsEnabled, modifier = modifier - .conditional(isScreenSource && isNotSingleFee) { + .conditional(isScreenSource && (isNotSingleFee || isGaslessAvailable)) { Modifier.clickable { model.showFeeSelector() } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt index aae3d79280..dc62754b5c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/DefaultFeeSelectorComponent.kt @@ -3,11 +3,23 @@ package com.tangem.features.send.v2.feeselector import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.decompose.navigation.inner.InnerRouter +import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.send.v2.api.FeeSelectorComponent import com.tangem.features.send.v2.api.params.FeeSelectorParams +import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.extended.FeeExtendedSelectorComponent +import com.tangem.features.send.v2.feeselector.component.speed.FeeSpeedSelectorComponent +import com.tangem.features.send.v2.feeselector.component.token.FeeTokenSelectorComponent import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel +import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute import com.tangem.features.send.v2.feeselector.ui.FeeSelectorModalBottomSheet import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -17,10 +29,33 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: FeeSelectorParams.FeeSelectorDetailsParams, @Assisted private val onDismiss: () -> Unit, + private val feeExtendedSelectorComponentFactory: FeeExtendedSelectorComponent.Factory, + private val feeSpeedSelectorComponentFactory: FeeSpeedSelectorComponent.Factory, + private val feeTokenSelectorComponentFactory: FeeTokenSelectorComponent.Factory, ) : FeeSelectorComponent, AppComponentContext by appComponentContext { private val model: FeeSelectorModel = getOrCreateModel(params = params) + val innerRouter = InnerRouter( + stackNavigation = model.stackNavigation, + popCallback = { + model.stackNavigation.pop { success -> + if (!success) { + dismiss() + } + } + }, + ) + + private val contentStack = childStack( + key = "FeeSelectorDetailsStack", + source = model.stackNavigation, + serializer = FeeSelectorRoute.serializer(), + initialConfiguration = model.getInitialRoute(), + handleBackButton = false, + childFactory = ::screenChild, + ) + override fun dismiss() { onDismiss() } @@ -28,14 +63,46 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor( @Composable override fun BottomSheet() { val state by model.uiState.collectAsStateWithLifecycle() + val contentStack by contentStack.subscribeAsState() + FeeSelectorModalBottomSheet( - onDismiss = ::dismiss, + childStack = contentStack, state = state, feeSelectorIntents = model, feeDisplaySource = params.feeDisplaySource, + onDismiss = onDismiss, + onBack = innerRouter::pop, ) } + private fun screenChild(config: FeeSelectorRoute, componentContext: ComponentContext): ComposableContentComponent { + val appComponentContext = childByContext( + componentContext = componentContext, + router = innerRouter, + ) + + val componentParams = FeeSelectorComponentParams( + parentParams = params, + state = model.uiState, + intents = model, + ) + + return when (config) { + FeeSelectorRoute.NetworkFee -> feeExtendedSelectorComponentFactory.create( + appComponentContext = appComponentContext, + params = componentParams, + ) + FeeSelectorRoute.ChooseSpeed -> feeSpeedSelectorComponentFactory.create( + appComponentContext = appComponentContext, + params = componentParams, + ) + FeeSelectorRoute.ChooseToken -> feeTokenSelectorComponentFactory.create( + appComponentContext = appComponentContext, + params = componentParams, + ) + } + } + @AssistedFactory interface Factory : FeeSelectorComponent.Factory { override fun create( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/FeeSelectorComponentParams.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/FeeSelectorComponentParams.kt new file mode 100644 index 0000000000..337a87186a --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/FeeSelectorComponentParams.kt @@ -0,0 +1,12 @@ +package com.tangem.features.send.v2.feeselector.component + +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.api.params.FeeSelectorParams +import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents +import kotlinx.coroutines.flow.MutableStateFlow + +internal class FeeSelectorComponentParams( + val parentParams: FeeSelectorParams.FeeSelectorDetailsParams, + val state: MutableStateFlow, + val intents: FeeSelectorIntents, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt new file mode 100644 index 0000000000..76f9baab92 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt @@ -0,0 +1,41 @@ +package com.tangem.features.send.v2.feeselector.component.extended + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.extended.model.FeeExtendedSelectorModel +import com.tangem.features.send.v2.feeselector.component.extended.ui.FeeExtendedSelectorContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class FeeExtendedSelectorComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: FeeSelectorComponentParams, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: FeeExtendedSelectorModel = getOrCreateModel(params = params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + FeeExtendedSelectorContent( + modifier = modifier, + state = state, + ) + } + + @AssistedFactory + interface Factory { + fun create( + appComponentContext: AppComponentContext, + params: FeeSelectorComponentParams, + ): FeeExtendedSelectorComponent + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/FeeExtendedSelectorUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/FeeExtendedSelectorUM.kt new file mode 100644 index 0000000000..a9406c3f73 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/entity/FeeExtendedSelectorUM.kt @@ -0,0 +1,14 @@ +package com.tangem.features.send.v2.feeselector.component.extended.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.features.send.v2.api.entity.FeeItem +import com.tangem.features.send.v2.api.entity.FeeSelectorUM + +@Immutable +data class FeeExtendedSelectorUM( + val parent: FeeSelectorUM.Content, + val token: TokenItemState, + val fee: FeeItem, + val onFeeClick: () -> Unit, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt new file mode 100644 index 0000000000..0a68ace88e --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt @@ -0,0 +1,72 @@ +package com.tangem.features.send.v2.feeselector.component.extended.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.extended.entity.FeeExtendedSelectorUM +import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +class FeeExtendedSelectorModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val router: Router, +) : Model() { + + private val params = paramsContainer.require() + private var appCurrency: AppCurrency = AppCurrency.Default + + init { + initAppCurrency() + } + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + init { + params.state + .filterIsInstance() + .onEach { state -> uiState.value = convertState(state) } + .launchIn(modelScope) + } + + private fun getInitialState(): FeeExtendedSelectorUM { + val parentContentState = params.state.value as FeeSelectorUM.Content + return convertState(parentContentState) + } + + private fun initAppCurrency() { + modelScope.launch { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + } + } + + private fun convertState(state: FeeSelectorUM.Content): FeeExtendedSelectorUM { + val tokenConverter = SelectedTokenItemConverter( + appCurrencyProvider = Provider { appCurrency }, + onTokenClick = { router.push(FeeSelectorRoute.ChooseToken) }, + isTokenSelectionAvailable = state.feeExtraInfo.availableFeeCurrencies.orEmpty().size > 1, + ) + + return FeeExtendedSelectorUM( + parent = state, + token = tokenConverter.convert(state.feeExtraInfo.feeCryptoCurrencyStatus), + fee = state.selectedFeeItem, + onFeeClick = { router.push(FeeSelectorRoute.ChooseSpeed) }, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/SelectedTokenItemConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/SelectedTokenItemConverter.kt new file mode 100644 index 0000000000..070c1895a1 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/SelectedTokenItemConverter.kt @@ -0,0 +1,68 @@ +package com.tangem.features.send.v2.feeselector.component.extended.model + +import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.ui.components.icons.IconTint +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.send.v2.impl.R +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter + +internal class SelectedTokenItemConverter( + private val appCurrencyProvider: Provider, + private val onTokenClick: () -> Unit, + private val isTokenSelectionAvailable: Boolean, +) : Converter { + + override fun convert(value: CryptoCurrencyStatus): TokenItemState { + if (value.value !is CryptoCurrencyStatus.Loaded) { + return TokenItemState.Loading(id = value.currency.id.value) + } + + val appCurrency = appCurrencyProvider() + + val tokenItemConverter = TokenItemStateConverter( + appCurrency = appCurrencyProvider(), + onItemClick = if (isTokenSelectionAvailable) { + { _, _ -> onTokenClick() } + } else { + null + }, + ) + + val state = tokenItemConverter.convert(value) + + return when (state) { + is TokenItemState.Content -> { + val balance = value.value.fiatAmount.format { + fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) + } + + state.copy( + fiatAmountState = if (isTokenSelectionAvailable) { + TokenItemState.FiatAmountState.Icon( + iconRes = R.drawable.ic_select_18_24, + tint = IconTint.Informative, + ) + } else { + TokenItemState.FiatAmountState.Empty + }, + subtitleState = TokenItemState.SubtitleState.TextContent( + value = resourceReference( + R.string.common_balance, + wrappedList(balance), + ), + isAvailable = false, + ), + subtitle2State = null, + ) + } + else -> state + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt new file mode 100644 index 0000000000..e084555a7b --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt @@ -0,0 +1,194 @@ +package com.tangem.features.send.v2.feeselector.component.extended.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.amountScreen.utils.getFiatReference +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.icons.IconTint +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.conditional +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fee +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.features.send.v2.api.entity.* +import com.tangem.features.send.v2.feeselector.component.extended.entity.FeeExtendedSelectorUM +import com.tangem.features.send.v2.feeselector.component.speed.ui.RegularFeeItemContent +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal + +@Composable +fun FeeExtendedSelectorContent(state: FeeExtendedSelectorUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ) + .fillMaxWidth(), + ) { + TokenItem( + modifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.action), + state = state.token, + isBalanceHidden = false, + ) + + SpacerH8() + + val feeFiatRateUM = state.parent.feeFiatRateUM + val fee = state.fee.fee + val isChooseSpeedAvailable = state.parent.feeItems.size > 1 + + RegularFeeItemContent( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .conditional(isChooseSpeedAvailable) { + clickable(onClick = state.onFeeClick) + } + .background( + TangemTheme.colors.background.action, + RoundedCornerShape(TangemTheme.dimens.radius14), + ), + title = state.fee.title, + iconRes = state.fee.iconRes, + iconBackgroundColor = TangemTheme.colors.icon.accent.copy(alpha = 0.1F), + iconTint = TangemTheme.colors.icon.accent, + preDot = stringReference( + fee.amount.value.format { + crypto( + symbol = fee.amount.currencySymbol, + decimals = fee.amount.decimals, + ).fee(canBeLower = state.parent.feeExtraInfo.isFeeApproximate) + }, + ), + postDot = if (state.parent.feeExtraInfo.isFeeConvertibleToFiat && feeFiatRateUM != null) { + getFiatReference( + value = fee.amount.value, + rate = feeFiatRateUM.rate, + appCurrency = feeFiatRateUM.appCurrency, + ) + } else { + null + }, + ellipsizeOffset = fee.amount.currencySymbol.length, + showSelectorIcon = isChooseSpeedAvailable, + isNotEnoughFunds = state.parent.feeExtraInfo.isNotEnoughFunds, + isLoading = state.fee.isLoading(), + ) + } +} + +@Suppress("LongMethod") +@Preview +@Composable +private fun Preview() { + val tokenItemState = TokenItemState.Content( + id = "1", + iconState = CurrencyIconState.Locked, + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Bitcoin")), + fiatAmountState = TokenItemState.FiatAmountState.Icon( + R.drawable.ic_select_18_24, + tint = IconTint.Informative, + ), + subtitle2State = null, + subtitleState = TokenItemState.SubtitleState.TextContent( + value = stringReference("Balance: 0,35853044 BTC"), + isAvailable = false, + ), + onItemClick = {}, + onItemLongClick = {}, + ) + + val cryptoCurrencyStatus = CryptoCurrencyStatus( + currency = CryptoCurrency.Coin( + id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), + network = Network( + id = Network.ID( + value = "bitcoin", + derivationPath = Network.DerivationPath.None, + ), + backendId = "bitcoin", + name = "Bitcoin", + currencySymbol = "BTC", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("bitcoin"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ), + name = "Bitcoin", + symbol = "BTC", + decimals = 8, + iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png", + isCustom = false, + ), + value = CryptoCurrencyStatus.Loading, + ) + + val lowFeeItem = + FeeItem.Market(Fee.Common(amount = Amount(value = BigDecimal("0.0002876"), blockchain = Blockchain.Ethereum))) + + TangemThemePreview { + FeeExtendedSelectorContent( + modifier = Modifier.background(TangemTheme.colors.background.tertiary), + state = FeeExtendedSelectorUM( + parent = FeeSelectorUM.Content( + isPrimaryButtonEnabled = true, + feeItems = persistentListOf(lowFeeItem), + selectedFeeItem = lowFeeItem, + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = false, + isFeeConvertibleToFiat = true, + isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, + isNotEnoughFunds = true, + ), + feeNonce = FeeNonce.None, + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal("2500"), + appCurrency = AppCurrency.Default, + ), + fees = TransactionFee.Single(lowFeeItem.fee), + ), + token = tokenItemState, + fee = FeeItem.Market( + fee = Fee.Common( + Amount( + value = BigDecimal("0.02"), + blockchain = Blockchain.Ethereum, + ), + ), + ), + onFeeClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/FeeSpeedSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/FeeSpeedSelectorComponent.kt new file mode 100644 index 0000000000..1735ccd476 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/FeeSpeedSelectorComponent.kt @@ -0,0 +1,44 @@ +package com.tangem.features.send.v2.feeselector.component.speed + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.speed.model.FeeSpeedSelectorModel +import com.tangem.features.send.v2.feeselector.component.speed.ui.FeeSpeedSelectorContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class FeeSpeedSelectorComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: FeeSelectorComponentParams, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: FeeSpeedSelectorModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + if (state is FeeSelectorUM.Content) { + FeeSpeedSelectorContent( + state = state as FeeSelectorUM.Content, + intents = model, + ) + } + } + + @AssistedFactory + interface Factory { + fun create( + appComponentContext: AppComponentContext, + params: FeeSelectorComponentParams, + ): FeeSpeedSelectorComponent + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/FeeSpeedSelectorIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/FeeSpeedSelectorIntents.kt new file mode 100644 index 0000000000..13a9fb4477 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/FeeSpeedSelectorIntents.kt @@ -0,0 +1,15 @@ +package com.tangem.features.send.v2.feeselector.component.speed + +import androidx.compose.runtime.Stable +import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents +import com.tangem.features.send.v2.feeselector.model.StubFeeSelectorIntents + +@Stable +internal interface FeeSpeedSelectorIntents : FeeSelectorIntents { + fun onLearnMoreClick() +} + +@Stable +internal class StubFeeSpeedSelectorIntents : FeeSpeedSelectorIntents, FeeSelectorIntents by StubFeeSelectorIntents() { + override fun onLearnMoreClick() {} +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/model/FeeSpeedSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/model/FeeSpeedSelectorModel.kt new file mode 100644 index 0000000000..8f42d817fb --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/model/FeeSpeedSelectorModel.kt @@ -0,0 +1,39 @@ +package com.tangem.features.send.v2.feeselector.component.speed.model + +import androidx.compose.runtime.Stable +import com.tangem.common.TangemBlogUrlBuilder +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.speed.FeeSpeedSelectorIntents +import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class FeeSpeedSelectorModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val urlOpener: UrlOpener, +) : Model(), FeeSpeedSelectorIntents, + FeeSelectorIntents by paramsContainer.require().intents { + + private val params = paramsContainer.require() + + val uiState: StateFlow + field = params.state + + override fun onLearnMoreClick() { + modelScope.launch { + urlOpener.openUrl( + TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee), + ) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt new file mode 100644 index 0000000000..ef604613c9 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt @@ -0,0 +1,578 @@ +package com.tangem.features.send.v2.feeselector.component.speed.ui + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.* +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.amountScreen.utils.getFiatReference +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.components.atoms.text.TextEllipsis +import com.tangem.core.ui.components.inputrow.InputRowEnter +import com.tangem.core.ui.components.inputrow.InputRowEnterInfoAmountV2 +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fee +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.SendSelectNetworkFeeBottomSheetTestTags +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.features.send.v2.api.entity.* +import com.tangem.features.send.v2.feeselector.component.speed.FeeSpeedSelectorIntents +import com.tangem.features.send.v2.feeselector.component.speed.StubFeeSpeedSelectorIntents +import com.tangem.features.send.v2.impl.R +import com.tangem.utils.StringsSigns +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal + +@Composable +internal fun FeeSpeedSelectorContent( + state: FeeSelectorUM.Content, + intents: FeeSpeedSelectorIntents, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Title(onLearnMoreClick = intents::onLearnMoreClick) + SpacerH8() + FeeSelectorItems( + state = state, + intents = intents, + ) + } +} + +@Composable +private fun Title(modifier: Modifier = Modifier, onLearnMoreClick: () -> Unit) { + val linkText = stringResourceSafe(R.string.common_learn_more) + val fullString = stringResourceSafe(R.string.fee_selector_choose_speed_description, linkText) + val linkTextPosition = fullString.length - linkText.length + val defaultColor = TangemTheme.colors.text.secondary + val linkColor = TangemTheme.colors.text.accent + val annotatedString = remember(defaultColor, linkColor, onLearnMoreClick) { + buildAnnotatedString { + withStyle(SpanStyle(defaultColor)) { + append(fullString.substring(0, linkTextPosition)) + } + withLink( + link = LinkAnnotation.Clickable( + tag = "learn_more", + linkInteractionListener = { onLearnMoreClick() }, + ), + block = { + withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.None)) { + append( + fullString.substring(linkTextPosition, fullString.length).replace( + ' ', + StringsSigns.NON_BREAKING_SPACE, + ), + ) + } + }, + ) + } + } + + Text( + modifier = modifier.padding(horizontal = 32.dp), + text = annotatedString, + style = TangemTheme.typography.caption2, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun FeeSelectorItems( + state: FeeSelectorUM.Content, + intents: FeeSpeedSelectorIntents, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + val feeFiatRateUM = state.feeFiatRateUM + state.feeItems.fastForEachIndexed { index, item -> + val isSelected = item.isSameClass(state.selectedFeeItem) + val iconTint by animateColorAsState( + targetValue = if (isSelected) TangemTheme.colors.icon.accent else TangemTheme.colors.text.tertiary, + label = "Fee selector icon tint change", + ) + val iconBackgroundColor by animateColorAsState( + targetValue = if (isSelected) { + TangemTheme.colors.icon.accent.copy(alpha = 0.1F) + } else { + TangemTheme.colors.background.secondary + }, + label = "Fee selector icon background change", + ) + val itemModifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + .selectedBorder(isSelected = isSelected) + .clickableSingle(onClick = { intents.onFeeItemSelected(item) }) + .background(TangemTheme.colors.background.action) + when (item) { + is FeeItem.Custom -> CustomFeeBlock( + modifier = itemModifier, + customFee = item, + isSelected = isSelected, + iconBackgroundColor = iconBackgroundColor, + iconTint = iconTint, + onValueChange = intents::onCustomFeeValueChange, + nonce = state.feeNonce, + ) + else -> RegularFeeItemContent( + modifier = itemModifier, + title = item.title, + iconRes = item.iconRes, + iconBackgroundColor = iconBackgroundColor, + iconTint = iconTint, + preDot = stringReference( + item.fee.amount.value.format { + crypto( + symbol = item.fee.amount.currencySymbol, + decimals = item.fee.amount.decimals, + ).fee(canBeLower = state.feeExtraInfo.isFeeApproximate) + }, + ), + postDot = if (state.feeExtraInfo.isFeeConvertibleToFiat && feeFiatRateUM != null) { + getFiatReference( + value = item.fee.amount.value, + rate = feeFiatRateUM.rate, + appCurrency = feeFiatRateUM.appCurrency, + ) + } else { + null + }, + ellipsizeOffset = item.fee.amount.currencySymbol.length, + ) + } + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun CustomFeeBlock( + customFee: FeeItem.Custom, + isSelected: Boolean, + iconBackgroundColor: Color, + iconTint: Color, + onValueChange: (Int, String) -> Unit, + nonce: FeeNonce, + modifier: Modifier = Modifier, +) { + val textMeasurer = rememberTextMeasurer() + val textHeight = with(LocalDensity.current) { + textMeasurer.measure("S", style = TangemTheme.typography.subtitle2).size.height.toDp() + + textMeasurer.measure("S", style = TangemTheme.typography.caption1).size.height.toDp() + 2.dp + } + + Column(modifier = modifier) { + Row( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 15.dp) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_FEE_ITEM), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier + .size(36.dp) + .background(color = iconBackgroundColor, shape = CircleShape) + .padding(6.dp) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_ICON), + painter = painterResource(R.drawable.ic_edit_v2_24), + tint = iconTint, + contentDescription = null, + ) + Box(Modifier.heightIn(min = textHeight)) { + Text( + text = stringResourceSafe(R.string.common_custom), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle2, + modifier = Modifier + .align(Alignment.CenterStart) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_TITLE), + ) + } + } + AnimatedVisibility( + visible = isSelected, + label = "Custom Fee Selected Animation", + enter = expandVertically().plus(fadeIn()), + exit = shrinkVertically().plus(fadeOut()), + ) { + ExpandedCustomFeeItems( + customFeeFields = customFee.customValues, + onValueChange = onValueChange, + nonce = nonce, + ) + } + } +} + +@Composable +private fun ExpandedCustomFeeItems( + customFeeFields: ImmutableList, + onValueChange: (Int, String) -> Unit, + nonce: FeeNonce, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + customFeeFields.fastForEachIndexed { index, field -> + val isShowDivider = index != customFeeFields.size - 1 || nonce is FeeNonce.Nonce + if (field.label != null) { + InputRowEnterInfoAmountV2( + text = field.value, + decimals = field.decimals, + symbol = field.symbol, + title = field.title, + titleColor = TangemTheme.colors.text.tertiary, + info = field.label, + description = field.footer, + keyboardOptions = field.keyboardOptions, + keyboardActions = field.keyboardActions, + onValueChange = { onValueChange(index, it) }, + showDivider = isShowDivider, + isReadOnly = field.isReadonly, + ) + } else { + InputRowEnterInfoAmountV2( + text = field.value, + decimals = field.decimals, + title = field.title, + titleColor = TangemTheme.colors.text.tertiary, + symbol = field.symbol, + description = field.footer, + onValueChange = { onValueChange(index, it) }, + keyboardOptions = field.keyboardOptions, + keyboardActions = field.keyboardActions, + showDivider = isShowDivider, + ) + } + } + + if (nonce is FeeNonce.Nonce) { + InputRowEnter( + text = nonce.nonce?.toString().orEmpty(), + title = resourceReference(R.string.send_nonce), + description = resourceReference(R.string.send_nonce_footer), + onValueChange = nonce.onNonceChange, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + placeholder = resourceReference(R.string.send_nonce_hint), + titleColor = TangemTheme.colors.text.secondary, + showDivider = false, + modifier = Modifier + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.NONCE_INPUT_ITEM), + ) + } + } +} + +@Composable +internal fun RegularFeeItemContent( + title: TextReference, + @DrawableRes iconRes: Int, + iconBackgroundColor: Color, + iconTint: Color, + modifier: Modifier = Modifier, + preDot: TextReference? = null, + postDot: TextReference? = null, + ellipsizeOffset: Int? = null, + showSelectorIcon: Boolean = false, + isLoading: Boolean = false, + isNotEnoughFunds: Boolean = false, +) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 15.dp) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_FEE_ITEM), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier + .size(36.dp) + .background(color = iconBackgroundColor, shape = CircleShape) + .padding(6.dp) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_ICON), + painter = painterResource(iconRes), + tint = iconTint, + contentDescription = null, + ) + FeeDescription( + title = title, + preDot = preDot, + postDot = postDot, + ellipsizeOffset = ellipsizeOffset, + isLoading = isLoading, + isNotEnoughFunds = isNotEnoughFunds, + ) + + if (showSelectorIcon) { + SpacerWMax() + + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_select_18_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + } + } + } +} + +@Composable +private fun FeeDescription( + title: TextReference, + modifier: Modifier = Modifier, + preDot: TextReference? = null, + postDot: TextReference? = null, + ellipsizeOffset: Int? = null, + isLoading: Boolean = false, + isNotEnoughFunds: Boolean = false, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title.resolveReference(), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle2, + modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE), + ) + when { + isNotEnoughFunds -> Text( + text = stringResourceSafe(R.string.gasless_not_enough_funds_to_cover_token_fee), + color = TangemTheme.colors.text.warning, + style = TangemTheme.typography.caption1, + ) + isLoading -> TextShimmer( + modifier = Modifier.width(122.dp), + style = TangemTheme.typography.caption1, + ) + preDot != null -> FeeValueContent(preDot = preDot, postDot = postDot, ellipsizeOffset = ellipsizeOffset) + } + } +} + +@Composable +private fun FeeValueContent(preDot: TextReference, postDot: TextReference?, ellipsizeOffset: Int? = null) { + val ellipsis = if (ellipsizeOffset == null) { + TextEllipsis.End + } else { + TextEllipsis.OffsetEnd(ellipsizeOffset) + } + val textColor = TangemTheme.colors.text.tertiary + val textStyle = TangemTheme.typography.caption1 + Row { + EllipsisText( + text = preDot.resolveReference(), + style = textStyle, + color = textColor, + textAlign = TextAlign.End, + ellipsis = ellipsis, + modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.TOKEN_AMOUNT), + ) + if (postDot != null) { + Text( + text = StringsSigns.DOT, + style = textStyle, + color = textColor, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing4) + .testTag(SendSelectNetworkFeeBottomSheetTestTags.DOT_SIGN), + ) + Text( + text = postDot.resolveReference(), + style = textStyle, + color = textColor, + modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.FIAT_AMOUNT), + ) + } + } +} + +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO) +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview( + @PreviewParameter(FeeSpeedSelectorUMContentProvider::class) + state: FeeSelectorUM.Content, +) { + TangemThemePreview { + Box(Modifier.background(TangemTheme.colors.background.secondary)) { + FeeSpeedSelectorContent( + state = state, + intents = StubFeeSpeedSelectorIntents(), + ) + } + } +} + +private class FeeSpeedSelectorUMContentProvider : CollectionPreviewParameterProvider( + collection = listOf( + FeeSelectorUM.Content( + isPrimaryButtonEnabled = false, + feeItems = persistentListOf( + FeeItem.Suggested( + title = resourceReference( + id = R.string.wc_fee_suggested, + formatArgs = wrappedList("Tangem"), + ), + fee = Fee.Common(Amount(value = BigDecimal("0.1"), blockchain = Blockchain.Ethereum)), + ), + FeeItem.Slow(fee = Fee.Common(Amount(value = BigDecimal("0.01"), blockchain = Blockchain.Ethereum))), + FeeItem.Market( + fee = Fee.Common( + Amount( + value = BigDecimal("0.02"), + blockchain = Blockchain.Ethereum, + ), + ), + ), + FeeItem.Fast(fee = Fee.Common(Amount(value = BigDecimal("0.03"), blockchain = Blockchain.Ethereum))), + customFeeItem, + ), + selectedFeeItem = FeeItem.Slow( + fee = Fee.Common( + Amount( + value = BigDecimal("0.01"), + blockchain = Blockchain.Ethereum, + ), + ), + ), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = true, + isFeeConvertibleToFiat = true, + isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, + ), + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal.TEN, + appCurrency = AppCurrency.Default, + ), + feeNonce = FeeNonce.None, + fees = TransactionFee.Single(customFeeItem.fee), + ), + ), +) + +private val cryptoCurrencyStatus + get() = CryptoCurrencyStatus( + currency = CryptoCurrency.Coin( + id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), + network = Network( + id = Network.ID( + value = "bitcoin", + derivationPath = Network.DerivationPath.None, + ), + backendId = "bitcoin", + name = "Bitcoin", + currencySymbol = "BTC", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("bitcoin"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ), + name = "Bitcoin", + symbol = "BTC", + decimals = 8, + iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png", + isCustom = false, + ), + value = CryptoCurrencyStatus.Loading, + ) + +private val customFeeItem + get() = FeeItem.Custom( + fee = Fee.Common(Amount(value = BigDecimal("0.05"), blockchain = Blockchain.Ethereum)), + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.119806", + onValueChange = {}, + keyboardOptions = KeyboardOptions(), + keyboardActions = KeyboardActions(), + symbol = "ETH", + decimals = 8, + title = resourceReference(R.string.send_max_fee), + footer = resourceReference(R.string.send_custom_amount_fee_footer), + label = stringReference("~ 0,03 \$"), + isReadonly = false, + ), + CustomFeeFieldUM( + value = "40000", + onValueChange = {}, + keyboardOptions = KeyboardOptions(), + keyboardActions = KeyboardActions(), + symbol = "GWEI", + decimals = 8, + title = resourceReference(R.string.send_gas_price), + footer = resourceReference(R.string.send_gas_price_footer), + label = null, + isReadonly = false, + ), + CustomFeeFieldUM( + value = "31400", + onValueChange = {}, + keyboardOptions = KeyboardOptions(), + keyboardActions = KeyboardActions(), + symbol = null, + decimals = 8, + title = resourceReference(R.string.send_gas_limit), + footer = resourceReference(R.string.send_gas_limit_footer), + label = null, + isReadonly = false, + ), + ), + ) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt new file mode 100644 index 0000000000..0fc8cce54a --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt @@ -0,0 +1,42 @@ +package com.tangem.features.send.v2.feeselector.component.token + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.token.model.FeeTokenSelectorModel +import com.tangem.features.send.v2.feeselector.component.token.ui.FeeTokenSelectorContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class FeeTokenSelectorComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: FeeSelectorComponentParams, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: FeeTokenSelectorModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + FeeTokenSelectorContent( + state = state, + intents = model, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory { + fun create( + appComponentContext: AppComponentContext, + params: FeeSelectorComponentParams, + ): FeeTokenSelectorComponent + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorIntents.kt new file mode 100644 index 0000000000..7e9e5b99c6 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorIntents.kt @@ -0,0 +1,15 @@ +package com.tangem.features.send.v2.feeselector.component.token + +import androidx.compose.runtime.Stable +import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents +import com.tangem.features.send.v2.feeselector.model.StubFeeSelectorIntents + +@Stable +internal interface FeeTokenSelectorIntents : FeeSelectorIntents { + fun onLearnMoreClick() +} + +@Stable +internal class StubFeeTokenSelectorIntents : FeeTokenSelectorIntents, FeeSelectorIntents by StubFeeSelectorIntents() { + override fun onLearnMoreClick() {} +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenItemState.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenItemState.kt new file mode 100644 index 0000000000..ffc1e2e2d0 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenItemState.kt @@ -0,0 +1,8 @@ +package com.tangem.features.send.v2.feeselector.component.token.entity + +import com.tangem.core.ui.components.token.state.TokenItemState + +internal data class FeeTokenItemState( + val state: TokenItemState, + val isAvailable: Boolean = true, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenSelectorUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenSelectorUM.kt new file mode 100644 index 0000000000..e1fb00d6af --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/entity/FeeTokenSelectorUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.send.v2.feeselector.component.token.entity + +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import kotlinx.collections.immutable.ImmutableList + +internal data class FeeTokenSelectorUM( + val parent: FeeSelectorUM.Content, + val selectedToken: FeeTokenItemState, + val tokens: ImmutableList, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenForListConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenForListConverter.kt new file mode 100644 index 0000000000..bcf449a9d1 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenForListConverter.kt @@ -0,0 +1,66 @@ +package com.tangem.features.send.v2.feeselector.component.token.model + +import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenItemState +import com.tangem.features.send.v2.impl.R +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isZero + +internal class FeeTokenForListConverter( + private val appCurrencyProvider: Provider, + private val onTokenClick: () -> Unit, +) : Converter { + + override fun convert(value: CryptoCurrencyStatus): FeeTokenItemState { + if (value.value !is CryptoCurrencyStatus.Loaded) { + return FeeTokenItemState(TokenItemState.Loading(id = value.currency.id.value)) + } + + val appCurrency = appCurrencyProvider() + val isBalanceZero = value.value.amount?.isZero() == true + val tokenItemConverter = TokenItemStateConverter( + appCurrency = appCurrencyProvider(), + onItemClick = if (isBalanceZero.not()) { + { _, _ -> onTokenClick() } + } else { + null + }, + ) + + val state = tokenItemConverter.convert(value) + + val updatedState = when (state) { + is TokenItemState.Content -> { + val balance = value.value.fiatAmount.format { + fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) + } + + state.copy( + fiatAmountState = TokenItemState.FiatAmountState.Empty, + subtitleState = TokenItemState.SubtitleState.TextContent( + value = resourceReference( + R.string.common_balance, + wrappedList(balance), + ), + isAvailable = false, + ), + subtitle2State = null, + ) + } + else -> state + } + + return FeeTokenItemState( + state = updatedState, + isAvailable = isBalanceZero.not(), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt new file mode 100644 index 0000000000..eb7e57e5b0 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt @@ -0,0 +1,95 @@ +package com.tangem.features.send.v2.feeselector.component.token.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.common.TangemBlogUrlBuilder +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams +import com.tangem.features.send.v2.feeselector.component.token.FeeTokenSelectorIntents +import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenItemState +import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenSelectorUM +import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class FeeTokenSelectorModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val urlOpener: UrlOpener, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, +) : Model(), FeeTokenSelectorIntents, + FeeSelectorIntents by paramsContainer.require().intents { + + private val params = paramsContainer.require() + private var appCurrency = AppCurrency.Default + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + init { + initAppCurrency() + + params.state + .filterIsInstance() + .onEach { state -> uiState.value = stateFromParent(state) } + .launchIn(modelScope) + } + + private fun initAppCurrency() { + modelScope.launch { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + } + } + + private fun getInitialState(): FeeTokenSelectorUM { + val parentState = params.state.value as FeeSelectorUM.Content + return stateFromParent(parentState) + } + + private fun stateFromParent(newParentState: FeeSelectorUM.Content): FeeTokenSelectorUM { + val parentState = newParentState + val selectedCurrencyStatus = parentState.feeExtraInfo.feeCryptoCurrencyStatus + val selectedTokenUM = convertToken(selectedCurrencyStatus) + + return FeeTokenSelectorUM( + parent = parentState, + selectedToken = selectedTokenUM, + tokens = parentState.feeExtraInfo.availableFeeCurrencies?.map { status -> + convertToken(status) + }?.toImmutableList() ?: persistentListOf(selectedTokenUM), + ) + } + + private fun convertToken(status: CryptoCurrencyStatus): FeeTokenItemState { + return FeeTokenForListConverter( + appCurrencyProvider = Provider { appCurrency }, + onTokenClick = { onTokenSelected(status) }, + ).convert(status) + } + + override fun onLearnMoreClick() { + modelScope.launch { + urlOpener.openUrl( + TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee), + ) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt new file mode 100644 index 0000000000..76adedfcaf --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt @@ -0,0 +1,270 @@ +package com.tangem.features.send.v2.feeselector.component.token.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.* +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.selectedBorder +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.features.send.v2.api.entity.* +import com.tangem.features.send.v2.feeselector.component.token.FeeTokenSelectorIntents +import com.tangem.features.send.v2.feeselector.component.token.StubFeeTokenSelectorIntents +import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenItemState +import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenSelectorUM +import com.tangem.features.send.v2.impl.R +import com.tangem.utils.StringsSigns +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +@Composable +internal fun FeeTokenSelectorContent( + state: FeeTokenSelectorUM, + intents: FeeTokenSelectorIntents, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Title(onLearnMoreClick = intents::onLearnMoreClick) + SpacerH8() + TokenListContent(state) + } +} + +@Composable +private fun Title(modifier: Modifier = Modifier, onLearnMoreClick: () -> Unit) { + val linkText = stringResourceSafe(R.string.common_learn_more) + val fullString = stringResourceSafe(R.string.fee_selector_choose_token_description, linkText) + val linkTextPosition = fullString.length - linkText.length + val defaultColor = TangemTheme.colors.text.secondary + val linkColor = TangemTheme.colors.text.accent + val annotatedString = remember(defaultColor, linkColor, onLearnMoreClick) { + buildAnnotatedString { + withStyle(SpanStyle(defaultColor)) { + append(fullString.substring(0, linkTextPosition)) + } + withLink( + link = LinkAnnotation.Clickable( + tag = "learn_more", + linkInteractionListener = { onLearnMoreClick() }, + ), + block = { + withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.None)) { + append( + fullString.substring(linkTextPosition, fullString.length).replace( + ' ', + StringsSigns.NON_BREAKING_SPACE, + ), + ) + } + }, + ) + } + } + + Text( + modifier = modifier.padding(horizontal = 32.dp), + text = annotatedString, + style = TangemTheme.typography.caption2, + textAlign = TextAlign.Center, + ) +} + +@Suppress("MagicNumber") +@Composable +internal fun TokenListContent(state: FeeTokenSelectorUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val (availableTokens, notAvailableTokens) = remember(state.tokens) { + state.tokens.partition { it.isAvailable }.let { pair -> + Pair( + first = pair.first.map { it.state }.toImmutableList(), + second = pair.second.map { it.state }.toImmutableList(), + ) + } + } + + availableTokens.fastForEach { item -> + key(item.id) { + TokenItem( + modifier = Modifier + .selectedBorder(isSelected = state.selectedToken.state.id == item.id) + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.action), + state = item, + isBalanceHidden = false, + ) + } + } + + if (notAvailableTokens.isNotEmpty()) { + Text( + modifier = Modifier.padding(start = 12.dp, top = 8.dp), + text = stringResourceSafe(R.string.common_not_available), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + + notAvailableTokens.fastForEach { item -> + key(item.id) { + TokenItem( + modifier = Modifier + .graphicsLayer { alpha = 0.5f } + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.action), + state = item, + isBalanceHidden = false, + ) + } + } + } + } +} + +@Suppress("LongMethod") +@Preview +@Composable +private fun Preview() { + val cryptoCurrencyStatus = CryptoCurrencyStatus( + currency = CryptoCurrency.Coin( + id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), + network = Network( + id = Network.ID( + value = "bitcoin", + derivationPath = Network.DerivationPath.None, + ), + backendId = "bitcoin", + name = "Bitcoin", + currencySymbol = "BTC", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("bitcoin"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ), + name = "Bitcoin", + symbol = "BTC", + decimals = 8, + iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png", + isCustom = false, + ), + value = CryptoCurrencyStatus.Loading, + ) + + val parentUM = FeeSelectorUM.Content( + isPrimaryButtonEnabled = false, + feeItems = persistentListOf( + FeeItem.Market( + fee = Fee.Common( + Amount( + value = BigDecimal("0.02"), + blockchain = Blockchain.Ethereum, + ), + ), + ), + ), + selectedFeeItem = FeeItem.Slow( + fee = Fee.Common( + Amount( + value = BigDecimal("0.01"), + blockchain = Blockchain.Ethereum, + ), + ), + ), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = true, + isFeeConvertibleToFiat = true, + isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, + ), + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal.TEN, + appCurrency = AppCurrency.Default, + ), + feeNonce = FeeNonce.None, + fees = TransactionFee.Single( + Fee.Common( + Amount( + value = BigDecimal("0.01"), + blockchain = Blockchain.Ethereum, + ), + ), + ), + ) + + val tokenItemState = TokenItemState.Content( + id = "1", + iconState = CurrencyIconState.Locked, + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Bitcoin")), + fiatAmountState = TokenItemState.FiatAmountState.Empty, + subtitle2State = null, + subtitleState = TokenItemState.SubtitleState.TextContent( + value = stringReference("Balance: 0,35853044 BTC"), + isAvailable = false, + ), + onItemClick = {}, + onItemLongClick = {}, + ) + + val feeTokenItemState = FeeTokenItemState(tokenItemState) + + val state = FeeTokenSelectorUM( + parent = parentUM, + selectedToken = feeTokenItemState, + tokens = persistentListOf( + feeTokenItemState, + feeTokenItemState.copy(state = tokenItemState.copy(id = "2")), + feeTokenItemState.copy(state = tokenItemState.copy(id = "3"), isAvailable = false), + feeTokenItemState.copy(state = tokenItemState.copy(id = "4"), isAvailable = false), + ), + ) + + TangemThemePreview { + FeeTokenSelectorContent( + state = state, + intents = StubFeeTokenSelectorIntents(), + modifier = Modifier + .background(TangemTheme.colors.background.tertiary), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt index 9afa349960..1891f991d3 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/di/FeeSelectorModelModule.kt @@ -2,6 +2,10 @@ package com.tangem.features.send.v2.feeselector.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model +import com.tangem.features.send.v2.feeselector.component.extended.model.FeeExtendedSelectorModel +import com.tangem.features.send.v2.feeselector.model.FeeSelectorBlockModel +import com.tangem.features.send.v2.feeselector.component.speed.model.FeeSpeedSelectorModel +import com.tangem.features.send.v2.feeselector.component.token.model.FeeTokenSelectorModel import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel import dagger.Binds import dagger.Module @@ -16,4 +20,24 @@ internal interface FeeSelectorModelModule { @IntoMap @ClassKey(FeeSelectorModel::class) fun bindModel(model: FeeSelectorModel): Model + + @Binds + @IntoMap + @ClassKey(FeeExtendedSelectorModel::class) + fun bindExtendedModel(model: FeeExtendedSelectorModel): Model + + @Binds + @IntoMap + @ClassKey(FeeSelectorBlockModel::class) + fun bindBlockModel(model: FeeSelectorBlockModel): Model + + @Binds + @IntoMap + @ClassKey(FeeSpeedSelectorModel::class) + fun bindFeeSpeedSelectorModel(model: FeeSpeedSelectorModel): Model + + @Binds + @IntoMap + @ClassKey(FeeTokenSelectorModel::class) + fun bindFeeTokenSelectorModel(model: FeeTokenSelectorModel): Model } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt index 6abebb0fb4..96a8476eef 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorAlertFactory.kt @@ -92,6 +92,7 @@ internal class FeeSelectorAlertFactory @Inject constructor( is FeeItem.Slow -> newTransactionFee.minimum is FeeItem.Market -> newTransactionFee.normal is FeeItem.Fast -> newTransactionFee.priority + FeeItem.Loading -> newTransactionFee.normal is FeeItem.Custom -> return } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorBlockModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorBlockModel.kt new file mode 100644 index 0000000000..36fdc82031 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorBlockModel.kt @@ -0,0 +1,78 @@ +package com.tangem.features.send.v2.feeselector.model + +import androidx.compose.runtime.Stable +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.TangemBlogUrlBuilder +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.settings.NeverShowTapHelpUseCase +import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.SendScreenSource +import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.api.params.FeeSelectorParams +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class FeeSelectorBlockModel @Inject constructor( + paramsContainer: ParamsContainer, + feeSelectorLogicFactory: FeeSelectorLogic.Factory, + override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, + private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase, + private val urlOpener: UrlOpener, +) : Model(), FeeSelectorModelCallback { + + private val params = paramsContainer.require() + private val feeSelectorLogic = feeSelectorLogicFactory.create( + params = params, + modelScope = modelScope, + ) + + val feeSelectorBottomSheet = SlotNavigation() + val uiState: StateFlow + field = feeSelectorLogic.uiState + + override fun onFeeResult(feeSelectorUM: FeeSelectorUM) { + uiState.value = feeSelectorUM + feeSelectorBottomSheet.dismiss() + } + + fun updateState(feeSelectorUM: FeeSelectorUM) { + uiState.value = feeSelectorUM + } + + fun onReadMoreClicked() { + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee)) + } + } + + fun showFeeSelector() { + analyticsEventHandler.send( + CommonSendAnalyticEvents.FeeScreenOpened( + categoryName = params.analyticsCategoryName, + source = params.analyticsSendSource, + ), + ) + analyticsEventHandler.send( + CommonSendAnalyticEvents.ScreenReopened( + categoryName = params.analyticsCategoryName, + source = SendScreenSource.Fee, + ), + ) + modelScope.launch { + neverShowTapHelpUseCase() + feeSelectorBottomSheet.activate(Unit) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorIntents.kt index d35aa6784b..ebb0c7a91b 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorIntents.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorIntents.kt @@ -1,9 +1,13 @@ package com.tangem.features.send.v2.feeselector.model +import androidx.compose.runtime.Stable +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.send.v2.api.entity.FeeItem +@Stable internal interface FeeSelectorIntents { fun onFeeItemSelected(feeItem: FeeItem) + fun onTokenSelected(status: CryptoCurrencyStatus) fun onCustomFeeValueChange(index: Int, value: String) fun onNonceChange(value: String) fun onDoneClick() @@ -11,6 +15,7 @@ internal interface FeeSelectorIntents { internal class StubFeeSelectorIntents : FeeSelectorIntents { override fun onFeeItemSelected(feeItem: FeeItem) {} + override fun onTokenSelected(status: CryptoCurrencyStatus) {} override fun onCustomFeeValueChange(index: Int, value: String) {} override fun onNonceChange(value: String) {} override fun onDoneClick() {} diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt new file mode 100644 index 0000000000..a6efb1b2dd --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorLogic.kt @@ -0,0 +1,322 @@ +package com.tangem.features.send.v2.feeselector.model + +import arrow.core.Either +import arrow.core.flatMap +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.raise.either +import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase +import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCase +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.send.v2.api.SendFeatureToggles +import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.NonceInserted +import com.tangem.features.send.v2.api.entity.FeeItem +import com.tangem.features.send.v2.api.entity.FeeNonce +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.api.params.FeeSelectorParams +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadListener +import com.tangem.features.send.v2.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents +import com.tangem.features.send.v2.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents.GasPriceInserter +import com.tangem.features.send.v2.feeselector.model.transformers.* +import com.tangem.utils.transformer.update +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +internal class FeeSelectorLogic @AssistedInject constructor( + @Assisted private val params: FeeSelectorParams, + @Assisted private val modelScope: CoroutineScope, + private val isFeeApproximateUseCase: IsFeeApproximateUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val feeSelectorReloadListener: FeeSelectorReloadListener, + private val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener, + private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger, + private val feeSelectorAlertFactory: FeeSelectorAlertFactory, + private val analyticsEventHandler: AnalyticsEventHandler, + private val sendFeatureToggles: SendFeatureToggles, + private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val getAvailableFeeTokensUseCase: GetAvailableFeeTokensUseCase, + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, +) : FeeSelectorIntents { + + private var appCurrency: AppCurrency = AppCurrency.Default + val uiState = MutableStateFlow(params.state) + + val isGaslessEnabled = sendFeatureToggles.isGaslessTransactionsEnabled && + params.onLoadFeeExtended != null && + isGaslessFeeSupportedForNetwork(params.feeCryptoCurrencyStatus.currency.network) && + params.cryptoCurrencyStatus.currency is CryptoCurrency.Token + + init { + initAppCurrency() + subscribeOnFeeReloadTriggerUpdates() + subscribeOnFeeCheckReloadTriggerUpdates() + subscribeOnFeeLoadingStateTriggerUpdates() + loadFee() + } + + private fun initAppCurrency() { + modelScope.launch { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + } + } + + private fun loadFee() { + if (uiState.value is FeeSelectorUM.Error) { + return + } + + if (uiState.value !is FeeSelectorUM.Content) { + uiState.update(FeeSelectorLoadingTransformer) + } + + modelScope.launch { + callLoadFee() + .fold( + ifLeft = { error -> uiState.update(FeeSelectorErrorTransformer(error)) }, + ifRight = { fee -> + uiState.update( + FeeSelectorLoadedTransformer( + cryptoCurrencyStatus = params.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = fee.selectedTokenOrNull ?: params.feeCryptoCurrencyStatus, + appCurrency = appCurrency, + fees = fee, + feeStateConfiguration = params.feeStateConfiguration, + isFeeApproximate = isFeeApproximate(fee.transactionFee.normal.amount.type), + feeSelectorIntents = this@FeeSelectorLogic, + ), + ) + }, + ) + } + } + + private fun isFeeApproximate(amountType: AmountType): Boolean { + val networkId = params.feeCryptoCurrencyStatus.currency.network.id + return isFeeApproximateUseCase(networkId = networkId, amountType = amountType) + } + + override fun onFeeItemSelected(feeItem: FeeItem) { + if (feeItem is FeeItem.Custom) { + analyticsEventHandler.send( + CommonSendFeeAnalyticEvents.CustomFeeButtonClicked(categoryName = params.analyticsCategoryName), + ) + } + uiState.update(FeeItemSelectedTransformer(feeItem)) + } + + override fun onTokenSelected(status: CryptoCurrencyStatus) { + uiState.update(FeeSelectorTokenSelectedTransformer(status)) + loadFee() + } + + override fun onCustomFeeValueChange(index: Int, value: String) { + uiState.update( + FeeSelectorCustomValueChangedTransformer( + index = index, + value = value, + intents = this, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, + ), + ) + } + + override fun onNonceChange(value: String) { + uiState.update(FeeSelectorNonceChangeTransformer(value = value)) + } + + override fun onDoneClick() { + val feeSelectorUM = uiState.value as? FeeSelectorUM.Content ?: return + analyticsEventHandler.send( + CommonSendFeeAnalyticEvents.SelectedFee( + categoryName = params.analyticsCategoryName, + feeType = feeSelectorUM.toAnalyticType(), + source = params.analyticsSendSource, + ), + ) + val isCustomFeeEdited = feeSelectorUM.selectedFeeItem.fee.amount.value != feeSelectorUM.fees.normal.amount.value + if (feeSelectorUM.selectedFeeItem is FeeItem.Custom && isCustomFeeEdited) { + analyticsEventHandler.send(GasPriceInserter(categoryName = params.analyticsCategoryName)) + } + if (feeSelectorUM.feeNonce is FeeNonce.Nonce) { + analyticsEventHandler.send( + NonceInserted( + categoryName = params.analyticsCategoryName, + token = params.feeCryptoCurrencyStatus.currency.symbol, + blockchain = params.feeCryptoCurrencyStatus.currency.network.name, + ), + ) + } + } + + private fun subscribeOnFeeReloadTriggerUpdates() { + feeSelectorReloadListener.reloadTriggerFlow + .onEach { data -> + if (data.isRemoveSuggestedFee) { + uiState.update(FeeSelectorRemoveSuggestedTransformer) + } + loadFee() + } + .launchIn(modelScope) + } + + private fun subscribeOnFeeLoadingStateTriggerUpdates() { + feeSelectorReloadListener.loadingStateTriggerFlow + .onEach { uiState.update(FeeSelectorLoadingTransformer) } + .launchIn(modelScope) + } + + private fun subscribeOnFeeCheckReloadTriggerUpdates() { + feeSelectorCheckReloadListener.checkReloadTriggerFlow + .onEach { checkLoadFee() } + .launchIn(modelScope) + } + + private fun checkLoadFee() { + modelScope.launch { + callLoadFee().fold( + ifRight = { newFee -> + feeSelectorAlertFactory.getFeeUpdatedAlert( + newTransactionFee = newFee.transactionFee, + feeSelectorUM = uiState.value, + proceedAction = { + modelScope.launch { + feeSelectorCheckReloadTrigger.callbackCheckResult(true) + } + }, + stopAction = { + modelScope.launch { + feeSelectorCheckReloadTrigger.callbackCheckResult(false) + } + }, + ) + }, + ifLeft = { feeError -> + feeSelectorCheckReloadTrigger.callbackCheckResult(false) + feeSelectorAlertFactory.getFeeUnreachableErrorState(::loadFee) + }, + ) + } + } + + @Suppress("UnreachableCode") + private suspend fun callLoadFee(): Either { + val extended = params.onLoadFeeExtended + if (extended == null || !isGaslessEnabled) { + return params.onLoadFee().map { LoadedFeeResult.Basic(it) } + } + + val selectedTokenOrNull = (uiState.value as? FeeSelectorUM.Content)?.feeExtraInfo?.feeCryptoCurrencyStatus + + if (selectedTokenOrNull?.currency is CryptoCurrency.Coin) { + return params.onLoadFee().flatMap { loadedFee -> + val feeExtended = TransactionFeeExtended( + transactionFee = loadedFee, + feeTokenId = selectedTokenOrNull.currency.id, + ) + populateExtendedFee(feeExtended) + } + } + + return extended(selectedTokenOrNull).fold( + ifLeft = { error -> + when (error) { + is GetFeeError.GaslessError.NotEnoughFunds -> error.left() + is GetFeeError.GaslessError -> { + // Something wrong with gasless fee, fallback to basic fee + params.onLoadFee().map { LoadedFeeResult.Basic(it) } + } + else -> error.left() + } + }, + ifRight = { fee -> + populateExtendedFee(fee) + }, + ) + } + + private suspend fun populateExtendedFee( + fee: TransactionFeeExtended, + ): Either = either { + LoadedFeeResult.Extended( + fee = fee, + selectedToken = getSelectedTokenStatus(fee.feeTokenId).bind(), + availableTokens = getAvailableFeeTokens().bind(), + ) + } + + private suspend fun getSelectedTokenStatus(tokenId: CryptoCurrency.ID): Either = + either { + val feeTokenId = tokenId + if (params.feeCryptoCurrencyStatus.currency.id != feeTokenId) { + getSingleCryptoCurrencyStatusUseCase + .invokeMultiWalletSync( + userWalletId = params.userWalletId, + cryptoCurrencyId = feeTokenId, + ).getOrElse { + raise(GetFeeError.DataError(IllegalStateException("No token found for id: $tokenId"))) + } + } else { + params.feeCryptoCurrencyStatus + } + } + + private suspend fun getAvailableFeeTokens(): Either> = either { + val userWallet = getUserWalletUseCase(params.userWalletId).mapLeft { + GetFeeError.DataError(IllegalStateException("No wallet found for id: ${params.userWalletId}")) + }.bind() + + getAvailableFeeTokensUseCase.invoke( + userWallet = userWallet, + network = params.cryptoCurrencyStatus.currency.network, + ).bind() + } + + sealed class LoadedFeeResult { + data class Extended( + val fee: TransactionFeeExtended, + val selectedToken: CryptoCurrencyStatus?, + val availableTokens: List, + ) : LoadedFeeResult() + + data class Basic(val fee: TransactionFee) : LoadedFeeResult() + + val selectedTokenOrNull: CryptoCurrencyStatus? + get() = when (this) { + is Extended -> selectedToken + is Basic -> null + } + + val transactionFee: TransactionFee + get() = when (this) { + is Extended -> fee.transactionFee + is Basic -> fee + } + } + + @AssistedFactory + interface Factory { + fun create(params: FeeSelectorParams, modelScope: CoroutineScope): FeeSelectorLogic + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt index c290cc9fb3..39db4c6f51 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt @@ -1,246 +1,93 @@ package com.tangem.features.send.v2.feeselector.model import androidx.compose.runtime.Stable -import arrow.core.getOrElse -import com.arkivanov.decompose.router.slot.SlotNavigation -import com.arkivanov.decompose.router.slot.activate -import com.arkivanov.decompose.router.slot.dismiss -import com.tangem.blockchain.common.AmountType -import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.pop import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.navigation.url.UrlOpener -import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.settings.NeverShowTapHelpUseCase -import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase -import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.NonceInserted -import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.SendScreenSource -import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback +import com.tangem.core.decompose.utils.stack +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.send.v2.api.entity.FeeItem -import com.tangem.features.send.v2.api.entity.FeeNonce import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.params.FeeSelectorParams -import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener -import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger -import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadListener -import com.tangem.features.send.v2.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents -import com.tangem.features.send.v2.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents.GasPriceInserter -import com.tangem.features.send.v2.feeselector.model.transformers.* -import com.tangem.utils.TangemBlogUrlBuilder +import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.transformer.update -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import com.tangem.utils.extensions.isSingleItem import kotlinx.coroutines.launch import javax.inject.Inject -@Suppress("LongParameterList") @Stable @ModelScoped internal class FeeSelectorModel @Inject constructor( paramsContainer: ParamsContainer, - private val urlOpener: UrlOpener, - private val isFeeApproximateUseCase: IsFeeApproximateUseCase, - private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, - private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase, - private val feeSelectorReloadListener: FeeSelectorReloadListener, - private val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener, - private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger, - private val feeSelectorAlertFactory: FeeSelectorAlertFactory, + feeSelectorLogicFactory: FeeSelectorLogic.Factory, override val dispatchers: CoroutineDispatcherProvider, - private val analyticsEventHandler: AnalyticsEventHandler, -) : Model(), FeeSelectorIntents, FeeSelectorModelCallback { +) : Model(), FeeSelectorIntents { - private val params = paramsContainer.require() - private var appCurrency: AppCurrency = AppCurrency.Default + private val params = paramsContainer.require() + private val feeSelectorLogic = feeSelectorLogicFactory.create( + params = params, + modelScope = modelScope, + ) - val feeSelectorBottomSheet = SlotNavigation() - - val uiState: StateFlow - field = MutableStateFlow(params.state) - - init { - initAppCurrency() - subscribeOnFeeReloadTriggerUpdates() - subscribeOnFeeCheckReloadTriggerUpdates() - subscribeOnFeeLoadingStateTriggerUpdates() - loadFee() - } - - fun updateState(feeSelectorUM: FeeSelectorUM) { - uiState.value = feeSelectorUM - } - - fun onReadMoreClicked() { - urlOpener.openUrl(TangemBlogUrlBuilder.FEE_BLOG_LINK) - } - - private fun initAppCurrency() { - modelScope.launch { - appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } - } - } - - private fun loadFee() { - if (uiState.value !is FeeSelectorUM.Content) { - uiState.update(FeeSelectorLoadingTransformer) - } - modelScope.launch { - params.onLoadFee() - .fold( - ifLeft = { error -> uiState.update(FeeSelectorErrorTransformer(error)) }, - ifRight = { fee -> - uiState.update( - FeeSelectorLoadedTransformer( - cryptoCurrencyStatus = params.cryptoCurrencyStatus, - feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, - appCurrency = appCurrency, - fees = fee, - feeStateConfiguration = params.feeStateConfiguration, - isFeeApproximate = isFeeApproximate(fee.normal.amount.type), - feeSelectorIntents = this@FeeSelectorModel, - ), - ) - }, - ) - } - } - - private fun isFeeApproximate(amountType: AmountType): Boolean { - val networkId = params.feeCryptoCurrencyStatus.currency.network.id - return isFeeApproximateUseCase(networkId = networkId, amountType = amountType) - } + val stackNavigation = StackNavigation() + val uiState = feeSelectorLogic.uiState override fun onFeeItemSelected(feeItem: FeeItem) { - if (feeItem is FeeItem.Custom) { - analyticsEventHandler.send( - CommonSendFeeAnalyticEvents.CustomFeeButtonClicked(categoryName = params.analyticsCategoryName), - ) + feeSelectorLogic.onFeeItemSelected(feeItem) + + if (contentState().selectedFeeItem is FeeItem.Custom) { + return } - uiState.update(FeeItemSelectedTransformer(feeItem)) - if (feeItem !is FeeItem.Custom) { - onDoneClick() + + modelScope.launch { + val stack = stackNavigation.stack() + if (stack.isSingleItem() && stack[0] is FeeSelectorRoute.ChooseSpeed) { + // In case of only speed selection, we finish the flow immediately after selection + feeSelectorLogic.onDoneClick() + params.callback.onFeeResult(uiState.value) + } else { + stackNavigation.pop() + } } } + override fun onTokenSelected(status: CryptoCurrencyStatus) { + feeSelectorLogic.onTokenSelected(status) + stackNavigation.pop() + } + override fun onCustomFeeValueChange(index: Int, value: String) { - uiState.update( - FeeSelectorCustomValueChangedTransformer( - index = index, - value = value, - intents = this, - appCurrency = appCurrency, - feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, - ), - ) + feeSelectorLogic.onCustomFeeValueChange(index, value) } override fun onNonceChange(value: String) { - uiState.update(FeeSelectorNonceChangeTransformer(value = value)) + feeSelectorLogic.onNonceChange(value) } override fun onDoneClick() { - val feeSelectorUM = uiState.value as? FeeSelectorUM.Content ?: return - analyticsEventHandler.send( - CommonSendFeeAnalyticEvents.SelectedFee( - categoryName = params.analyticsCategoryName, - feeType = feeSelectorUM.toAnalyticType(), - source = params.analyticsSendSource, - ), - ) - val isCustomFeeEdited = feeSelectorUM.selectedFeeItem.fee.amount.value != feeSelectorUM.fees.normal.amount.value - if (feeSelectorUM.selectedFeeItem is FeeItem.Custom && isCustomFeeEdited) { - analyticsEventHandler.send(GasPriceInserter(categoryName = params.analyticsCategoryName)) - } - if (feeSelectorUM.feeNonce is FeeNonce.Nonce) { - analyticsEventHandler.send( - NonceInserted( - categoryName = params.analyticsCategoryName, - token = params.feeCryptoCurrencyStatus.currency.symbol, - blockchain = params.feeCryptoCurrencyStatus.currency.network.name, - ), - ) - } - - (params as? FeeSelectorParams.FeeSelectorDetailsParams)?.callback?.onFeeResult(uiState.value) - } - - override fun onFeeResult(feeSelectorUM: FeeSelectorUM) { - uiState.value = feeSelectorUM - feeSelectorBottomSheet.dismiss() - } - - fun showFeeSelector() { - analyticsEventHandler.send( - CommonSendAnalyticEvents.FeeScreenOpened( - categoryName = params.analyticsCategoryName, - source = params.analyticsSendSource, - ), - ) - analyticsEventHandler.send( - CommonSendAnalyticEvents.ScreenReopened( - categoryName = params.analyticsCategoryName, - source = SendScreenSource.Fee, - ), - ) modelScope.launch { - neverShowTapHelpUseCase() - feeSelectorBottomSheet.activate(Unit) - } - } - - private fun subscribeOnFeeReloadTriggerUpdates() { - feeSelectorReloadListener.reloadTriggerFlow - .onEach { data -> - if (data.isRemoveSuggestedFee) { - uiState.update(FeeSelectorRemoveSuggestedTransformer) - } - loadFee() + val stack = stackNavigation.stack() + if (stack.isSingleItem()) { + feeSelectorLogic.onDoneClick() + params.callback.onFeeResult(uiState.value) + } else { + stackNavigation.pop() } - .launchIn(modelScope) + } } - private fun subscribeOnFeeLoadingStateTriggerUpdates() { - feeSelectorReloadListener.loadingStateTriggerFlow - .onEach { uiState.update(FeeSelectorLoadingTransformer) } - .launchIn(modelScope) + private fun contentState(): FeeSelectorUM.Content { + val currentState = uiState.value + require(currentState is FeeSelectorUM.Content) { "Current state must be FeeSelectorUM.Content" } + return currentState } - private fun subscribeOnFeeCheckReloadTriggerUpdates() { - feeSelectorCheckReloadListener.checkReloadTriggerFlow - .onEach { checkLoadFee() } - .launchIn(modelScope) - } - - private fun checkLoadFee() { - modelScope.launch { - params.onLoadFee().fold( - ifRight = { newFee -> - feeSelectorAlertFactory.getFeeUpdatedAlert( - newTransactionFee = newFee, - feeSelectorUM = uiState.value, - proceedAction = { - modelScope.launch { - feeSelectorCheckReloadTrigger.callbackCheckResult(true) - } - }, - stopAction = { - modelScope.launch { - feeSelectorCheckReloadTrigger.callbackCheckResult(false) - } - }, - ) - }, - ifLeft = { feeError -> - feeSelectorCheckReloadTrigger.callbackCheckResult(false) - feeSelectorAlertFactory.getFeeUnreachableErrorState(::loadFee) - }, - ) + fun getInitialRoute(): FeeSelectorRoute { + return when { + feeSelectorLogic.isGaslessEnabled -> FeeSelectorRoute.NetworkFee + else -> FeeSelectorRoute.ChooseSpeed } } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorErrorTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorErrorTransformer.kt index 80386d5c77..7cac2f632f 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorErrorTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorErrorTransformer.kt @@ -7,6 +7,13 @@ import com.tangem.utils.transformer.Transformer internal class FeeSelectorErrorTransformer(private val error: GetFeeError) : Transformer { override fun transform(prevState: FeeSelectorUM): FeeSelectorUM { + if (prevState is FeeSelectorUM.Content && error is GetFeeError.GaslessError.NotEnoughFunds) { + return prevState.copy( + isPrimaryButtonEnabled = false, + feeExtraInfo = prevState.feeExtraInfo.copy(isNotEnoughFunds = true), + ) + } + return FeeSelectorUM.Error(error = error) } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt index 69a2e8ece2..21fe42a28e 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt @@ -1,23 +1,24 @@ package com.tangem.features.send.v2.feeselector.model.transformers import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.send.v2.api.entity.* import com.tangem.features.send.v2.api.params.FeeSelectorParams import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents +import com.tangem.features.send.v2.feeselector.model.FeeSelectorLogic.LoadedFeeResult import com.tangem.lib.crypto.BlockchainUtils.isTron import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList @Suppress("LongParameterList") internal class FeeSelectorLoadedTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val feeCryptoCurrencyStatus: CryptoCurrencyStatus, private val appCurrency: AppCurrency, - private val fees: TransactionFee, + private val fees: LoadedFeeResult, private val feeStateConfiguration: FeeSelectorParams.FeeStateConfiguration, private val isFeeApproximate: Boolean, private val feeSelectorIntents: FeeSelectorIntents, @@ -25,7 +26,7 @@ internal class FeeSelectorLoadedTransformer( private val feeItemsConverter = FeeItemConverter( feeStateConfiguration = feeStateConfiguration, - normalFee = fees.normal, + normalFee = fees.transactionFee.normal, feeSelectorIntents = feeSelectorIntents, appCurrency = appCurrency, cryptoCurrencyStatus = feeCryptoCurrencyStatus, @@ -37,10 +38,17 @@ internal class FeeSelectorLoadedTransformer( } else { null } - val feeItems: ImmutableList = feeItemsConverter.convert(FeeItemConverter.Input(fees, prevCustomFee)) + + val feeItems: ImmutableList = feeItemsConverter.convert( + FeeItemConverter.Input(fees.transactionFee, prevCustomFee), + ) val selectedFee = when (prevState) { - is FeeSelectorUM.Content -> feeItems.first { it.isSameClass(prevState.selectedFeeItem) } + is FeeSelectorUM.Content -> if (prevState.selectedFeeItem is FeeItem.Loading) { + feeItems.firstOrNull { it is FeeItem.Market } + } else { + feeItems.firstOrNull { it.isSameClass(prevState.selectedFeeItem) } + } ?: FeeItem.Loading is FeeSelectorUM.Error, FeeSelectorUM.Loading, -> feeItems.find { it is FeeItem.Suggested } ?: feeItems.first { it is FeeItem.Market } @@ -49,8 +57,8 @@ internal class FeeSelectorLoadedTransformer( val nonce = ((prevState as? FeeSelectorUM.Content)?.feeNonce as? FeeNonce.Nonce)?.nonce return FeeSelectorUM.Content( - isPrimaryButtonEnabled = true, - fees = fees, + isPrimaryButtonEnabled = selectedFee !is FeeItem.Loading, + fees = fees.transactionFee, feeItems = feeItems, selectedFeeItem = selectedFee, feeExtraInfo = FeeExtraInfo( @@ -58,6 +66,9 @@ internal class FeeSelectorLoadedTransformer( isFeeConvertibleToFiat = feeCryptoCurrencyStatus.currency.network.hasFiatFeeRate, isTronToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token && isTron(cryptoCurrencyStatus.currency.network.rawId), + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + availableFeeCurrencies = getAvailableFeeCurrencies(), + transactionFeeExtended = (fees as? LoadedFeeResult.Extended)?.fee, ), feeFiatRateUM = feeCryptoCurrencyStatus.value.fiatRate?.let { rate -> FeeFiatRateUM( @@ -65,7 +76,7 @@ internal class FeeSelectorLoadedTransformer( appCurrency = appCurrency, ) }, - feeNonce = if (fees.normal is Fee.Ethereum) { + feeNonce = if (fees.transactionFee.normal is Fee.Ethereum) { FeeNonce.Nonce( nonce = nonce, onNonceChange = feeSelectorIntents::onNonceChange, @@ -75,4 +86,9 @@ internal class FeeSelectorLoadedTransformer( }, ) } + + private fun getAvailableFeeCurrencies(): ImmutableList? { + if (fees !is LoadedFeeResult.Extended) return null + return fees.availableTokens.toImmutableList() + } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt new file mode 100644 index 0000000000..c054119f3d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt @@ -0,0 +1,28 @@ +package com.tangem.features.send.v2.feeselector.model.transformers + +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.send.v2.api.entity.FeeItem +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf + +class FeeSelectorTokenSelectedTransformer( + private val selectedToken: CryptoCurrencyStatus, +) : Transformer { + + override fun transform(prevState: FeeSelectorUM): FeeSelectorUM { + return if (prevState is FeeSelectorUM.Content) { + prevState.copy( + isPrimaryButtonEnabled = false, + selectedFeeItem = FeeItem.Loading, + feeItems = persistentListOf(FeeItem.Loading), + feeExtraInfo = prevState.feeExtraInfo.copy( + feeCryptoCurrencyStatus = selectedToken, + isNotEnoughFunds = false, + ), + ) + } else { + prevState + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/route/FeeSelectorRoute.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/route/FeeSelectorRoute.kt new file mode 100644 index 0000000000..270230164d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/route/FeeSelectorRoute.kt @@ -0,0 +1,30 @@ +package com.tangem.features.send.v2.feeselector.route + +import androidx.compose.runtime.Immutable +import com.tangem.core.decompose.navigation.Route +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.send.v2.impl.R +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +internal sealed interface FeeSelectorRoute : Route { + + val title: TextReference + + @Serializable + data object NetworkFee : FeeSelectorRoute { + override val title: TextReference = resourceReference(R.string.common_network_fee_title) + } + + @Serializable + data object ChooseSpeed : FeeSelectorRoute { + override val title: TextReference = resourceReference(R.string.fee_selector_choose_speed_title) + } + + @Serializable + data object ChooseToken : FeeSelectorRoute { + override val title: TextReference = resourceReference(R.string.fee_selector_choose_token_title) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt index 5eb28eda97..c635854c69 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt @@ -28,7 +28,11 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.ui.amountScreen.utils.getFiatString import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.components.audits.AuditLabel +import com.tangem.core.ui.components.audits.AuditLabelUM import com.tangem.core.ui.components.tooltip.TangemTooltip +import com.tangem.core.ui.extensions.annotatedReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.EMPTY_BALANCE_SIGN import com.tangem.core.ui.format.bigdecimal.crypto @@ -38,6 +42,9 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.FeeSelectorBlockTestTags import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network import com.tangem.domain.transaction.error.GetFeeError import com.tangem.features.send.v2.api.entity.* import com.tangem.features.send.v2.impl.R @@ -50,9 +57,14 @@ private const val READ_MORE_TAG = "READ_MORE" @Composable internal fun FeeSelectorBlockContent( state: FeeSelectorUM, + isGaslessFeatureEnabled: Boolean, onReadMoreClick: () -> Unit, modifier: Modifier = Modifier, ) { + if (state is FeeSelectorUM.Error && state.isHidden) { + return + } + Row( modifier = modifier .background(TangemTheme.colors.background.action) @@ -68,16 +80,25 @@ internal fun FeeSelectorBlockContent( contentDescription = null, tint = TangemTheme.colors.icon.accent, ) - FeeSelectorDescription(state = state, onReadMoreClick = onReadMoreClick) + FeeSelectorDescription( + state = state, + isGaslessFeatureEnabled = isGaslessFeatureEnabled, + onReadMoreClick = onReadMoreClick, + ) } } @Composable -private fun FeeSelectorDescription(state: FeeSelectorUM, onReadMoreClick: () -> Unit, modifier: Modifier = Modifier) { +private fun FeeSelectorDescription( + state: FeeSelectorUM, + isGaslessFeatureEnabled: Boolean, + onReadMoreClick: () -> Unit, + modifier: Modifier = Modifier, +) { Row(modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween) { FeeSelectorStaticPart(modifier = Modifier.weight(1f), onReadMoreClick = onReadMoreClick) when (state) { - is FeeSelectorUM.Content -> FeeContent(state) + is FeeSelectorUM.Content -> FeeContent(state, isGaslessFeatureEnabled) is FeeSelectorUM.Loading -> FeeLoading() is FeeSelectorUM.Error -> FeeError() } @@ -122,7 +143,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) @@ -160,9 +181,18 @@ private fun FeeLoading() { } @Composable -private fun FeeContent(state: FeeSelectorUM.Content, modifier: Modifier = Modifier) { +private fun FeeContent(state: FeeSelectorUM.Content, isGaslessFeatureEnabled: Boolean, modifier: Modifier = Modifier) { val fiatRate = state.feeFiatRateUM Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + if (isGaslessFeatureEnabled) { + AuditLabel( + state = AuditLabelUM( + text = stringReference(state.selectedFeeItem.fee.amount.currencySymbol), + type = AuditLabelUM.Type.General, + ), + ) + } + EllipsisText( text = if (state.feeExtraInfo.isFeeConvertibleToFiat && fiatRate != null) { getFiatString( @@ -187,7 +217,10 @@ private fun FeeContent(state: FeeSelectorUM.Content, modifier: Modifier = Modifi .padding(start = TangemTheme.dimens.spacing4) .testTag(FeeSelectorBlockTestTags.FEE_AMOUNT), ) - if (!state.feeItems.isSingleItem()) { + + val isGaslessAvailable = isGaslessFeatureEnabled && state.feeExtraInfo.transactionFeeExtended != null + + if (!state.feeItems.isSingleItem() || isGaslessAvailable) { Icon( modifier = Modifier .size(width = 18.dp, height = 24.dp) @@ -205,16 +238,58 @@ private fun FeeContent(state: FeeSelectorUM.Content, modifier: Modifier = Modifi @Composable private fun FeeSelectorBlockContent_Preview(@PreviewParameter(FeeSelectorUMProvider::class) state: FeeSelectorUM) { TangemThemePreview { - FeeSelectorBlockContent(modifier = Modifier.fillMaxWidth(), state = state, onReadMoreClick = {}) + FeeSelectorBlockContent( + modifier = Modifier.fillMaxWidth(), + isGaslessFeatureEnabled = true, + state = state, + onReadMoreClick = {}, + ) } } private class FeeSelectorUMProvider : PreviewParameterProvider { - private val maxFeeItem = FeeItem.Market( - fee = Fee.Common(amount = Amount(value = BigDecimal("100000000"), blockchain = Blockchain.Ethereum)), - ) - private val lowFeeItem = - FeeItem.Market(Fee.Common(amount = Amount(value = BigDecimal("0.0002876"), blockchain = Blockchain.Ethereum))) + private val maxFeeItem + get() = FeeItem.Market( + fee = Fee.Common(amount = Amount(value = BigDecimal("100000000"), blockchain = Blockchain.Hedera)), + ) + private val lowFeeItem + get() = FeeItem.Market( + Fee.Common( + amount = Amount( + value = BigDecimal("0.0002876"), + blockchain = Blockchain.Ethereum, + ), + ), + ) + + val cryptoCurrencyStatus + get() = CryptoCurrencyStatus( + currency = CryptoCurrency.Coin( + id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), + network = Network( + id = Network.ID( + value = "bitcoin", + derivationPath = Network.DerivationPath.None, + ), + backendId = "bitcoin", + name = "Bitcoin", + currencySymbol = "BTC", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("bitcoin"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ), + name = "Bitcoin", + symbol = "BTC", + decimals = 8, + iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png", + isCustom = false, + ), + value = CryptoCurrencyStatus.Loading, + ) override val values: Sequence = sequenceOf( FeeSelectorUM.Content( @@ -225,6 +300,7 @@ private class FeeSelectorUMProvider : PreviewParameterProvider { isFeeApproximate = false, isFeeConvertibleToFiat = true, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeNonce = FeeNonce.None, feeFiatRateUM = FeeFiatRateUM( @@ -241,6 +317,7 @@ private class FeeSelectorUMProvider : PreviewParameterProvider { isFeeApproximate = false, isFeeConvertibleToFiat = true, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeNonce = FeeNonce.None, feeFiatRateUM = FeeFiatRateUM( diff --git a/features/send-v2/impl/src/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..7deca5c48b 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt @@ -1,67 +1,41 @@ package com.tangem.features.send.v2.feeselector.ui -import android.content.res.Configuration -import androidx.annotation.DrawableRes -import androidx.compose.animation.* -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.Text +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastForEachIndexed -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.common.ui.amountScreen.utils.getFiatReference +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.fade +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import com.arkivanov.decompose.router.stack.ChildStack import com.tangem.core.ui.components.PrimaryButton -import com.tangem.core.ui.components.atoms.text.EllipsisText -import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter -import com.tangem.core.ui.components.inputrow.InputRowEnter -import com.tangem.core.ui.components.inputrow.InputRowEnterInfoAmountV2 -import com.tangem.core.ui.extensions.* -import com.tangem.core.ui.format.bigdecimal.crypto -import com.tangem.core.ui.format.bigdecimal.fee -import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.features.send.v2.api.entity.* +import com.tangem.features.send.v2.api.entity.FeeItem +import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.params.FeeSelectorParams import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents -import com.tangem.features.send.v2.feeselector.model.StubFeeSelectorIntents +import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute import com.tangem.features.send.v2.impl.R -import com.tangem.utils.StringsSigns -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import java.math.BigDecimal +@Suppress("LongParameterList") @Composable internal fun FeeSelectorModalBottomSheet( + childStack: ChildStack, state: FeeSelectorUM, feeSelectorIntents: FeeSelectorIntents, feeDisplaySource: FeeSelectorParams.FeeDisplaySource, onDismiss: () -> Unit, + onBack: () -> Unit, ) { if (state !is FeeSelectorUM.Content) return @@ -71,426 +45,98 @@ internal fun FeeSelectorModalBottomSheet( onDismissRequest = onDismiss, content = TangemBottomSheetConfigContent.Empty, ), - containerColor = TangemTheme.colors.background.primary, + containerColor = TangemTheme.colors.background.tertiary, + onBack = if (childStack.backStack.isNotEmpty()) onBack else null, title = { - FeeTitle(feeDisplaySource = feeDisplaySource, onDismiss = onDismiss) - }, - content = { - FeeSelectorItems( - state = state, - feeSelectorIntents = feeSelectorIntents, - modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp), + FeeTitle( + childStack = childStack, + feeDisplaySource = feeDisplaySource, + onBack = onBack, ) }, - footer = if (state.selectedFeeItem is FeeItem.Custom) { - { - PrimaryButton( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - enabled = state.isPrimaryButtonEnabled, - text = stringResourceSafe(R.string.common_done), - onClick = feeSelectorIntents::onDoneClick, - ) + content = { + Content(childStack = childStack) + }, + footer = when { + childStack.active.configuration is FeeSelectorRoute.NetworkFee -> { + { + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + enabled = state.isPrimaryButtonEnabled, + text = stringResourceSafe(R.string.common_confirm), + onClick = feeSelectorIntents::onDoneClick, + ) + } } - } else { - null + childStack.active.configuration is FeeSelectorRoute.ChooseSpeed && + state.selectedFeeItem is FeeItem.Custom -> { + { + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + enabled = state.isPrimaryButtonEnabled, + text = stringResourceSafe(R.string.common_done), + onClick = feeSelectorIntents::onDoneClick, + ) + } + } + else -> null }, ) } @Composable -private fun FeeTitle(feeDisplaySource: FeeSelectorParams.FeeDisplaySource, onDismiss: () -> Unit) { - when (feeDisplaySource) { - FeeSelectorParams.FeeDisplaySource.Screen -> { - TangemModalBottomSheetTitle( - title = resourceReference(R.string.common_network_fee_title), - endIconRes = R.drawable.ic_close_24, - onEndClick = onDismiss, - ) - } - FeeSelectorParams.FeeDisplaySource.BottomSheet -> { - TangemModalBottomSheetTitle( - title = resourceReference(R.string.common_network_fee_title), - startIconRes = R.drawable.ic_back_24, - onStartClick = onDismiss, - ) - } - } -} - -@Composable -private fun FeeSelectorItems( - state: FeeSelectorUM.Content, - feeSelectorIntents: FeeSelectorIntents, - modifier: Modifier = Modifier, +private fun FeeTitle( + childStack: ChildStack, + feeDisplaySource: FeeSelectorParams.FeeDisplaySource, + onBack: () -> Unit, ) { - Column(modifier = modifier) { - val feeFiatRateUM = state.feeFiatRateUM - state.feeItems.fastForEachIndexed { index, item -> - val isSelected = item.isSameClass(state.selectedFeeItem) - val isLastItem = index == state.feeItems.size - 1 - val iconTint by animateColorAsState( - targetValue = if (isSelected) TangemTheme.colors.icon.accent else TangemTheme.colors.text.tertiary, - label = "Fee selector icon tint change", - ) - val iconBackgroundColor by animateColorAsState( - targetValue = if (isSelected) { - TangemTheme.colors.icon.accent.copy(alpha = 0.1F) + Children( + stack = childStack, + animation = stackAnimation(fade(animationSpec = tween(durationMillis = 100))), + ) { current -> + when (feeDisplaySource) { + FeeSelectorParams.FeeDisplaySource.Screen -> { + if (childStack.backStack.isNotEmpty()) { + TangemModalBottomSheetTitle( + title = current.configuration.title, + startIconRes = R.drawable.ic_back_24, + onStartClick = onBack, + ) } else { - TangemTheme.colors.background.secondary - }, - label = "Fee selector icon background change", - ) - val itemModifier = Modifier - .fillMaxWidth() - .selectedBorder(isSelected = isSelected) - .clickableSingle(onClick = { feeSelectorIntents.onFeeItemSelected(item) }) - when (item) { - is FeeItem.Custom -> CustomFeeBlock( - modifier = itemModifier, - customFee = item, - isSelected = isSelected, - iconBackgroundColor = iconBackgroundColor, - iconTint = iconTint, - onValueChange = feeSelectorIntents::onCustomFeeValueChange, - nonce = state.feeNonce, - ) - else -> RegularFeeItemContent( - modifier = itemModifier, - title = item.title, - iconRes = item.iconRes, - iconBackgroundColor = iconBackgroundColor, - iconTint = iconTint, - preDot = stringReference( - item.fee.amount.value.format { - crypto( - symbol = item.fee.amount.currencySymbol, - decimals = item.fee.amount.decimals, - ).fee(canBeLower = state.feeExtraInfo.isFeeApproximate) - }, - ), - postDot = if (state.feeExtraInfo.isFeeConvertibleToFiat && feeFiatRateUM != null) { - getFiatReference( - value = item.fee.amount.value, - rate = feeFiatRateUM.rate, - appCurrency = feeFiatRateUM.appCurrency, - ) - } else { - null - }, - ellipsizeOffset = item.fee.amount.currencySymbol.length, - showDivider = !isSelected && !isLastItem, + TangemModalBottomSheetTitle( + title = current.configuration.title, + endIconRes = R.drawable.ic_close_24, + onEndClick = onBack, + ) + } + } + FeeSelectorParams.FeeDisplaySource.BottomSheet -> { + TangemModalBottomSheetTitle( + title = current.configuration.title, + startIconRes = R.drawable.ic_back_24, + onStartClick = onBack, ) } } } } -@Suppress("LongParameterList") @Composable -private fun CustomFeeBlock( - customFee: FeeItem.Custom, - isSelected: Boolean, - iconBackgroundColor: Color, - iconTint: Color, - onValueChange: (Int, String) -> Unit, - nonce: FeeNonce, +private fun Content( + childStack: ChildStack, modifier: Modifier = Modifier, ) { - Column(modifier = modifier) { - Row( - modifier = Modifier.padding(all = 12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - modifier = Modifier - .size(36.dp) - .background(color = iconBackgroundColor, shape = CircleShape) - .padding(6.dp), - painter = painterResource(R.drawable.ic_edit_v2_24), - tint = iconTint, - contentDescription = null, - ) - Text( - text = stringResourceSafe(R.string.common_custom), - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.subtitle2, - ) - } - AnimatedVisibility( - visible = isSelected, - label = "Custom Fee Selected Animation", - enter = expandVertically().plus(fadeIn()), - exit = shrinkVertically().plus(fadeOut()), - ) { - ExpandedCustomFeeItems( - customFeeFields = customFee.customValues, - onValueChange = onValueChange, - nonce = nonce, - ) - } + Children( + stack = childStack, + animation = stackAnimation(fade(animationSpec = tween(durationMillis = 100))), + modifier = modifier + .fillMaxSize() + .animateContentSize(), + ) { + it.instance.Content(Modifier.fillMaxSize()) } -} - -@Composable -private fun ExpandedCustomFeeItems( - customFeeFields: ImmutableList, - onValueChange: (Int, String) -> Unit, - nonce: FeeNonce, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier) { - customFeeFields.fastForEachIndexed { index, field -> - val isShowDivider = index != customFeeFields.size - 1 || nonce is FeeNonce.Nonce - if (field.label != null) { - InputRowEnterInfoAmountV2( - text = field.value, - decimals = field.decimals, - symbol = field.symbol, - title = field.title, - titleColor = TangemTheme.colors.text.tertiary, - info = field.label, - description = field.footer, - keyboardOptions = field.keyboardOptions, - keyboardActions = field.keyboardActions, - onValueChange = { onValueChange(index, it) }, - showDivider = isShowDivider, - isReadOnly = field.isReadonly, - ) - } else { - InputRowEnterInfoAmountV2( - text = field.value, - decimals = field.decimals, - title = field.title, - titleColor = TangemTheme.colors.text.tertiary, - symbol = field.symbol, - description = field.footer, - onValueChange = { onValueChange(index, it) }, - keyboardOptions = field.keyboardOptions, - keyboardActions = field.keyboardActions, - showDivider = isShowDivider, - ) - } - } - - if (nonce is FeeNonce.Nonce) { - InputRowEnter( - text = nonce.nonce?.toString().orEmpty(), - title = resourceReference(R.string.send_nonce), - description = resourceReference(R.string.send_nonce_footer), - onValueChange = nonce.onNonceChange, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - placeholder = resourceReference(R.string.send_nonce_hint), - titleColor = TangemTheme.colors.text.secondary, - showDivider = false, - modifier = Modifier - .background( - color = TangemTheme.colors.background.primary, - shape = TangemTheme.shapes.roundedCornersXMedium, - ), - ) - } - } -} - -@Composable -private fun RegularFeeItemContent( - title: TextReference, - @DrawableRes iconRes: Int, - iconBackgroundColor: Color, - iconTint: Color, - modifier: Modifier = Modifier, - preDot: TextReference? = null, - postDot: TextReference? = null, - ellipsizeOffset: Int? = null, - showDivider: Boolean = true, -) { - Column(modifier = modifier) { - Row( - modifier = Modifier.padding(all = 12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - modifier = Modifier - .size(36.dp) - .background(color = iconBackgroundColor, shape = CircleShape) - .padding(6.dp), - painter = painterResource(iconRes), - tint = iconTint, - contentDescription = null, - ) - FeeDescription( - title = title, - preDot = preDot, - postDot = postDot, - ellipsizeOffset = ellipsizeOffset, - ) - } - if (showDivider) { - HorizontalDivider( - modifier = Modifier.padding(start = 60.dp, end = 12.dp), - color = TangemTheme.colors.stroke.primary, - thickness = 0.5.dp, - ) - } - } -} - -@Composable -private fun FeeDescription( - title: TextReference, - modifier: Modifier = Modifier, - preDot: TextReference? = null, - postDot: TextReference? = null, - ellipsizeOffset: Int? = null, -) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = title.resolveReference(), - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.subtitle2, - ) - if (preDot != null) { - FeeValueContent(preDot = preDot, postDot = postDot, ellipsizeOffset = ellipsizeOffset) - } - } -} - -@Composable -private fun FeeValueContent(preDot: TextReference, postDot: TextReference?, ellipsizeOffset: Int? = null) { - val ellipsis = if (ellipsizeOffset == null) { - TextEllipsis.End - } else { - TextEllipsis.OffsetEnd(ellipsizeOffset) - } - val textColor = TangemTheme.colors.text.tertiary - val textStyle = TangemTheme.typography.caption1 - Row { - EllipsisText( - text = preDot.resolveReference(), - style = textStyle, - color = textColor, - textAlign = TextAlign.End, - ellipsis = ellipsis, - ) - if (postDot != null) { - Text( - text = StringsSigns.DOT, - style = textStyle, - color = textColor, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing4), - ) - Text(text = postDot.resolveReference(), style = textStyle, color = textColor) - } - } -} - -@Preview(showBackground = true, device = Devices.PIXEL_7_PRO) -@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun FeeSelectorBS_Preview( - @PreviewParameter(FeeSelectorUMContentProvider::class) - state: FeeSelectorUM.Content, -) { - TangemThemePreview { - FeeSelectorModalBottomSheet( - onDismiss = {}, - state = state, - feeSelectorIntents = StubFeeSelectorIntents(), - feeDisplaySource = FeeSelectorParams.FeeDisplaySource.BottomSheet, - ) - } -} - -private class FeeSelectorUMContentProvider : CollectionPreviewParameterProvider( - collection = listOf( - FeeSelectorUM.Content( - isPrimaryButtonEnabled = false, - feeItems = persistentListOf( - FeeItem.Suggested( - title = resourceReference( - id = R.string.wc_fee_suggested, - formatArgs = wrappedList("Tangem"), - ), - fee = Fee.Common(Amount(value = BigDecimal("0.1"), blockchain = Blockchain.Ethereum)), - ), - FeeItem.Slow(fee = Fee.Common(Amount(value = BigDecimal("0.01"), blockchain = Blockchain.Ethereum))), - FeeItem.Market( - fee = Fee.Common( - Amount( - value = BigDecimal("0.02"), - blockchain = Blockchain.Ethereum, - ), - ), - ), - FeeItem.Fast(fee = Fee.Common(Amount(value = BigDecimal("0.03"), blockchain = Blockchain.Ethereum))), - customFeeItem, - ), - selectedFeeItem = FeeItem.Slow( - fee = Fee.Common( - Amount( - value = BigDecimal("0.01"), - blockchain = Blockchain.Ethereum, - ), - ), - ), - feeExtraInfo = FeeExtraInfo( - isFeeApproximate = true, - isFeeConvertibleToFiat = true, - isTronToken = false, - ), - feeFiatRateUM = FeeFiatRateUM( - rate = BigDecimal.TEN, - appCurrency = AppCurrency.Default, - ), - feeNonce = FeeNonce.None, - fees = TransactionFee.Single(customFeeItem.fee), - ), - ), -) - -private val customFeeItem = FeeItem.Custom( - fee = Fee.Common(Amount(value = BigDecimal("0.05"), blockchain = Blockchain.Ethereum)), - customValues = persistentListOf( - CustomFeeFieldUM( - value = "0.119806", - onValueChange = {}, - keyboardOptions = KeyboardOptions(), - keyboardActions = KeyboardActions(), - symbol = "ETH", - decimals = 8, - title = resourceReference(R.string.send_max_fee), - footer = resourceReference(R.string.send_custom_amount_fee_footer), - label = stringReference("~ 0,03 \$"), - isReadonly = false, - ), - CustomFeeFieldUM( - value = "40000", - onValueChange = {}, - keyboardOptions = KeyboardOptions(), - keyboardActions = KeyboardActions(), - symbol = "GWEI", - decimals = 8, - title = resourceReference(R.string.send_gas_price), - footer = resourceReference(R.string.send_gas_price_footer), - label = null, - isReadonly = false, - ), - CustomFeeFieldUM( - value = "31400", - onValueChange = {}, - keyboardOptions = KeyboardOptions(), - keyboardActions = KeyboardActions(), - symbol = null, - decimals = 8, - title = resourceReference(R.string.send_gas_limit), - footer = resourceReference(R.string.send_gas_limit_footer), - label = null, - isReadonly = false, - ), - ), -) \ No newline at end of file +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt index d6e89cd52c..2230676a18 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt @@ -216,6 +216,7 @@ internal class DefaultSendComponent @AssistedInject constructor( callback = model, predefinedValues = model.predefinedValues, onLoadFee = model::loadFee, + onLoadFeeExtended = model::loadFeeExtended, onSendTransaction = { innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess) }, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt index 02c4c88993..423fc07b00 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt @@ -15,6 +15,7 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.features.send.v2.api.FeeSelectorBlockComponent import com.tangem.features.send.v2.api.SendNotificationsComponent import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData @@ -86,12 +87,14 @@ internal class SendConfirmComponent( params = FeeSelectorParams.FeeSelectorBlockParams( state = model.uiState.value.feeSelectorUM, onLoadFee = params.onLoadFee, + onLoadFeeExtended = params.onLoadFeeExtended, feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, cryptoCurrencyStatus = params.cryptoCurrencyStatus, feeStateConfiguration = model.feeStateConfiguration, feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen, analyticsCategoryName = params.analyticsCategoryName, analyticsSendSource = params.analyticsSendSource, + userWalletId = params.userWallet.walletId, ), onResult = model::onFeeResult, ) @@ -102,7 +105,6 @@ internal class SendConfirmComponent( analyticsCategoryName = params.analyticsCategoryName, userWalletId = params.userWallet.walletId, cryptoCurrencyStatus = params.cryptoCurrencyStatus, - feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, appCurrency = params.appCurrency, callback = model, notificationData = NotificationData( @@ -113,6 +115,7 @@ internal class SendConfirmComponent( isIgnoreReduce = model.confirmData.isIgnoreReduce, fee = model.confirmData.fee, feeError = model.confirmData.feeError, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, ), ), ) @@ -163,6 +166,7 @@ internal class SendConfirmComponent( val isBalanceHidingFlow: StateFlow, val predefinedValues: PredefinedValues, val onLoadFee: suspend () -> Either, + val onLoadFeeExtended: suspend (CryptoCurrencyStatus?) -> Either, val onSendTransaction: () -> Unit, ) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt index 6fcd04be67..2479812b57 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Stable import arrow.core.getOrElse import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer import com.tangem.common.ui.amountScreen.models.AmountState @@ -29,6 +30,7 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.settings.NeverShowTapHelpUseCase import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase @@ -36,6 +38,7 @@ import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.features.send.v2.api.SendNotificationsComponent @@ -111,6 +114,7 @@ internal class SendConfirmModel @Inject constructor( private val accountsFeatureToggles: AccountsFeatureToggles, private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val currenciesRepository: CurrenciesRepository, + private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, sendBalanceUpdaterFactory: SendBalanceUpdater.Factory, ) : Model(), SendConfirmClickIntents, FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback { @@ -153,10 +157,7 @@ internal class SendConfirmModel @Inject constructor( internal var feeStateConfiguration: FeeStateConfiguration = FeeStateConfiguration.None init { - modelScope.launch { - isAmountSubtractAvailable = - isAmountSubtractAvailableUseCase(userWallet.walletId, cryptoCurrency).getOrElse { false } - } + updateAmountSubtractAvailability() configConfirmNavigation() subscribeOnNotificationsUpdateTrigger() subscribeOnCheckFeeResultUpdates() @@ -355,7 +356,7 @@ internal class SendConfirmModel @Inject constructor( val receivingAmount = checkAndCalculateSubtractedAmount( isAmountSubtractAvailable = isAmountSubtractAvailable, - cryptoCurrencyStatus = cryptoCurrencyStatus, + cryptoCurrencyStatus = getCurrencyStatusForFeePayment(), amountValue = amountValue, feeValue = feeValue, reduceAmountBy = confirmData.reduceAmountBy.orZero(), @@ -388,11 +389,23 @@ internal class SendConfirmModel @Inject constructor( } private suspend fun sendTransaction(txData: TransactionData.Uncompiled) { - val result = sendTransactionUseCase( - txData = txData, - userWallet = userWallet, - network = cryptoCurrency.network, - ) + val feeExtended = feeUMV2?.feeExtraInfo?.transactionFeeExtended + + val isFeeInTokenCurrency = feeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency + + val result = if (isFeeInTokenCurrency) { + createAndSendGaslessTransactionUseCase( + userWallet = userWallet, + transactionData = txData, + fee = feeExtended, + ) + } else { + sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = cryptoCurrency.network, + ) + } _uiState.update(SendConfirmSendingStateTransformer(isSending = false)) @@ -412,8 +425,8 @@ internal class SendConfirmModel @Inject constructor( ), ) }, - ifRight = { - updateTransactionStatus(txData) + ifRight = { txHash -> + updateTransactionStatus(txData, txHash) addTokenToWalletIfNeeded() sendBalanceUpdater.scheduleUpdates() sendAnalyticHelper.sendSuccessAnalytics( @@ -427,6 +440,16 @@ internal class SendConfirmModel @Inject constructor( ) } + private fun getCurrencyStatusForFeePayment(): CryptoCurrencyStatus { + val feeExtended = feeUMV2?.feeExtraInfo?.transactionFeeExtended + val isFeeInTokenCurrency = feeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency + return if (isFeeInTokenCurrency) { + feeUMV2?.feeExtraInfo?.feeCryptoCurrencyStatus ?: cryptoCurrencyStatus + } else { + params.feeCryptoCurrencyStatus + } + } + private fun addTokenToWalletIfNeeded() { if (cryptoCurrency !is CryptoCurrency.Token) return val wallets = destinationUM?.wallets ?: return @@ -456,9 +479,9 @@ internal class SendConfirmModel @Inject constructor( } } - private fun updateTransactionStatus(txData: TransactionData.Uncompiled) { + private fun updateTransactionStatus(txData: TransactionData.Uncompiled, txHash: String) { val txUrl = getExplorerTransactionUrlUseCase( - txHash = txData.hash.orEmpty(), + txHash = txHash.ifEmpty { txData.hash.orEmpty() }, networkId = cryptoCurrency.network.id, ).getOrNull().orEmpty() _uiState.update(SendConfirmSentStateTransformer(txData, txUrl)) @@ -497,6 +520,7 @@ internal class SendConfirmModel @Inject constructor( isIgnoreReduce = confirmData.isIgnoreReduce, fee = confirmData.fee, feeError = confirmData.feeError, + feeCryptoCurrencyStatus = getCurrencyStatusForFeePayment(), ), ) _uiState.update { state -> @@ -598,9 +622,23 @@ internal class SendConfirmModel @Inject constructor( override fun onFeeResult(feeSelectorUM: FeeSelectorUMRedesigned) { sendIdleTimer = SystemClock.elapsedRealtime() _uiState.update { it.copy(feeSelectorUM = feeSelectorUM) } + updateAmountSubtractAvailability() updateConfirmNotifications() } + private fun updateAmountSubtractAvailability() { + modelScope.launch { + val fee = feeUMV2?.feeExtraInfo?.transactionFeeExtended?.transactionFee?.normal + // we assume if feeExtraInfo is empty then pay fee in the main currency + val feeTokenId = feeUMV2?.feeExtraInfo?.transactionFeeExtended?.feeTokenId ?: cryptoCurrency.id + isAmountSubtractAvailable = isAmountSubtractAvailableUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + maybeGaslessFee = fee?.let { feeTokenId to fee }, + ).getOrElse { false } + } + } + private companion object { const val CHECK_FEE_UPDATE_DELAY = 10_000L } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt index 1028a207b5..d6b190dd5d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import arrow.core.Either import arrow.core.getOrElse import arrow.core.left +import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.navigationButtons.NavigationUM @@ -37,8 +38,11 @@ import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase +import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.models.GetUserWalletError import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -95,6 +99,8 @@ internal class SendModel @Inject constructor( private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val getFeeUseCase: GetFeeUseCase, + private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase, + private val getFeeForTokenUseCase: GetFeeForTokenUseCase, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val sendAmountUpdateTrigger: SendAmountUpdateTrigger, @@ -245,10 +251,10 @@ internal class SendModel @Inject constructor( showAlertError() } - suspend fun loadFee(): Either { + suspend fun prepareTransferTransaction(): Either { val predefinedValues = predefinedValues val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value - val transferTransaction = if (predefinedValues is PredefinedValues.Content.Deeplink) { + return if (predefinedValues is PredefinedValues.Content.Deeplink) { val predefinedAmount = predefinedValues.amount.parseBigDecimalOrNull() createTransferTransactionUseCase( amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus) ?: error("Invalid amount"), @@ -271,9 +277,12 @@ internal class SendModel @Inject constructor( userWalletId = userWallet.walletId, network = cryptoCurrency.network, ) - }.getOrElse { - return GetFeeError.DataError(it).left() } + } + + suspend fun loadFee(): Either { + val transferTransaction = prepareTransferTransaction() + .getOrElse { return GetFeeError.DataError(it).left() } return getFeeUseCase( transactionData = transferTransaction, @@ -282,6 +291,25 @@ internal class SendModel @Inject constructor( ) } + suspend fun loadFeeExtended(maybeToken: CryptoCurrencyStatus?): Either { + val transferTransaction = prepareTransferTransaction() + .getOrElse { return GetFeeError.DataError(it).left() } + + return if (maybeToken == null) { + getFeeForGaslessUseCase( + transactionData = transferTransaction, + userWallet = userWallet, + network = params.currency.network, + ) + } else { + getFeeForTokenUseCase( + transactionData = transferTransaction, + userWallet = userWallet, + token = maybeToken.currency, + ) + } + } + fun showAlertError() { sendConfirmAlertFactory.getGenericErrorState( onFailedTxEmailClick = ::onFailedTxEmailClick, @@ -334,15 +362,15 @@ internal class SendModel @Inject constructor( userWalletId = params.userWalletId, currency = cryptoCurrency, ).onEach { (account, cryptoCurrencyStatus) -> + isAccountModeFlow.value = isAccountsModeEnabledUseCase.invokeSync() + accountFlow.value = account + cryptoCurrencyStatusFlow.value = cryptoCurrencyStatus feeCryptoCurrencyStatusFlow.value = getFeePaidCryptoCurrencyStatusSyncUseCase( userWalletId = params.userWalletId, cryptoCurrencyStatus = cryptoCurrencyStatus, ).getOrNull() ?: cryptoCurrencyStatus - isAccountModeFlow.value = isAccountsModeEnabledUseCase.invokeSync() - accountFlow.value = account - if (params.amount != null) { router.replaceAll(Confirm) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/ui/SendConfirmSuccessContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/ui/SendConfirmSuccessContent.kt index 7d07f291bd..04905ad82d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/ui/SendConfirmSuccessContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/ui/SendConfirmSuccessContent.kt @@ -21,7 +21,7 @@ import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.toPx import com.tangem.core.ui.utils.toTimeFormat import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationBlockComponent -import com.tangem.features.send.v2.common.ui.FeeBlock +import com.tangem.features.send.v2.common.ui.FeeBlockSuccess import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.impl.R import com.tangem.features.send.v2.send.ui.state.SendUM @@ -106,7 +106,7 @@ private fun SuccessContent( onClick = {}, ) destinationBlockComponent.Content(modifier = Modifier) - FeeBlock(feeSelectorUM = sendUM.feeSelectorUM) + FeeBlockSuccess(feeSelectorUM = sendUM.feeSelectorUM) SpacerH(16.dp) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt index 8bf26ce732..98e150c9bb 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt @@ -78,6 +78,7 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen, analyticsCategoryName = params.analyticsCategoryName, analyticsSendSource = params.analyticsSendSource, + userWalletId = params.userWallet.walletId, ), onResult = model::onFeeResult, ) @@ -101,7 +102,6 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( analyticsCategoryName = params.analyticsCategoryName, userWalletId = params.userWallet.walletId, cryptoCurrencyStatus = params.cryptoCurrencyStatus, - feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, appCurrency = params.appCurrency, callback = model, notificationData = SendNotificationsComponent.Params.NotificationData( @@ -112,6 +112,7 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( isIgnoreReduce = false, fee = model.confirmData.fee, feeError = model.confirmData.feeError, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, ), ), ) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt index 8bab99a2a4..1b926b4ea2 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -350,6 +350,7 @@ internal class NFTSendConfirmModel @Inject constructor( isIgnoreReduce = false, fee = confirmData.fee, feeError = confirmData.feeError, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, // TODO change when gasless implement ), ) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/ui/NFTSendSuccessContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/ui/NFTSendSuccessContent.kt index b5e13f0e06..2f18427779 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/ui/NFTSendSuccessContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/ui/NFTSendSuccessContent.kt @@ -21,7 +21,7 @@ import com.tangem.core.ui.utils.toPx import com.tangem.core.ui.utils.toTimeFormat import com.tangem.features.nft.component.NFTDetailsBlockComponent import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationBlockComponent -import com.tangem.features.send.v2.common.ui.FeeBlock +import com.tangem.features.send.v2.common.ui.FeeBlockSuccess import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.impl.R import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM @@ -109,7 +109,7 @@ private fun SuccessContent( } nftDetailsBlockComponent.Content(modifier = Modifier) destinationBlockComponent.Content(modifier = Modifier) - FeeBlock(feeSelectorUM = nftSendUM.feeSelectorUM) + FeeBlockSuccess(feeSelectorUM = nftSendUM.feeSelectorUM) SpacerH(16.dp) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt index 1177c1f24f..87d931d616 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt @@ -75,7 +75,7 @@ private fun SendConvertTokenButton(onConvertToAnother: () -> Unit) { tint = TangemTheme.colors.icon.informative, modifier = Modifier .size(20.dp) - .background(TangemTheme.colors.control.unchecked, CircleShape) + .background(TangemTheme.colors.control.default, CircleShape) .padding(2.dp), ) Text( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt index 65e2574a85..7a7d53e4ec 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt @@ -14,6 +14,7 @@ import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkExceedBalance import com.tangem.features.send.v2.impl.R import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList internal class EthereumCustomFeeConverter( @@ -38,11 +39,16 @@ internal class EthereumCustomFeeConverter( ) override fun convert(value: Fee.Ethereum): ImmutableList { + if (value is Fee.Ethereum.TokenCurrency) { + return persistentListOf() + } + return buildList { add(convertFeeValue(value)) addAll( when (value) { + is Fee.Ethereum.TokenCurrency -> error("unreachable") is Fee.Ethereum.EIP1559 -> eipFeeConverter.convert(value) is Fee.Ethereum.Legacy -> legacyFeeConverter.convert(value) }, @@ -55,6 +61,7 @@ internal class EthereumCustomFeeConverter( override fun convertBack(normalFee: Fee.Ethereum, value: ImmutableList): Fee.Ethereum { return when (normalFee) { + is Fee.Ethereum.TokenCurrency -> error("unreachable") is Fee.Ethereum.EIP1559 -> eipFeeConverter.convertBack(normalFee = normalFee, value = value) is Fee.Ethereum.Legacy -> legacyFeeConverter.convertBack(normalFee = normalFee, value = value) } @@ -62,6 +69,7 @@ internal class EthereumCustomFeeConverter( override fun getGasLimitIndex(feeValue: Fee.Ethereum): Int { return when (feeValue) { + is Fee.Ethereum.TokenCurrency -> error("unreachable") is Fee.Ethereum.EIP1559 -> eipFeeConverter.getGasLimitIndex(feeValue) is Fee.Ethereum.Legacy -> legacyFeeConverter.getGasLimitIndex(feeValue) } @@ -74,6 +82,7 @@ internal class EthereumCustomFeeConverter( value: String, ): ImmutableList { return when (feeValue) { + is Fee.Ethereum.TokenCurrency -> error("unreachable") is Fee.Ethereum.EIP1559 -> eipFeeConverter.onValueChange( feeValue = feeValue, customValues = customValues, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt index 7efbe2186d..87cb0d5f97 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt @@ -79,7 +79,7 @@ internal class NotificationsModel @Inject constructor( private val analyticsCategoryName = params.analyticsCategoryName private val userWalletId = params.userWalletId private val cryptoCurrencyStatus = params.cryptoCurrencyStatus - private val feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus + private val feeCryptoCurrencyStatus = params.notificationData.feeCryptoCurrencyStatus private val currency = cryptoCurrencyStatus.currency private val appCurrency = params.appCurrency @@ -92,7 +92,10 @@ internal class NotificationsModel @Inject constructor( init { subscribeToNotificationUpdateTrigger() - checkIfSubtractAvailable() + modelScope.launch { + checkIfSubtractAvailable() + buildNotifications() + } incrementNotificationsShowCount() } @@ -102,11 +105,14 @@ internal class NotificationsModel @Inject constructor( .launchIn(modelScope) } - private fun checkIfSubtractAvailable() { - modelScope.launch { - isAmountSubtractAvailable = isAmountSubtractAvailableUseCase(userWalletId, currency).getOrElse { false } - buildNotifications() - } + private suspend fun checkIfSubtractAvailable() { + val feeCurrencyId = notificationData.feeCryptoCurrencyStatus.currency.id + val fee = notificationData.fee + isAmountSubtractAvailable = isAmountSubtractAvailableUseCase( + userWalletId = userWalletId, + currency = currency, + maybeGaslessFee = fee?.let { feeCurrencyId to fee }, + ).getOrElse { false } } private fun incrementNotificationsShowCount() { @@ -115,16 +121,19 @@ internal class NotificationsModel @Inject constructor( } } - private suspend fun updateState(data: NotificationData) { + private fun updateState(data: NotificationData) { notificationData = data - buildNotifications() + modelScope.launch { + checkIfSubtractAvailable() + buildNotifications() + } } private suspend fun buildNotifications() = with(notificationData) { val feeValue = fee?.amount?.value val sendingAmount = checkAndCalculateSubtractedAmount( isAmountSubtractAvailable = isAmountSubtractAvailable, - cryptoCurrencyStatus = cryptoCurrencyStatus, + cryptoCurrencyStatus = getCurrencyStatusForFeePayment(), amountValue = amountValue, feeValue = feeValue, reduceAmountBy = reduceAmountBy, @@ -226,7 +235,7 @@ internal class NotificationsModel @Inject constructor( fee = feeValue, userWalletId = userWalletId, tokenStatus = cryptoCurrencyStatus, - coinStatus = feeCryptoCurrencyStatus, + feeStatus = notificationData.feeCryptoCurrencyStatus, ).getOrNull() addExceedBalanceNotification( @@ -369,6 +378,15 @@ internal class NotificationsModel @Inject constructor( } } + private fun getCurrencyStatusForFeePayment(): CryptoCurrencyStatus { + val isFeeInTokenCurrency = notificationData.fee is Fee.Ethereum.TokenCurrency + return if (isFeeInTokenCurrency) { + notificationData.feeCryptoCurrencyStatus + } else { + cryptoCurrencyStatus + } + } + companion object { const val TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT = 3 } diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt index b44e37a21d..92dc0a0d66 100644 --- a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt @@ -8,6 +8,8 @@ import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network import com.tangem.features.send.v2.api.entity.* import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformerV2 @@ -28,6 +30,34 @@ class NFTSendConfirmationNotificationsTransformerV2Test { private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$") private val analyticsCategoryName = "test_category" + val cryptoCurrencyStatus = CryptoCurrencyStatus( + currency = CryptoCurrency.Coin( + id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), + network = Network( + id = Network.ID( + value = "bitcoin", + derivationPath = Network.DerivationPath.None, + ), + backendId = "bitcoin", + name = "Bitcoin", + currencySymbol = "BTC", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("bitcoin"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ), + name = "Bitcoin", + symbol = "BTC", + decimals = 8, + iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png", + isCustom = false, + ), + value = CryptoCurrencyStatus.Loading, + ) + @Test fun `GIVEN non content state WHEN transform THEN returns original state`() = runTest { // GIVEN @@ -191,6 +221,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), @@ -274,6 +305,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), @@ -345,6 +377,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), @@ -428,6 +461,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt index 4e3050ec47..ee1d34d98e 100644 --- a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt @@ -10,6 +10,8 @@ import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network import com.tangem.features.send.v2.api.entity.* import com.tangem.features.send.v2.common.ui.state.ConfirmUM import io.mockk.mockk @@ -30,6 +32,34 @@ class SendConfirmationNotificationsTransformerV2Test { private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$") private val analyticsCategoryName = "test_category" + val cryptoCurrencyStatus = CryptoCurrencyStatus( + currency = CryptoCurrency.Coin( + id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), + network = Network( + id = Network.ID( + value = "bitcoin", + derivationPath = Network.DerivationPath.None, + ), + backendId = "bitcoin", + name = "Bitcoin", + currencySymbol = "BTC", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("bitcoin"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ), + name = "Bitcoin", + symbol = "BTC", + decimals = 8, + iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png", + isCustom = false, + ), + value = CryptoCurrencyStatus.Loading, + ) + @Test fun `GIVEN non content state WHEN transform THEN returns original state`() = runTest { // GIVEN @@ -247,6 +277,7 @@ class SendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), @@ -330,6 +361,7 @@ class SendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), @@ -401,6 +433,7 @@ class SendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), @@ -484,6 +517,7 @@ class SendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), diff --git a/features/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/build.gradle.kts b/features/staking/impl/build.gradle.kts index c647e6e760..92180a4384 100644 --- a/features/staking/impl/build.gradle.kts +++ b/features/staking/impl/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(deps.lifecycle.compose) implementation(deps.jodatime) implementation(deps.timber) + implementation(deps.moshi) /** Compose */ implementation(deps.compose.accompanist.systemUiController) diff --git a/features/staking/impl/detekt-baseline-debug.xml b/features/staking/impl/detekt-baseline-debug.xml index 79c9fb9f11..113f53de5d 100644 --- a/features/staking/impl/detekt-baseline-debug.xml +++ b/features/staking/impl/detekt-baseline-debug.xml @@ -7,41 +7,30 @@ BooleanPropertyNaming:StakingModel.kt$StakingModel$val noBalanceState = balanceState == null BooleanPropertyNaming:StakingUiState.kt$StakingStates.InitialInfoState.Data$val showBanner: Boolean BooleanPropertyNaming:StakingUiState.kt$StakingUiState$val showColdWalletInteractionIcon: Boolean - CanBeNonNullable:StakingScreen.kt$bottomSheetConfig: TangemBottomSheetConfig? CastNullableToNonNullableType:SetApprovalBottomSheetInProgressTransformer.kt$SetApprovalBottomSheetInProgressTransformer$as CastNullableToNonNullableType:SetApprovalBottomSheetTypeChangeTransformer.kt$SetApprovalBottomSheetTypeChangeTransformer$as MultilineLambdaItParameter:AddStakingNotificationsTransformer.kt$AddStakingNotificationsTransformer${ it is StakingNotification.Error || it is NotificationUM.Error || it is NotificationUM.Warning.NetworkFeeUnreachable || it is StakingNotification.Warning.TransactionInProgress || it is StakingNotification.Warning.InitializeTonAccount } MultilineLambdaItParameter:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler${ val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true) val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true isNetwork && isCurrency } MultilineLambdaItParameter:StakingFeeBlock.kt${ if (it == FeeState.Error) { Text( text = DASH_SIGN, color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.body1, ) } } MultilineLambdaItParameter:StakingFeeBlock.kt${ if (it == FeeState.Loading) { RectangleShimmer( radius = TangemTheme.dimens.radius3, modifier = Modifier.size( height = TangemTheme.dimens.size24, width = TangemTheme.dimens.size90, ), ) } } - MultilineLambdaItParameter:StakingFeeTransactionLoader.kt$StakingFeeTransactionLoader${ onStakingFeeError(it) null } - MultilineLambdaItParameter:StakingFeeTransactionLoader.kt$StakingFeeTransactionLoader${ onStakingFeeError(it) return } MultilineLambdaItParameter:StakingInfoNotificationsFactory.kt$StakingInfoNotificationsFactory${ it.type == BalanceType.PREPARING || it.type == BalanceType.STAKED || it.type == BalanceType.LOCKED } - MultilineLambdaItParameter:StakingInfoNotificationsFactory.kt$StakingInfoNotificationsFactory${ it.type == BalanceType.STAKED || it.type == BalanceType.PREPARING || it.type == BalanceType.UNSTAKED } MultilineLambdaItParameter:StakingModel.kt$StakingModel${ EnterAmountBoundary( amount = it, fiatRate = status.value.fiatRate.orZero(), ) } MultilineLambdaItParameter:StakingModel.kt$StakingModel${ Timber.e(it) false } MultilineLambdaItParameter:StakingModel.kt$StakingModel${ isBalanceHiddenFlow.value = it.isBalanceHidden stateController.update( transformer = HideBalanceStateTransformer( isBalanceHidden = it.isBalanceHidden, cryptoCurrencyStatus = cryptoCurrencyStatus, appCurrency = appCurrency, ), ) } MultilineLambdaItParameter:StakingModel.kt$StakingModel${ stateController.update( SetFeeToTonInitializeBottomSheetTransformer( appCurrencyProvider = Provider { appCurrency }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = it.normal, isFeeApproximate = false, ), ) } MultilineLambdaItParameter:StakingStateController.kt$StakingStateController${ it.copy( showColdWalletInteractionIcon = userWallet is UserWallet.Cold, ) } - MultilineLambdaItParameter:StakingTransactionSender.kt$StakingTransactionSender${ onConstructError(it) null } - MultilineLambdaItParameter:StakingTransactionSender.kt$StakingTransactionSender${ onConstructError(it) return emptyList() } - NoNameShadowing:StakingFeeTransactionLoader.kt$StakingFeeTransactionLoader$amount - NoNameShadowing:StakingFeeTransactionLoader.kt$StakingFeeTransactionLoader${ if (!it.amount.isZero()) return feeResult } NullCheckOnMutableProperty:StakingModel.kt$StakingModel$if (feeCryptoCurrencyStatus != null && fee != null) { getBalanceNotEnoughForFeeWarningUseCase( fee = fee, userWalletId = userWalletId, tokenStatus = cryptoCurrencyStatus, coinStatus = feeCryptoCurrencyStatus ?: cryptoCurrencyStatus, ).getOrNull() } else { null } NullableToStringCall:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$$networkId NullableToStringCall:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$$tokenId PropertyUsedBeforeDeclaration:StakingFeeBlock.kt$FeeBlockPreviewProvider$contentState PropertyUsedBeforeDeclaration:StakingModel.kt$StakingModel$isAmountSubtractAvailable PropertyUsedBeforeDeclaration:StakingStateController.kt$StakingStateController$uiState - ReusedModifierInstance:StakingClaimRewardsValidatorContent.kt$InputRowImageInfo( subtitle = item.title, caption = item.getAprTextNeutral(), infoTitle = item.formattedFiatAmount, infoSubtitle = item.formattedCryptoAmount, imageUrl = item.validator?.image.orEmpty(), onImageError = { ValidatorImagePlaceholder() }, modifier = modifier .roundedShapeItemDecoration(index, state.rewards.lastIndex, false) .background(TangemTheme.colors.background.action) .clickable( enabled = item.pendingActions.isNotEmpty(), onClick = { clickIntents.onActiveStake(item) }, ), ) SuspendFunSwallowedCancellation:StakingModel.kt$StakingModel$runCatching UnnecessaryEventHandlerParameter:StakingInitialInfoContent.kt$onClick: (BalanceState) -> Unit - UnnecessaryFilter:StakingInfoNotificationsFactory.kt$StakingInfoNotificationsFactory$filter { it.validator?.address == validatorAddress } UnnecessaryLet:StakingModel.kt$StakingModel$let(::add) UnnecessaryLet:StakingTosText.kt$let { onTextClick(PRIVACY_POLICY_URL) } UnnecessaryLet:StakingTosText.kt$let { onTextClick(TERMS_OF_USE_URL) } UnsafeCallOnNullableType:StakingModel.kt$StakingModel$tonAccountInitializeTransaction!! - UseOrEmpty:StakingTransactionSender.kt$StakingTransactionSender$getExplorerTransactionUrlUseCase( txHash = transactionHashes.last(), networkId = cryptoCurrencyStatus.currency.network.id, ).getOrNull() ?: "" VarCouldBeVal:StakingModel.kt$StakingModel$private var actionsJobHolder: JobHolder = JobHolder() VarCouldBeVal:StakingModel.kt$StakingModel$private var approvalJobHolder: JobHolder = JobHolder() VarCouldBeVal:StakingModel.kt$StakingModel$private var feeJobHolder: JobHolder = JobHolder() diff --git a/features/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..66b3ccedc6 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt @@ -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() @@ -75,5 +75,9 @@ internal interface StakingClickIntents : AmountScreenClickIntents { fun onActivateTonAccountNotificationShow() + fun onNotEnoughFeeNotificationShow() + 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..a70dfc89f5 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -3,9 +3,11 @@ package com.tangem.features.staking.impl.presentation.model import androidx.compose.runtime.Stable import arrow.core.getOrElse import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer +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 +47,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 @@ -69,7 +70,8 @@ import com.tangem.features.staking.impl.presentation.state.events.StakingAlertUM import com.tangem.features.staking.impl.presentation.state.events.StakingEvent import com.tangem.features.staking.impl.presentation.state.events.StakingEventFactory import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater -import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeTransactionLoader +import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeLoader +import com.tangem.features.staking.impl.presentation.state.helpers.StakingOperationsFactory import com.tangem.features.staking.impl.presentation.state.helpers.StakingTransactionSender import com.tangem.features.staking.impl.presentation.state.transformers.* import com.tangem.features.staking.impl.presentation.state.transformers.amount.* @@ -128,13 +130,13 @@ internal class StakingModel @Inject constructor( private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase, private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase, - private val stakingTransactionLoader: StakingTransactionSender.Factory, - private val stakingFeeTransactionLoader: StakingFeeTransactionLoader.Factory, + private val stakingOperationsFactory: StakingOperationsFactory, private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, private val analyticsEventHandler: AnalyticsEventHandler, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, 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 +167,18 @@ internal class StakingModel @Inject constructor( private val cryptoCurrencyId: CryptoCurrency.ID = params.cryptoCurrency.id private val userWalletId: UserWalletId = params.userWalletId - private val yield: Yield = runBlocking { - getYieldUseCase(params.yieldId).getOrElse { - error("yield must be not null") + private val integration: StakingIntegration = runBlocking { + when (val integrationId = params.integrationId) { + is StakingIntegrationID.StakeKit -> { + val yield = getYieldUseCase(integrationId.value).getOrElse { + error("yield must be not null") + } + StakeKitIntegration(integrationId, yield) + } + StakingIntegrationID.P2PEthPool -> { + val vaults = p2pEthPoolRepository.getVaultsSync() + P2PEthPoolIntegration(integrationId, vaults) + } } } @@ -189,14 +200,23 @@ internal class StakingModel @Inject constructor( } private var appCurrency: AppCurrency by Delegates.notNull() - private val balancesToShow: List + private val balancesToShow: List get() { - val stakeKitBalance = cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit - return invalidatePendingTransactionsUseCase( - balanceItems = stakeKitBalance?.balance?.items.orEmpty(), - stakingActions = stakingActions, - token = yield.token, - ).getOrElse { emptyList() } + val stakingBalance = cryptoCurrencyStatus.value.stakingBalance + return when (integration) { + is StakeKitIntegration -> { + val balanceItems = (stakingBalance as? StakingBalance.Data.StakeKit) + ?.balance?.items.orEmpty() + invalidatePendingTransactionsUseCase( + balanceItems = balanceItems, + stakingActions = stakingActions, + token = integration.token, + ).getOrElse { emptyList() }.toStakingBalanceEntries() + } + is P2PEthPoolIntegration -> { + (stakingBalance as? StakingBalance.Data.P2PEthPool)?.entries.orEmpty() + } + } } private var isInitialInfoAnalyticSent: Boolean = false @@ -206,23 +226,23 @@ internal class StakingModel @Inject constructor( stakingBalanceUpdater.create( cryptoCurrencyStatus, userWallet, - yield, + integration, ) } - private val feeLoader by lazy(LazyThreadSafetyMode.NONE) { - stakingFeeTransactionLoader.create( + private val feeLoader: StakingFeeLoader by lazy(LazyThreadSafetyMode.NONE) { + stakingOperationsFactory.createFeeLoader( cryptoCurrencyStatus = cryptoCurrencyStatus, userWallet = userWallet, - yield = yield, + integration = integration, ) } - private val transactionSender by lazy(LazyThreadSafetyMode.NONE) { - stakingTransactionLoader.create( + private val transactionSender: StakingTransactionSender by lazy(LazyThreadSafetyMode.NONE) { + stakingOperationsFactory.createTransactionSender( cryptoCurrencyStatus = cryptoCurrencyStatus, userWallet = userWallet, - yield = yield, + integration = integration, isAmountSubtractAvailable = isAmountSubtractAvailable, ) } @@ -249,11 +269,12 @@ internal class StakingModel @Inject constructor( private val transactionsInProgress: CopyOnWriteArrayList = CopyOnWriteArrayList() - private var actionsJobHolder: JobHolder = JobHolder() - private var approvalJobHolder: JobHolder = JobHolder() - private var feeJobHolder: JobHolder = JobHolder() - private var sendTransactionJobHolder = JobHolder() - private var stepChangesJobHolder = JobHolder() + private val actionsJobHolder: JobHolder = JobHolder() + private val approvalJobHolder: JobHolder = JobHolder() + private val feeJobHolder: JobHolder = JobHolder() + private val sendTransactionJobHolder = JobHolder() + private val stepChangesJobHolder = JobHolder() + private val balanceHidingJobHolder = JobHolder() init { subscribeOnSelectedAppCurrency() @@ -268,6 +289,7 @@ internal class StakingModel @Inject constructor( feeJobHolder.cancel() sendTransactionJobHolder.cancel() stepChangesJobHolder.cancel() + balanceHidingJobHolder.cancel() } override fun onBackClick() { @@ -281,7 +303,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 +316,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 +336,7 @@ internal class StakingModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, minimumTransactionAmount = minimumTransactionAmount, actionType = uiState.value.actionType, - yield = yield, + integration = integration, ).let(::add) } } @@ -328,7 +350,7 @@ internal class StakingModel @Inject constructor( override fun getFee() { stateController.update( SetConfirmationStateLoadingTransformer( - yield = yield, + integration = integration, appCurrency = appCurrency, cryptoCurrency = cryptoCurrencyStatus.currency, ), @@ -380,54 +402,122 @@ internal class StakingModel @Inject constructor( modelScope.launch { stakingAnalyticSender.sendTransactionStakingClickedAnalytics(value) stateController.update(SetConfirmationStateInProgressTransformer()) - transactionSender.constructAndSendTransactions( - onConstructSuccess = { constructedTransactions -> - transactionsInProgress.addAll(constructedTransactions) - }, - onConstructError = { error -> - stakingEventFactory.createStakingErrorAlert(error) - stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) - }, - onSendSuccess = { txUrl -> - stakingAnalyticSender.sendTransactionStakingAnalytics( - stateController.value, - cryptoCurrencyStatus, - ) - transactionsInProgress.clear() - stateController.update(SetConfirmationStateCompletedTransformer(txUrl, cryptoCurrencyStatus)) - }, - onSendError = { error -> - analyticsEventHandler.send( - StakingAnalyticsEvent.TransactionError( - errorCode = error.getAnalyticsDescription(), - ), - ) - stakingEventFactory.createSendTransactionErrorAlert(error) - stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) - }, - onFeeIncreased = { increasedFee, isFeeApproximate -> - stateController.updateAll( - SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus), - SetConfirmationStateAssentTransformer( - appCurrencyProvider = Provider { appCurrency }, - feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, - fee = increasedFee, - isFeeApproximate = isFeeApproximate, - cryptoCurrencyStatus = cryptoCurrencyStatus, - ), - ) - stateController.updateEvent( - StakingEvent.ShowAlert( - StakingAlertUM.FeeIncreased(stateController::dismissAlert), - ), - ) - updateNotifications() - }, - ) + + if (integration is P2PEthPoolIntegration) { + checkFeeAndSendP2PTransaction() + } else { + sendTransaction() + } }.saveIn(sendTransactionJobHolder) } } + private suspend fun checkFeeAndSendP2PTransaction() { + val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data + val currentFeeState = confirmationState?.feeState as? FeeState.Content + val currentFee = currentFeeState?.fee + + var actualFee: Fee? = null + feeLoader.getFee( + onStakingFee = { fee, _ -> + actualFee = fee + }, + onStakingFeeError = { error -> + stakingEventFactory.createStakingErrorAlert(error) + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + }, + onApprovalFee = { }, + onFeeError = { error -> + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + updateNotifications(error) + }, + ) + + val newFee = actualFee ?: return + + val currentFeeAmount = when (currentFee) { + is Fee.Common -> currentFee.amount.value ?: BigDecimal.ZERO + is Fee.Ethereum -> currentFee.amount.value ?: BigDecimal.ZERO + else -> BigDecimal.ZERO + } + + val newFeeAmount = when (newFee) { + is Fee.Common -> newFee.amount.value ?: BigDecimal.ZERO + is Fee.Ethereum -> newFee.amount.value ?: BigDecimal.ZERO + else -> BigDecimal.ZERO + } + + if (newFeeAmount > currentFeeAmount) { + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + stakingEventFactory.createNetworkFeeUpdatedAlert( + onConfirm = { + modelScope.launch { + stateController.update(UpdateConfirmationFeeTransformer(newFee)) + stateController.update(SetConfirmationStateInProgressTransformer()) + sendTransaction() + } + }, + ) + } else { + sendTransaction() + } + } + + private suspend fun sendTransaction() { + transactionSender.send( + StakingTransactionSender.Callbacks( + onConstructSuccess = { constructedTransactions -> + transactionsInProgress.addAll(constructedTransactions) + }, + onConstructError = { error -> + stakingEventFactory.createStakingErrorAlert(error) + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + }, + onSendSuccess = { txUrl -> + stakingAnalyticSender.sendTransactionStakingAnalytics( + stateController.value, + cryptoCurrencyStatus, + ) + transactionsInProgress.clear() + stateController.update( + SetConfirmationStateCompletedTransformer(txUrl, cryptoCurrencyStatus), + ) + }, + onSendError = { error -> + analyticsEventHandler.send( + StakingAnalyticsEvent.TransactionError( + errorCode = error.getAnalyticsDescription(), + ), + ) + stakingEventFactory.createSendTransactionErrorAlert(error) + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + }, + onFeeIncreased = { increasedFee, isFeeApproximate -> + stateController.updateAll( + SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus), + SetConfirmationStateAssentTransformer( + appCurrencyProvider = Provider { appCurrency }, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fee = increasedFee, + isFeeApproximate = isFeeApproximate, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ), + ) + stateController.updateEvent( + StakingEvent.ShowAlert( + StakingAlertUM.FeeIncreased(stateController::dismissAlert), + ), + ) + updateNotifications() + }, + onTransactionExpired = { + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + getFee() + }, + ), + ) + } + override fun onPrevClick() { if (value.currentStep == StakingStep.Confirmation) { val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data @@ -480,7 +570,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 +578,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 +593,7 @@ internal class StakingModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, minimumTransactionAmount = minimumTransactionAmount, value = value, - yield = yield, + integration = integration, ), ) } @@ -519,7 +609,7 @@ internal class StakingModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, minimumTransactionAmount = minimumTransactionAmount, actionType = uiState.value.actionType, - yield = yield, + integration = integration, ), ) } @@ -540,16 +630,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 +692,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 +710,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 +723,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) @@ -763,12 +856,13 @@ internal class StakingModel @Inject constructor( val amount = amountState?.amountTextField?.cryptoAmount?.value val fee = feeState?.fee?.amount?.value - val currencyWarning = if (feeCryptoCurrencyStatus != null && fee != null) { + val feeStatus = feeCryptoCurrencyStatus + val currencyWarning = if (feeStatus != null && fee != null) { getBalanceNotEnoughForFeeWarningUseCase( fee = fee, userWalletId = userWalletId, tokenStatus = cryptoCurrencyStatus, - coinStatus = feeCryptoCurrencyStatus ?: cryptoCurrencyStatus, + feeStatus = feeStatus, ).getOrNull() } else { null @@ -799,7 +893,7 @@ internal class StakingModel @Inject constructor( isSubtractAvailable = isAmountSubtractAvailable, feeError = feeError, stakingError = stakingError, - yield = yield, + integration = integration, ), ) } @@ -922,7 +1016,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 +1024,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 +1033,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 }, ) @@ -1031,6 +1125,14 @@ internal class StakingModel @Inject constructor( ) } + override fun onNotEnoughFeeNotificationShow() { + analyticsEventHandler.send( + StakingAnalyticsEvent.NotEnoughFee( + token = cryptoCurrencyStatus.currency.symbol, + ), + ) + } + override fun onActivateTonAccountClick() { analyticsEventHandler.send( StakingAnalyticsEvent.ButtonActivate( @@ -1068,6 +1170,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 @@ -1175,6 +1288,7 @@ internal class StakingModel @Inject constructor( } .flowOn(dispatchers.main) .launchIn(modelScope) + .saveIn(balanceHidingJobHolder) } private fun subscribeOnSelectedAppCurrency() { @@ -1240,7 +1354,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 +1373,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 +1386,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 +1405,7 @@ internal class StakingModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, value = amountValue, minimumTransactionAmount = minimumTransactionAmount, - yield = yield, + integration = integration, ), ) } @@ -1331,8 +1445,10 @@ internal class StakingModel @Inject constructor( } private suspend fun checkIfSubtractAvailable() { - isAmountSubtractAvailable = isAmountSubtractAvailableUseCase(userWalletId, cryptoCurrencyStatus.currency) - .getOrElse { false } + isAmountSubtractAvailable = isAmountSubtractAvailableUseCase( + userWalletId = userWalletId, + currency = cryptoCurrencyStatus.currency, + ).getOrElse { false } } private fun isTopHeatupCase(): Boolean { diff --git a/features/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..c126cd165c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt @@ -4,7 +4,9 @@ import androidx.annotation.StringRes import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.* +import com.tangem.domain.staking.model.CooldownPeriod import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.utils.toTextReference internal object StakingNotification { @@ -89,25 +91,30 @@ 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( - val cooldownPeriodDays: Int, + val cooldownPeriod: CooldownPeriod, @StringRes val subtitleRes: Int, ) : Info( title = resourceReference(R.string.common_unstake), subtitle = resourceReference( subtitleRes, - wrappedList( - pluralReference( - id = R.plurals.common_days, - count = cooldownPeriodDays, - formatArgs = wrappedList(cooldownPeriodDays), - ), - ), + wrappedList(cooldownPeriod.toTextReference()), ), ) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt index 92676e87dd..a9164b0dca 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt @@ -17,10 +17,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import com.tangem.core.decompose.di.ModelScoped import javax.inject.Inject -import javax.inject.Singleton -@Singleton +@ModelScoped internal class StakingStateController @Inject constructor( urlOpener: UrlOpener, ) { 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..1b6605e4ff 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt @@ -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 @@ -71,8 +71,14 @@ internal sealed class StakingStates { val onInfoClick: (InfoType) -> Unit, val yieldBalance: InnerYieldBalanceState, val pullToRefreshConfig: PullToRefreshConfig, + val legalUrls: LegalUrls, ) : InitialInfoState() + data class LegalUrls( + val termsOfServiceUrl: String, + val privacyPolicyUrl: String, + ) + data class Empty( override val isPrimaryButtonEnabled: Boolean = false, ) : InitialInfoState() @@ -97,9 +103,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 deleted file mode 100644 index 20230aecef..0000000000 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt +++ /dev/null @@ -1,177 +0,0 @@ -package com.tangem.features.staking.impl.presentation.state.converters - -import com.tangem.core.ui.extensions.* -import com.tangem.core.ui.format.bigdecimal.crypto -import com.tangem.core.ui.format.bigdecimal.fiat -import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.core.ui.utils.parseBigDecimal -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.staking.BalanceItem -import com.tangem.domain.models.staking.BalanceType -import com.tangem.domain.models.staking.BalanceType.Companion.isClickable -import com.tangem.domain.models.staking.StakingBalance -import com.tangem.domain.models.staking.action.StakingActionType -import com.tangem.domain.staking.model.stakekit.Yield -import com.tangem.domain.staking.utils.getRewardStakingBalance -import com.tangem.features.staking.impl.R -import com.tangem.features.staking.impl.presentation.state.BalanceState -import com.tangem.lib.crypto.BlockchainUtils -import com.tangem.lib.crypto.BlockchainUtils.isTon -import com.tangem.utils.Provider -import com.tangem.utils.converter.Converter -import com.tangem.utils.extensions.orZero -import kotlinx.collections.immutable.toPersistentList -import kotlinx.datetime.Instant -import java.math.BigDecimal -import java.util.Calendar - -internal class BalanceItemConverter( - private val cryptoCurrencyStatus: CryptoCurrencyStatus, - private val appCurrencyProvider: Provider, - private val yield: Yield, -) : Converter { - - override fun convert(value: BalanceItem): BalanceState? { - val appCurrency = appCurrencyProvider() - val cryptoCurrency = cryptoCurrencyStatus.currency - - val validator = yield.validators.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) - return title?.let { - BalanceState( - groupId = value.groupId, - validator = validator, - title = title, - subtitle = getSubtitle(value), - type = value.type, - cryptoValue = cryptoAmount.parseBigDecimal(cryptoCurrency.decimals), - cryptoAmount = cryptoAmount, - formattedCryptoAmount = stringReference( - cryptoAmount.format { crypto(cryptoCurrency) }, - ), - fiatAmount = fiatAmount, - formattedFiatAmount = stringReference( - fiatAmount.format { - fiat( - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ) - }, - ), - rawCurrencyId = value.rawCurrencyId, - pendingActions = value.pendingActions.toPersistentList(), - isClickable = value.isClickable(), - isPending = value.isPending, - validatorAddress = value.validatorAddress, - ) - } - } - - private fun BalanceItem.getBalanceValue(): BigDecimal { - val isIncludeStakingTotalBalance = BlockchainUtils.isIncludeStakingTotalBalance( - blockchainId = cryptoCurrencyStatus.currency.network.rawId, - ) - val stakeKitBalance = cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit - return if (isIncludeStakingTotalBalance) { - amount - } else { - amount - stakeKitBalance?.getRewardStakingBalance().orZero() - } - } - - private fun BalanceType.getTitle(validatorName: String?) = when (this) { - BalanceType.PREPARING, - BalanceType.STAKED, - -> validatorName?.let { stringReference(it) } - BalanceType.UNSTAKED -> resourceReference(R.string.staking_unstaked) - BalanceType.UNSTAKING -> resourceReference(R.string.staking_unstaking) - BalanceType.LOCKED -> resourceReference(R.string.staking_locked) - BalanceType.AVAILABLE, - BalanceType.REWARDS, - BalanceType.UNLOCKING, - BalanceType.UNKNOWN, - -> null - } - - private fun getSubtitle(balance: BalanceItem) = when (balance.type) { - BalanceType.UNSTAKING -> getUnbondingDate(balance.date) - BalanceType.UNSTAKED -> resourceReference(R.string.staking_tap_to_withdraw) - BalanceType.LOCKED -> if (balance.pendingActions.any { it.type == StakingActionType.VOTE_LOCKED }) { - resourceReference(R.string.staking_tap_to_unlock_or_vote) - } else { - resourceReference(R.string.staking_tap_to_unlock) - } - BalanceType.PREPARING -> { - val warmupPeriod = yield.metadata.warmupPeriod.days - combinedReference( - resourceReference(R.string.staking_details_warmup_period), - stringReference(" "), - pluralReference(R.plurals.common_days, warmupPeriod, wrappedList(warmupPeriod)), - ) - } - BalanceType.AVAILABLE, - BalanceType.STAKED, - BalanceType.UNLOCKING, - BalanceType.REWARDS, - BalanceType.UNKNOWN, - -> null - } - - private fun getUnbondingDate(date: Instant?): TextReference? { - val unbondingPeriod = yield.metadata.cooldownPeriod?.days ?: return null - if (date == null) { - return combinedReference( - resourceReference(R.string.staking_details_unbonding_period), - stringReference(" "), - pluralReference(R.plurals.common_days, unbondingPeriod, wrappedList(unbondingPeriod)), - ) - } - - val nowCalendar = Calendar.getInstance() - nowCalendar.resetHours() - - val endDate = Calendar.getInstance() - endDate.timeInMillis = date.toEpochMilliseconds() - endDate.resetHours() - - val days = ((endDate.timeInMillis - nowCalendar.timeInMillis) / DAY_IN_MILLIS).toInt() - return if (days > 0) { - resourceReference( - R.string.common_left, - wrappedList( - pluralReference(R.plurals.common_days, days, wrappedList(days)), - ), - ) - } else { - resourceReference(R.string.common_today) - } - } - - private fun BalanceItem.isClickable(): Boolean { - val networkId = cryptoCurrencyStatus.currency.network.rawId - return when { - // TON allows withdrawing funds in the preparing state, unlike other networks. - isTon(networkId) && this.type == BalanceType.PREPARING -> { - pendingActions.any { it.type == StakingActionType.WITHDRAW } - } - else -> this.type.isClickable() && !this.isPending - } - } - - private fun Calendar.resetHours() { - this[Calendar.HOUR_OF_DAY] = 0 - this[Calendar.MINUTE] = 0 - this[Calendar.SECOND] = 0 - this[Calendar.MILLISECOND] = 0 - } - - private companion object { - const val DAY_IN_MILLIS = 24 * 60 * 60 * 1000 - } -} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt index 68e09c30c5..bfa8c333f9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt @@ -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,34 +22,36 @@ 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 - return if (stakingBalance is StakingBalance.Data.StakeKit) { - val balances = stakingBalance.balance.items - StakingStates.RewardsValidatorsState.Data( - isPrimaryButtonEnabled = true, - rewards = balances - .filter { it.type == BalanceType.REWARDS } - .mapRewardBalances(cryptoCurrencyStatus) - .toPersistentList(), - ) - } else { - // TODO p2p - StakingStates.RewardsValidatorsState.Empty() + return when (stakingBalance) { + is StakingBalance.Data.StakeKit -> { + val balances = stakingBalance.balance.items + StakingStates.RewardsValidatorsState.Data( + isPrimaryButtonEnabled = true, + rewards = balances + .filter { it.type == BalanceType.REWARDS } + .mapRewardBalances(cryptoCurrencyStatus) + .toPersistentList(), + ) + } + is StakingBalance.Data.P2PEthPool -> StakingStates.RewardsValidatorsState.Empty() + else -> StakingStates.RewardsValidatorsState.Empty() } } 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 +59,7 @@ internal class RewardsValidatorStateConverter( ) } - private fun Yield.Validator.toBalanceState( + private fun StakingTarget.toBalanceState( balance: BalanceItem, cryptoCurrencyStatus: CryptoCurrencyStatus, cryptoValue: BigDecimal, @@ -80,7 +83,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 +96,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/StakingBalanceEntryConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt new file mode 100644 index 0000000000..a46b3a7c79 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt @@ -0,0 +1,236 @@ +package com.tangem.features.staking.impl.presentation.state.converters + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.staking.BalanceType +import com.tangem.domain.models.staking.PendingAction +import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.models.staking.StakingBalanceEntry +import com.tangem.domain.models.staking.StakingEntryActions +import com.tangem.domain.models.staking.StakingEntryType +import com.tangem.domain.models.staking.action.StakingActionType +import com.tangem.domain.staking.model.StakingIntegration +import com.tangem.domain.staking.model.StakingTarget +import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.BalanceState +import com.tangem.features.staking.impl.presentation.state.utils.toTextReference +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.lib.crypto.BlockchainUtils.isTon +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toPersistentList +import kotlinx.datetime.Instant +import java.math.BigDecimal +import java.util.Calendar + +internal class StakingBalanceEntryConverter( + private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val appCurrencyProvider: Provider, + private val integration: StakingIntegration, +) : Converter { + + override fun convert(value: StakingBalanceEntry): BalanceState? { + val appCurrency = appCurrencyProvider() + val cryptoCurrency = cryptoCurrencyStatus.currency + + val target = findTarget(value) + val cryptoAmount = value.getBalanceValue() + val fiatAmount = cryptoCurrencyStatus.value.fiatRate?.times(cryptoAmount) + + val title = value.type.getTitle(target?.name ?: value.validator?.name) + return title?.let { + BalanceState( + groupId = value.id, + target = target, + title = title, + subtitle = getSubtitle(value), + type = value.type.toBalanceType(), + cryptoValue = cryptoAmount.parseBigDecimal(cryptoCurrency.decimals), + cryptoAmount = cryptoAmount, + formattedCryptoAmount = stringReference( + cryptoAmount.format { crypto(cryptoCurrency) }, + ), + fiatAmount = fiatAmount, + formattedFiatAmount = stringReference( + fiatAmount.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + ), + rawCurrencyId = value.rawCurrencyId, + pendingActions = value.getPendingActions().toPersistentList(), + isClickable = value.isClickable(), + isPending = value.isPending, + targetAddress = value.validator?.address, + ) + } + } + + private fun findTarget(entry: StakingBalanceEntry): StakingTarget? { + return integration.targets.firstOrNull { + entry.validator?.address?.contains(it.address, ignoreCase = true) == true + } + } + + private fun StakingBalanceEntry.getBalanceValue(): BigDecimal { + val isIncludeStakingTotalBalance = BlockchainUtils.isIncludeStakingTotalBalance( + blockchainId = cryptoCurrencyStatus.currency.network.rawId, + ) + return if (isIncludeStakingTotalBalance) { + amount + } else { + val stakingBalance = cryptoCurrencyStatus.value.stakingBalance + if (stakingBalance is StakingBalance.Data.StakeKit) { + amount - stakingBalance.totalRewards + } else { + amount + } + } + } + + private fun StakingEntryType.getTitle(validatorName: String?): TextReference? = when (this) { + StakingEntryType.PREPARING, + StakingEntryType.STAKED, + -> validatorName?.let { stringReference(it) } + StakingEntryType.WITHDRAWABLE -> resourceReference(R.string.staking_unstaked) + StakingEntryType.UNSTAKING -> resourceReference(R.string.staking_unstaking) + StakingEntryType.LOCKED -> resourceReference(R.string.staking_locked) + StakingEntryType.AVAILABLE, + StakingEntryType.REWARDS, + StakingEntryType.UNLOCKING, + StakingEntryType.UNKNOWN, + -> null + } + + private fun getSubtitle(entry: StakingBalanceEntry): TextReference? = when (entry.type) { + StakingEntryType.UNSTAKING -> getUnbondingDate(entry.date) + StakingEntryType.WITHDRAWABLE -> resourceReference(R.string.staking_tap_to_withdraw) + StakingEntryType.LOCKED -> { + val hasVoteLocked = entry.getPendingActions().any { it.type == StakingActionType.VOTE_LOCKED } + if (hasVoteLocked) { + resourceReference(R.string.staking_tap_to_unlock_or_vote) + } else { + resourceReference(R.string.staking_tap_to_unlock) + } + } + StakingEntryType.PREPARING -> { + val warmupPeriod = integration.warmupPeriodDays + TextReference.Combined( + wrappedList( + resourceReference(R.string.staking_details_warmup_period), + stringReference(" "), + pluralReference(R.plurals.common_days, warmupPeriod, wrappedList(warmupPeriod)), + ), + ) + } + StakingEntryType.AVAILABLE, + StakingEntryType.STAKED, + StakingEntryType.UNLOCKING, + StakingEntryType.REWARDS, + StakingEntryType.UNKNOWN, + -> null + } + + private fun getUnbondingDate(date: Instant?): TextReference? { + val cooldownPeriod = integration.cooldownPeriod ?: return null + if (date == null) { + return TextReference.Combined( + wrappedList( + resourceReference(R.string.staking_details_unbonding_period), + stringReference(" "), + cooldownPeriod.toTextReference(), + ), + ) + } + + val nowCalendar = Calendar.getInstance() + nowCalendar.resetHours() + + val endDate = Calendar.getInstance() + endDate.timeInMillis = date.toEpochMilliseconds() + endDate.resetHours() + + val days = ((endDate.timeInMillis - nowCalendar.timeInMillis) / DAY_IN_MILLIS).toInt() + return if (days > 0) { + resourceReference( + R.string.common_left, + wrappedList( + pluralReference(R.plurals.common_days, days, wrappedList(days)), + ), + ) + } else { + resourceReference(R.string.common_today) + } + } + + private fun StakingBalanceEntry.isClickable(): Boolean { + val networkId = cryptoCurrencyStatus.currency.network.rawId + return when { + // TON allows withdrawing funds in the preparing state, unlike other networks. + isTon(networkId) && this.type == StakingEntryType.PREPARING -> { + getPendingActions().any { it.type == StakingActionType.WITHDRAW } + } + else -> this.type.isClickableType() && !this.isPending + } + } + + private fun StakingEntryType.isClickableType(): Boolean = when (this) { + StakingEntryType.STAKED, + StakingEntryType.WITHDRAWABLE, + StakingEntryType.LOCKED, + -> true + else -> false + } + + private fun StakingBalanceEntry.getPendingActions(): List { + return when (val actions = this.actions) { + is StakingEntryActions.StakeKit -> actions.pendingActions + is StakingEntryActions.P2PEthPool -> if (type == StakingEntryType.WITHDRAWABLE) { + listOf(STUB_WITHDRAW_ACTION) + } else { + emptyList() + } + } + } + + private fun StakingEntryType.toBalanceType(): BalanceType { + return when (this) { + StakingEntryType.AVAILABLE -> BalanceType.AVAILABLE + StakingEntryType.STAKED -> BalanceType.STAKED + StakingEntryType.PREPARING -> BalanceType.PREPARING + StakingEntryType.LOCKED -> BalanceType.LOCKED + StakingEntryType.UNSTAKING -> BalanceType.UNSTAKING + StakingEntryType.UNLOCKING -> BalanceType.UNLOCKING + StakingEntryType.WITHDRAWABLE -> BalanceType.UNSTAKED + StakingEntryType.REWARDS -> BalanceType.REWARDS + StakingEntryType.UNKNOWN -> BalanceType.UNKNOWN + } + } + + private fun Calendar.resetHours() { + this[Calendar.HOUR_OF_DAY] = 0 + this[Calendar.MINUTE] = 0 + this[Calendar.SECOND] = 0 + this[Calendar.MILLISECOND] = 0 + } + + private companion object { + val STUB_WITHDRAW_ACTION = PendingAction( + StakingActionType.WITHDRAW, + passthrough = "", + args = null, + ) + const val DAY_IN_MILLIS = 24 * 60 * 60 * 1000 + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt index 8c118c5572..7725be5eee 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt @@ -6,13 +6,13 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.staking.BalanceItem import com.tangem.domain.models.staking.BalanceType import com.tangem.domain.models.staking.RewardBlockType import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.models.staking.StakingBalanceEntry +import com.tangem.domain.models.staking.StakingEntryType import com.tangem.domain.models.staking.action.StakingActionType -import com.tangem.domain.staking.model.stakekit.Yield -import com.tangem.domain.staking.utils.getRewardStakingBalance +import com.tangem.domain.staking.model.StakingIntegration import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState import com.tangem.features.staking.impl.presentation.state.YieldReward import com.tangem.lib.crypto.BlockchainUtils @@ -24,23 +24,23 @@ import kotlinx.collections.immutable.toPersistentList internal class YieldBalancesConverter( private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val appCurrencyProvider: Provider, - private val balancesToShowProvider: Provider>, - private val yield: Yield, + private val balancesToShowProvider: Provider>, + private val integration: StakingIntegration, ) : Converter { - private val balanceItemConverter by lazy(LazyThreadSafetyMode.NONE) { - BalanceItemConverter(cryptoCurrencyStatus, appCurrencyProvider, yield) + private val balanceEntryConverter by lazy(LazyThreadSafetyMode.NONE) { + StakingBalanceEntryConverter(cryptoCurrencyStatus, appCurrencyProvider, integration) } override fun convert(value: Unit): InnerYieldBalanceState { val appCurrency = appCurrencyProvider() - val cryptoCurrency = cryptoCurrencyStatus.currency - val stakeKitBalance = cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit - val balanceToShowItems = balancesToShowProvider() + val stakingBalance = cryptoCurrencyStatus.value.stakingBalance + val balanceEntries = balancesToShowProvider() + val hasStakingData = stakingBalance is StakingBalance.Data - return if (stakeKitBalance != null || balanceToShowItems.any { it.isPending }) { - val cryptoRewardsValue = stakeKitBalance?.getRewardStakingBalance() + return if (hasStakingData || balanceEntries.any { it.isPending }) { + val cryptoRewardsValue = (stakingBalance as? StakingBalance.Data)?.totalRewards val fiatRate = cryptoCurrencyStatus.value.fiatRate val fiatRewardsValue = if (fiatRate != null && cryptoRewardsValue != null) { @@ -48,14 +48,11 @@ internal class YieldBalancesConverter( } else { null } - val type = getRewardBlockType() - val pendingRewardsConstraints = stakeKitBalance?.balance?.items - ?.firstOrNull { it.type == BalanceType.REWARDS } - ?.pendingActionsConstraints - ?.firstOrNull { it.type == StakingActionType.CLAIM_REWARDS } + val type = getRewardBlockType(stakingBalance) + val pendingRewardsConstraints = getRewardConstraints(stakingBalance) InnerYieldBalanceState.Data( - integrationId = stakeKitBalance?.stakingId?.integrationId, + integrationId = stakingBalance?.stakingId?.integrationId, reward = YieldReward( rewardsCrypto = cryptoRewardsValue.format { crypto(cryptoCurrency) }, rewardsFiat = fiatRewardsValue.format { @@ -68,24 +65,35 @@ internal class YieldBalancesConverter( rewardConstraints = pendingRewardsConstraints, ), isActionable = type.isActionable, - balances = balanceToShowItems.mapBalances(), + balances = balanceEntries.mapBalances(), ) } else { - // TODO p2p InnerYieldBalanceState.Empty } } - private fun List.mapBalances() = asSequence() - .filterNot { it.amount.isZero() || it.type == BalanceType.REWARDS } - .mapNotNull(balanceItemConverter::convert) + private fun List.mapBalances() = asSequence() + .filterNot { it.amount.isZero() || it.type == StakingEntryType.REWARDS } + .mapNotNull(balanceEntryConverter::convert) .sortedByDescending { it.cryptoAmount } .sortedBy { it.type.order } .toPersistentList() - private fun getRewardBlockType(): RewardBlockType { + private fun getRewardBlockType(stakingBalance: StakingBalance?): RewardBlockType { val blockchainId = cryptoCurrencyStatus.currency.network.rawId - val stakeKitBalance = cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit + val isCoin = cryptoCurrencyStatus.currency.id.isCoin + + val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool + if (p2pEthPoolBalance != null) { + val hasEarnedRewards = !p2pEthPoolBalance.totalRewards.isZero() + return if (hasEarnedRewards) { + RewardBlockType.EthereumEarnedRewards + } else { + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + } + } + + val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit val rewards = stakeKitBalance?.balance?.items ?.filter { it.type == BalanceType.REWARDS && !it.amount.isZero() } @@ -93,7 +101,7 @@ internal class YieldBalancesConverter( val isRewardsClaimable = rewards?.isNotEmpty() == true return when { - isStakingRewardUnavailable(blockchainId) -> { + isStakingRewardUnavailable(blockchainId, isCoin) -> { if (BlockchainUtils.isSolana(blockchainId)) { RewardBlockType.RewardUnavailable.SolanaRewardUnavailable } else { @@ -105,4 +113,11 @@ internal class YieldBalancesConverter( else -> RewardBlockType.NoRewards } } + + private fun getRewardConstraints(stakingBalance: StakingBalance?) = + (stakingBalance as? StakingBalance.Data.StakeKit) + ?.balance?.items + ?.firstOrNull { it.type == BalanceType.REWARDS } + ?.pendingActionsConstraints + ?.firstOrNull { it.type == StakingActionType.CLAIM_REWARDS } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt index af6931c29f..3e757a6adc 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt @@ -74,4 +74,12 @@ internal sealed class StakingAlertUM : AlertUM { ) override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok) } + + data class NetworkFeeUpdated( + override val onConfirmClick: () -> Unit, + ) : StakingAlertUM() { + override val title: TextReference = resourceReference(R.string.staking_alert_network_fee_updated_title) + override val message: TextReference = resourceReference(R.string.staking_alert_network_fee_updated_message) + override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok) + } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt index 465bd1c024..4ba7fb1dd9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt @@ -57,4 +57,13 @@ internal class StakingEventFactory( ), ) } + + fun createNetworkFeeUpdatedAlert(onConfirm: () -> Unit) { + val alert = StakingEvent.ShowAlert( + alert = StakingAlertUM.NetworkFeeUpdated( + onConfirmClick = onConfirm, + ), + ) + stateController.updateEvent(alert) + } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolFeeLoader.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolFeeLoader.kt new file mode 100644 index 0000000000..0f728d1ce5 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolFeeLoader.kt @@ -0,0 +1,55 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.staking.model.P2PEthPoolIntegration +import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.transaction.error.GetFeeError +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class P2PEthPoolFeeLoader @AssistedInject constructor( + private val transactionCreator: P2PEthPoolTransactionCreator, + @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, + @Assisted private val integration: P2PEthPoolIntegration, +) : StakingFeeLoader { + + override suspend fun getFee( + onStakingFee: (Fee, Boolean) -> Unit, + onStakingFeeError: (StakingError) -> Unit, + onApprovalFee: (TransactionFee) -> Unit, + onFeeError: (GetFeeError) -> Unit, + ) { + transactionCreator.createTransaction(cryptoCurrencyStatus).fold( + ifLeft = onStakingFeeError, + ifRight = { unsignedTx -> + val fee = convertUnsignedTxToFee(unsignedTx) + onStakingFee(fee, false) + }, + ) + } + + private fun convertUnsignedTxToFee(unsignedTx: P2PEthPoolUnsignedTx): Fee { + val blockchain = integration.integrationId.blockchain + val decimals = blockchain.decimals() + val feeInWei = unsignedTx.gasLimit * unsignedTx.maxFeePerGas + val feeValue = feeInWei.movePointLeft(decimals) + return Fee.Common( + Amount( + currencySymbol = blockchain.currency, + value = feeValue, + decimals = decimals, + ), + ) + } + + @AssistedFactory + interface Factory { + + fun create(cryptoCurrencyStatus: CryptoCurrencyStatus, integration: P2PEthPoolIntegration): P2PEthPoolFeeLoader + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt new file mode 100644 index 0000000000..09ee8a1b5c --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt @@ -0,0 +1,102 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import arrow.core.Either +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig +import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.features.staking.impl.presentation.state.StakingStateController +import com.tangem.features.staking.impl.presentation.state.StakingStates +import java.math.BigDecimal +import javax.inject.Inject + +@ModelScoped +internal class P2PEthPoolTransactionCreator @Inject constructor( + private val stateController: StakingStateController, + private val p2pEthPoolRepository: P2PEthPoolRepository, +) { + + suspend fun createTransaction( + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): Either { + val params = extractParams(cryptoCurrencyStatus) + ?: return Either.Left(StakingError.DomainError("Invalid state for transaction creation")) + + return createTransaction( + actionType = params.actionType, + amount = params.amount, + vaultAddress = params.vaultAddress, + sourceAddress = params.sourceAddress, + ) + } + + fun extractParams(cryptoCurrencyStatus: CryptoCurrencyStatus): TransactionParams? { + val state = stateController.value + val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data + ?: return null + + val sourceAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value + ?: return null + + val vaultAddress = (state.validatorState as? StakingStates.ValidatorState.Data)?.chosenTarget?.address + ?: state.balanceState?.targetAddress + ?: return null + + val amount = (state.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value + ?: return null + + return TransactionParams( + actionType = state.actionType, + amount = amount, + vaultAddress = vaultAddress, + sourceAddress = sourceAddress, + ) + } + + private suspend fun createTransaction( + actionType: StakingActionCommonType, + amount: BigDecimal, + vaultAddress: String, + sourceAddress: String, + ): Either { + val network = P2PEthPoolStakingConfig.activeNetwork + + return when (actionType) { + is StakingActionCommonType.Enter -> { + p2pEthPoolRepository.createDepositTransaction( + network = network, + delegatorAddress = sourceAddress, + vaultAddress = vaultAddress, + amount = amount.toPlainString(), + ) + } + is StakingActionCommonType.Exit -> { + p2pEthPoolRepository.createUnstakeTransaction( + network = network, + delegatorAddress = sourceAddress, + vaultAddress = vaultAddress, + amount = amount.toPlainString(), + ) + } + is StakingActionCommonType.Pending -> { + p2pEthPoolRepository.createWithdrawTransaction( + network = network, + delegatorAddress = sourceAddress, + vaultAddress = vaultAddress, + amount = amount.toPlainString(), + ) + } + } + } + + data class TransactionParams( + val actionType: StakingActionCommonType, + val amount: BigDecimal, + val vaultAddress: String, + val sourceAddress: String, + ) +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionSender.kt new file mode 100644 index 0000000000..672f9ea61c --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionSender.kt @@ -0,0 +1,128 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import arrow.core.getOrElse +import com.squareup.moshi.Moshi +import com.tangem.blockchain.blockchains.ethereum.models.EthereumCompiledTransaction +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.extensions.formatHex +import com.tangem.common.extensions.toHexString +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.staking.model.P2PEthPoolIntegration +import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig +import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.transaction.usecase.PrepareForSendUseCase +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import java.math.BigInteger + +@Suppress("LongParameterList") +internal class P2PEthPoolTransactionSender @AssistedInject constructor( + private val transactionCreator: P2PEthPoolTransactionCreator, + private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, + private val prepareForSendUseCase: PrepareForSendUseCase, + private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, + private val p2pEthPoolRepository: P2PEthPoolRepository, + @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, + @Assisted private val userWallet: UserWallet, + @Assisted private val integration: P2PEthPoolIntegration, +) : StakingTransactionSender { + + private val balanceUpdater: StakingBalanceUpdater + get() = stakingBalanceUpdater.create(cryptoCurrencyStatus, userWallet, integration) + + override suspend fun send(callbacks: StakingTransactionSender.Callbacks) { + val params = transactionCreator.extractParams(cryptoCurrencyStatus) + ?: run { + callbacks.onConstructError( + StakingError.DomainError("Invalid state for transaction"), + ) + return + } + + val unsignedTx = transactionCreator.createTransaction(cryptoCurrencyStatus).getOrElse { error -> + callbacks.onConstructError(error) + return + } + + val compiledTxJson = createCompiledTransactionJson(unsignedTx, params.sourceAddress) + + val transactionData = TransactionData.Compiled( + value = TransactionData.Compiled.Data.RawString(compiledTxJson), + ) + + val signedTxBytes = prepareForSendUseCase( + transactionData = transactionData, + userWallet = userWallet, + network = cryptoCurrencyStatus.currency.network, + ).getOrElse { error -> + callbacks.onSendError(error) + return + } + + val signedTxHex = signedTxBytes.toHexString().lowercase().formatHex() + + p2pEthPoolRepository.broadcastTransaction( + network = P2PEthPoolStakingConfig.activeNetwork, + signedTransaction = signedTxHex, + ).fold( + ifLeft = { error -> + callbacks.onConstructError(error) + }, + ifRight = { broadcastResult -> + val txUrl = getExplorerTransactionUrlUseCase( + txHash = broadcastResult.hash, + networkId = cryptoCurrencyStatus.currency.network.id, + ).getOrNull().orEmpty() + + balanceUpdater.updateAfterTransaction() + callbacks.onSendSuccess(txUrl) + }, + ) + } + + private fun createCompiledTransactionJson(unsignedTx: P2PEthPoolUnsignedTx, fromAddress: String): String { + val compiledTx = EthereumCompiledTransaction( + from = fromAddress, + to = unsignedTx.to, + data = unsignedTx.data, + value = unsignedTx.value.toBigInteger().toHexString(), + nonce = unsignedTx.nonce, + chainId = unsignedTx.chainId, + gasLimit = unsignedTx.gasLimit.toBigInteger().toHexString(), + gasPrice = null, // EIP-1559: gasPrice is null + maxFeePerGas = unsignedTx.maxFeePerGas.toBigInteger().toHexString(), + maxPriorityFeePerGas = unsignedTx.maxPriorityFeePerGas.toBigInteger().toHexString(), + type = EIP_1559_TX_TYPE, + ) + return ethereumCompiledTxAdapter.toJson(compiledTx) + } + + private fun BigInteger.toHexString(): String { + val hex = toString(HEX_RADIX) + val paddedHex = if (hex.length % 2 != 0) "0$hex" else hex + return HEX_PREFIX + paddedHex + } + + @AssistedFactory + interface Factory { + fun create( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWallet: UserWallet, + integration: P2PEthPoolIntegration, + ): P2PEthPoolTransactionSender + } + + private companion object { + const val HEX_PREFIX = "0x" + const val HEX_RADIX = 16 + const val EIP_1559_TX_TYPE = 2 + + val ethereumCompiledTxAdapter: com.squareup.moshi.JsonAdapter = + Moshi.Builder().build().adapter(EthereumCompiledTransaction::class.java) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitFeeLoader.kt similarity index 61% rename from features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt rename to features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitFeeLoader.kt index 05373bb1f8..f41f4e4772 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitFeeLoader.kt @@ -12,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.StakeKitIntegration 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 @@ -37,7 +36,7 @@ import kotlinx.coroutines.delay import java.math.BigDecimal @Suppress("LongParameterList") -internal class StakingFeeTransactionLoader @AssistedInject constructor( +internal class StakeKitFeeLoader @AssistedInject constructor( private val stateController: StakingStateController, private val getFeeUseCase: GetFeeUseCase, private val estimateGasUseCase: EstimateGasUseCase, @@ -45,10 +44,10 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, @Assisted private val userWallet: UserWallet, - @Assisted private val yield: Yield, -) { + @Assisted private val integration: StakeKitIntegration, +) : StakingFeeLoader { - suspend fun getFee( + override suspend fun getFee( onStakingFee: (Fee, Boolean) -> Unit, onStakingFeeError: (StakingError) -> Unit, onApprovalFee: (TransactionFee) -> Unit, @@ -58,21 +57,41 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data ?: error("Illegal state") - val validatorAddress = (state.validatorState as? StakingStates.ValidatorState.Data)?.chosenValidator?.address - ?: state.balanceState?.validatorAddress - ?: error("No validator address provided") + val targetAddress = (state.validatorState as? StakingStates.ValidatorState.Data)?.chosenTarget?.address + ?: state.balanceState?.targetAddress + ?: error("No target address provided") val amount = (state.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value ?: error("No amount provided") - val pendingAction = confirmationState.pendingAction - val pendingActions = confirmationState.pendingActions + getStakeKitFee( + confirmationState = confirmationState, + actionType = state.actionType, + amount = amount, + validatorAddress = targetAddress, + onStakingFee = onStakingFee, + onStakingFeeError = onStakingFeeError, + onApprovalFee = onApprovalFee, + onFeeError = onFeeError, + ) + } - val isEnter = state.actionType is StakingActionCommonType.Enter + private suspend fun getStakeKitFee( + confirmationState: StakingStates.ConfirmationState.Data, + actionType: StakingActionCommonType, + amount: BigDecimal, + validatorAddress: String, + onStakingFee: (Fee, Boolean) -> Unit, + onStakingFeeError: (StakingError) -> Unit, + onApprovalFee: (TransactionFee) -> Unit, + onFeeError: (GetFeeError) -> Unit, + ) { + val isEnter = actionType is StakingActionCommonType.Enter val isApprovalNeeded = confirmationState.isApprovalNeeded val isAllowanceNotEnough = confirmationState.allowance < amount + if (isEnter && isApprovalNeeded && isAllowanceNotEnough) { - getApproveFee( + getApprovalFee( amount = amount, validatorAddress = validatorAddress, onApprovalFee = onApprovalFee, @@ -80,8 +99,8 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( ) } else { estimateGas( - pendingAction = pendingAction, - pendingActions = pendingActions, + pendingAction = confirmationState.pendingAction, + pendingActions = confirmationState.pendingActions, amount = amount, validatorAddress = validatorAddress, onStakingFeeError = onStakingFeeError, @@ -106,61 +125,80 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( pendingActions = pendingActions, ) ) { - val result = coroutineScope { - pendingActions?.map { action -> - async { - // Simultaneous or quick api calls can sometimes return ZERO fee - estimateFeeRetry { - estimateFee( - amount = amount, - sourceAddress = sourceAddress, - validatorAddress = validatorAddress, - action = action, - ) - }.getOrElse { - onStakingFeeError(it) - null - } - } - }?.awaitAll()?.filterNotNull() - } - - if (result.isNullOrEmpty()) { - onStakingFeeError(StakingError.DomainError("Error estimating fee")) - return - } - - val totalAmount = result.sumOf { it.amount } - val totalGasLimit = result.sumOf { it.gasLimit?.toBigDecimalOrNull().orZero() } - StakingGasEstimate( - amount = totalAmount, - token = result.first().token, - gasLimit = totalGasLimit.toPlainString().orEmpty(), - ) + estimateCompositeGas( + pendingActions = pendingActions, + amount = amount, + sourceAddress = sourceAddress, + validatorAddress = validatorAddress, + onStakingFeeError = onStakingFeeError, + ) ?: return } else { - estimateFee( + estimateSingleGas( amount = amount, sourceAddress = sourceAddress, validatorAddress = validatorAddress, action = pendingAction, - ).getOrElse { - onStakingFeeError(it) + ).getOrElse { error -> + onStakingFeeError(error) return } } - val amount = Amount( + val feeAmount = Amount( currencySymbol = gasEstimate.token.symbol, value = gasEstimate.amount, decimals = gasEstimate.token.decimals, ) onStakingFee( - Fee.Common(amount), - isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), + Fee.Common(feeAmount), + isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = feeAmount.type), ) } - private suspend fun estimateFee( + /** + * Estimates gas for several staking transactions. + */ + private suspend fun estimateCompositeGas( + pendingActions: ImmutableList?, + amount: BigDecimal, + sourceAddress: String, + validatorAddress: String, + onStakingFeeError: (StakingError) -> Unit, + ): StakingGasEstimate? { + val result = coroutineScope { + pendingActions?.map { action -> + async { + // Simultaneous or quick API calls can sometimes return ZERO fee + estimateGasRetry { + estimateSingleGas( + amount = amount, + sourceAddress = sourceAddress, + validatorAddress = validatorAddress, + action = action, + ) + }.getOrElse { gasError -> + onStakingFeeError(gasError) + null + } + } + }?.awaitAll()?.filterNotNull() + } + + if (result.isNullOrEmpty()) { + onStakingFeeError(StakingError.DomainError("Error estimating fee")) + return null + } + + val totalAmount = result.sumOf { it.amount } + val totalGasLimit = result.sumOf { it.gasLimit?.toBigDecimalOrNull().orZero() } + return StakingGasEstimate( + amount = totalAmount, + token = result.first().token, + gasLimit = totalGasLimit.toPlainString().orEmpty(), + ) + } + + private suspend fun estimateSingleGas( amount: BigDecimal, sourceAddress: String, validatorAddress: String, @@ -170,17 +208,17 @@ 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, ), ) - private suspend fun getApproveFee( + private suspend fun getApprovalFee( amount: BigDecimal, validatorAddress: String, onApprovalFee: (TransactionFee) -> Unit, @@ -188,6 +226,7 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( ) { val tokenCurrency = cryptoCurrencyStatus.currency as? CryptoCurrency.Token ?: return onApprovalFeeError(GetFeeError.UnknownError) + val approvalTransactionData = createApprovalTransactionUseCase( cryptoCurrencyStatus = cryptoCurrencyStatus, userWalletId = userWallet.walletId, @@ -197,34 +236,29 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( ).getOrElse { return onApprovalFeeError(GetFeeError.DataError(it)) } + getFeeUseCase( userWallet = userWallet, network = tokenCurrency.network, transactionData = approvalTransactionData, ).fold( - ifRight = { fee -> - onApprovalFee(fee) - }, - ifLeft = { error -> - onApprovalFeeError(error) - }, + ifRight = onApprovalFee, + ifLeft = onApprovalFeeError, ) } - private suspend fun estimateFeeRetry( - times: Int = 3, - delay: Long = 1000, + private suspend fun estimateGasRetry( + times: Int = RETRY_COUNT, + delayMs: Long = RETRY_DELAY_MS, block: suspend () -> Either, ): Either { repeat(times - 1) { val feeResult = block() feeResult.fold( ifLeft = { return feeResult }, - ifRight = { - if (!it.amount.isZero()) return feeResult - }, + ifRight = { estimate -> if (!estimate.amount.isZero()) return feeResult }, ) - delay(delay) + delay(delayMs) } return block() } @@ -234,7 +268,12 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( fun create( cryptoCurrencyStatus: CryptoCurrencyStatus, userWallet: UserWallet, - yield: Yield, - ): StakingFeeTransactionLoader + integration: StakeKitIntegration, + ): StakeKitFeeLoader + } + + private companion object { + const val RETRY_COUNT = 3 + const val RETRY_DELAY_MS = 1000L } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt new file mode 100644 index 0000000000..f1b004afe8 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt @@ -0,0 +1,322 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import arrow.core.getOrElse +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionSender +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.staking.NetworkType +import com.tangem.domain.models.staking.PendingAction +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.staking.GetConstructedStakingTransactionUseCase +import com.tangem.domain.staking.GetStakingTransactionsUseCase +import com.tangem.domain.staking.SaveUnsubmittedHashUseCase +import com.tangem.domain.staking.SubmitHashUseCase +import com.tangem.domain.staking.model.StakeKitIntegration +import com.tangem.domain.staking.model.SubmitHashData +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.staking.model.stakekit.transaction.ActionParams +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionStatus +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionType +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.features.staking.impl.presentation.state.FeeState +import com.tangem.features.staking.impl.presentation.state.StakingStateController +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.utils.checkAndCalculateSubtractedAmount +import com.tangem.features.staking.impl.presentation.state.utils.isCompositePendingActions +import com.tangem.utils.extensions.orZero +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import timber.log.Timber +import java.math.BigDecimal + +@Suppress("LongParameterList") +internal class StakeKitTransactionSender @AssistedInject constructor( + private val stateController: StakingStateController, + private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, + private val getStakingTransactionsUseCase: GetStakingTransactionsUseCase, + private val getConstructedStakingTransactionUseCase: GetConstructedStakingTransactionUseCase, + private val sendTransactionUseCase: SendTransactionUseCase, + private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, + private val submitHashUseCase: SubmitHashUseCase, + private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase, + private val isFeeApproximateUseCase: IsFeeApproximateUseCase, + @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, + @Assisted private val userWallet: UserWallet, + @Assisted private val integration: StakeKitIntegration, + @Assisted private val isAmountSubtractAvailable: Boolean, +) : StakingTransactionSender { + + private val balanceUpdater: StakingBalanceUpdater + get() = stakingBalanceUpdater.create(cryptoCurrencyStatus, userWallet, integration) + + override suspend fun send(callbacks: StakingTransactionSender.Callbacks) { + constructAndSendTransactions( + onConstructSuccess = callbacks.onConstructSuccess, + onConstructError = callbacks.onConstructError, + onSendSuccess = callbacks.onSendSuccess, + onSendError = callbacks.onSendError, + onFeeIncreased = callbacks.onFeeIncreased, + ) + } + + private suspend fun constructAndSendTransactions( + onConstructSuccess: (List) -> Unit, + onConstructError: (StakingError) -> Unit, + onSendSuccess: (String) -> Unit, + onSendError: (SendTransactionError) -> Unit, + onFeeIncreased: (Fee, Boolean) -> Unit, + ) { + val state = stateController.value + + val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data + ?: error("No confirmation state") + val fee = (confirmationState.feeState as? FeeState.Content)?.fee + ?: error("No fee provided") + val amountState = state.amountState as? AmountState.Data ?: error("No amount state") + + val stakingTransactions = getStakingTransactions( + state = state, + confirmationState = confirmationState, + onConstructError = onConstructError, + ) + + val fullTransactionsData = getConstructedTransactions( + stakingTransactions = stakingTransactions, + fee = fee, + amount = amountState.amountTextField.cryptoAmount.value.orZero(), + onConstructError = onConstructError, + ) + + if (fullTransactionsData.isNullOrEmpty()) { + onConstructError(StakingError.DomainError("fullTransactionsData is null or empty")) + return + } + + val totalFee = fullTransactionsData.sumOf { it.stakeKitTransaction.gasEstimate?.amount.orZero() } + + if (fee.amount.value.orZero() >= totalFee) { + onConstructSuccess(fullTransactionsData.map { it.stakeKitTransaction }) + sendTransaction( + fullTransactionsData = fullTransactionsData, + onSendSuccess = onSendSuccess, + onSendError = onSendError, + ) + } else { + val amount = fee.amount.copy(value = totalFee) + onFeeIncreased( + Fee.Common(amount), + isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), + ) + } + } + + private suspend fun getStakingTransactions( + state: StakingUiState, + confirmationState: StakingStates.ConfirmationState.Data, + onConstructError: (StakingError) -> Unit, + ) = coroutineScope { + val isComposePendingActions = isCompositePendingActions( + networkId = cryptoCurrencyStatus.currency.network.rawId, + pendingActions = confirmationState.pendingActions, + ) + if (isComposePendingActions) { + confirmationState.pendingActions?.map { action -> + async { + getStakingTransaction( + state = state, + action = action, + confirmationState = confirmationState, + onConstructError = onConstructError, + ) + } + }?.awaitAll()?.flatten() + } else { + getStakingTransaction( + state = state, + confirmationState = confirmationState, + onConstructError = onConstructError, + ) + } + } + + private suspend fun getConstructedTransactions( + stakingTransactions: List?, + fee: Fee, + amount: BigDecimal, + onConstructError: (StakingError) -> Unit, + ) = coroutineScope { + stakingTransactions + ?.filterNot { + it.type == StakingTransactionType.APPROVAL || it.status == StakingTransactionStatus.SKIPPED + } + ?.map { transaction -> + async { + getConstructedStakingTransactionUseCase( + networkId = cryptoCurrencyStatus.currency.network.rawId, + fee = fee, + amount = amount.convertToSdkAmount(cryptoCurrencyStatus), + transactionId = transaction.id, + ).fold( + ifRight = { (constructedTransaction, transactionData) -> + FullTransactionData( + stakeKitTransaction = constructedTransaction, + tangemTransaction = transactionData, + ) + }, + ifLeft = { error -> + onConstructError(error) + null + }, + ) + } + } + ?.awaitAll() + ?.filterNotNull() + } + + private suspend fun getStakingTransaction( + state: StakingUiState, + confirmationState: StakingStates.ConfirmationState.Data, + action: PendingAction? = confirmationState.pendingAction, + onConstructError: (StakingError) -> Unit, + ): List { + val validatorState = state.validatorState as? StakingStates.ValidatorState.Data + ?: error("No validator provided") + val fee = (confirmationState.feeState as? FeeState.Content)?.fee + ?: error("No fee provided") + val defaultAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value + ?: error("No available address") + val amountState = state.amountState as? AmountState.Data + ?: error("No amount provided") + + val validatorAddress = validatorState.chosenTarget.address + val amount = getAmount(amountState, fee, confirmationState.reduceAmountBy) + + return getStakingTransactionsUseCase( + userWalletId = userWallet.walletId, + network = cryptoCurrencyStatus.currency.network, + params = ActionParams( + actionCommonType = state.actionType, + integrationId = integration.integrationId.value, + amount = amount, + address = defaultAddress, + validatorAddress = validatorAddress, + token = integration.getCurrentToken(cryptoCurrencyStatus.currency.id.rawCurrencyId), + passthrough = action?.passthrough, + type = action?.type, + ), + ).getOrElse { error -> + onConstructError(error) + return emptyList() + } + } + + private suspend fun sendTransaction( + fullTransactionsData: List, + onSendSuccess: (txUrl: String) -> Unit, + onSendError: (SendTransactionError) -> Unit, + ) { + if (fullTransactionsData.isEmpty()) return + + val sortedTransactions = fullTransactionsData.sortedBy { it.stakeKitTransaction.stepIndex } + + val firstTransaction = sortedTransactions.first() + val network = firstTransaction.stakeKitTransaction.network + + val sendMode = if (network == NetworkType.SOLANA && + firstTransaction.stakeKitTransaction.type == StakingTransactionType.SPLIT + ) { + TransactionSender.MultipleTransactionSendMode.WAIT_AFTER_FIRST + } else { + TransactionSender.MultipleTransactionSendMode.DEFAULT + } + + sendTransactionUseCase( + txsData = sortedTransactions.map { it.tangemTransaction }, + userWallet = userWallet, + network = cryptoCurrencyStatus.currency.network, + sendMode = sendMode, + ).fold( + ifLeft = { error -> + onSendError(error) + }, + ifRight = { transactionHashes -> + submitHash( + transactions = sortedTransactions.map { it.stakeKitTransaction }, + transactionHashes = transactionHashes, + ) + + val txUrl = getExplorerTransactionUrlUseCase( + txHash = transactionHashes.last(), + networkId = cryptoCurrencyStatus.currency.network.id, + ).getOrNull().orEmpty() + + balanceUpdater.updateAfterTransaction() + onSendSuccess(txUrl) + }, + ) + } + + private suspend fun submitHash(transactions: List, transactionHashes: List) { + transactions + .zip(transactionHashes) + .forEach { (transaction, transactionHash) -> + submitHashUseCase( + SubmitHashData( + transactionId = transaction.id, + transactionHash = transactionHash, + ), + ) + .onLeft { + saveUnsubmittedHashUseCase.invoke( + transactionId = transaction.id, + transactionHash = transactionHash, + ) + }.onRight { + Timber.d("Successful hash submission") + } + } + } + + private fun getAmount(amountState: AmountState.Data, fee: Fee, reduceAmountBy: BigDecimal?): BigDecimal { + val amountValue = amountState.amountTextField.cryptoAmount.value ?: error("No amount value") + val feeValue = fee.amount.value ?: error("No fee value") + val isEnterAction = stateController.value.actionType is StakingActionCommonType.Enter + + return checkAndCalculateSubtractedAmount( + isAmountSubtractAvailable = isAmountSubtractAvailable && isEnterAction, + cryptoCurrencyStatus = cryptoCurrencyStatus, + amountValue = amountValue, + feeValue = feeValue, + reduceAmountBy = reduceAmountBy.orZero(), + ) + } + + private data class FullTransactionData( + val stakeKitTransaction: StakingTransaction, + val tangemTransaction: TransactionData.Compiled, + ) + + @AssistedFactory + interface Factory { + fun create( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWallet: UserWallet, + integration: StakeKitIntegration, + isAmountSubtractAvailable: Boolean, + ): StakeKitTransactionSender + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/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/StakingFeeLoader.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeLoader.kt new file mode 100644 index 0000000000..f4e8d449bb --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeLoader.kt @@ -0,0 +1,16 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.transaction.error.GetFeeError + +internal interface StakingFeeLoader { + + suspend fun getFee( + onStakingFee: (Fee, Boolean) -> Unit, + onStakingFeeError: (StakingError) -> Unit, + onApprovalFee: (TransactionFee) -> Unit, + onFeeError: (GetFeeError) -> Unit, + ) +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingOperationsFactory.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingOperationsFactory.kt new file mode 100644 index 0000000000..b1da82c9c5 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingOperationsFactory.kt @@ -0,0 +1,55 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.staking.model.P2PEthPoolIntegration +import com.tangem.domain.staking.model.StakeKitIntegration +import com.tangem.domain.staking.model.StakingIntegration +import javax.inject.Inject + +internal class StakingOperationsFactory @Inject constructor( + private val stakeKitFeeLoaderFactory: StakeKitFeeLoader.Factory, + private val p2pEthPoolFeeLoaderFactory: P2PEthPoolFeeLoader.Factory, + private val stakeKitTransactionSenderFactory: StakeKitTransactionSender.Factory, + private val p2pEthPoolTransactionSenderFactory: P2PEthPoolTransactionSender.Factory, +) { + + fun createFeeLoader( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWallet: UserWallet, + integration: StakingIntegration, + ): StakingFeeLoader { + return when (integration) { + is StakeKitIntegration -> stakeKitFeeLoaderFactory.create( + cryptoCurrencyStatus = cryptoCurrencyStatus, + userWallet = userWallet, + integration = integration, + ) + is P2PEthPoolIntegration -> p2pEthPoolFeeLoaderFactory.create( + cryptoCurrencyStatus = cryptoCurrencyStatus, + integration = integration, + ) + } + } + + fun createTransactionSender( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWallet: UserWallet, + integration: StakingIntegration, + isAmountSubtractAvailable: Boolean, + ): StakingTransactionSender { + return when (integration) { + is StakeKitIntegration -> stakeKitTransactionSenderFactory.create( + cryptoCurrencyStatus = cryptoCurrencyStatus, + userWallet = userWallet, + integration = integration, + isAmountSubtractAvailable = isAmountSubtractAvailable, + ) + is P2PEthPoolIntegration -> p2pEthPoolTransactionSenderFactory.create( + cryptoCurrencyStatus = cryptoCurrencyStatus, + userWallet = userWallet, + integration = integration, + ) + } + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt index ef5030f334..968bf1c310 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt @@ -1,313 +1,20 @@ package com.tangem.features.staking.impl.presentation.state.helpers -import arrow.core.getOrElse -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.TransactionSender import com.tangem.blockchain.common.transaction.Fee -import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.staking.NetworkType -import com.tangem.domain.models.staking.PendingAction -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.staking.GetConstructedStakingTransactionUseCase -import com.tangem.domain.staking.GetStakingTransactionsUseCase -import com.tangem.domain.staking.SaveUnsubmittedHashUseCase -import com.tangem.domain.staking.SubmitHashUseCase -import com.tangem.domain.staking.model.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 -import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase -import com.tangem.domain.utils.convertToSdkAmount -import com.tangem.features.staking.impl.presentation.state.FeeState -import com.tangem.features.staking.impl.presentation.state.StakingStateController -import com.tangem.features.staking.impl.presentation.state.StakingStates -import com.tangem.features.staking.impl.presentation.state.StakingUiState -import com.tangem.features.staking.impl.presentation.state.utils.checkAndCalculateSubtractedAmount -import com.tangem.features.staking.impl.presentation.state.utils.isCompositePendingActions -import com.tangem.utils.extensions.orZero -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import timber.log.Timber -import java.math.BigDecimal -@Suppress("LongParameterList") -internal class StakingTransactionSender @AssistedInject constructor( - private val stateController: StakingStateController, - private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, - private val getStakingTransactionsUseCase: GetStakingTransactionsUseCase, - private val getConstructedStakingTransactionUseCase: GetConstructedStakingTransactionUseCase, - private val sendTransactionUseCase: SendTransactionUseCase, - private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, - private val submitHashUseCase: SubmitHashUseCase, - private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase, - private val isFeeApproximateUseCase: IsFeeApproximateUseCase, - @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, - @Assisted private val userWallet: UserWallet, - @Assisted private val yield: Yield, - @Assisted private val isAmountSubtractAvailable: Boolean, -) { +internal interface StakingTransactionSender { - private val balanceUpdater: StakingBalanceUpdater - get() = stakingBalanceUpdater.create(cryptoCurrencyStatus, userWallet, yield) + suspend fun send(callbacks: Callbacks) - suspend fun constructAndSendTransactions( - onConstructSuccess: (List) -> Unit, - onConstructError: (StakingError) -> Unit, - onSendSuccess: (String) -> Unit, - onSendError: (SendTransactionError) -> Unit, - onFeeIncreased: (Fee, Boolean) -> Unit, - ) { - val state = stateController.value - - val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data - ?: error("No confirmation state") - val fee = (confirmationState.feeState as? FeeState.Content)?.fee - ?: error("No fee provided") - val amountState = state.amountState as? AmountState.Data ?: error("No amount state") - - val stakingTransactions = getStakingTransactions( - state = state, - confirmationState = confirmationState, - onConstructError = onConstructError, - ) - - val fullTransactionsData = getConstructedTransactions( - stakingTransactions = stakingTransactions, - fee = fee, - amount = amountState.amountTextField.cryptoAmount.value.orZero(), - onConstructError = onConstructError, - ) - - if (fullTransactionsData.isNullOrEmpty()) { - onConstructError(StakingError.DomainError("fullTransactionsData is null or empty")) - return - } - - val totalFee = fullTransactionsData.sumOf { it.stakeKitTransaction.gasEstimate?.amount.orZero() } - - if (fee.amount.value.orZero() >= totalFee) { - onConstructSuccess(fullTransactionsData.map { it.stakeKitTransaction }) - sendStakingTransaction( - fullTransactionsData = fullTransactionsData, - onSendSuccess = onSendSuccess, - onSendError = onSendError, - ) - } else { - val amount = fee.amount.copy(value = totalFee) - onFeeIncreased( - Fee.Common(amount), - isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), - ) - } - } - - private suspend fun getStakingTransactions( - state: StakingUiState, - confirmationState: StakingStates.ConfirmationState.Data, - onConstructError: (StakingError) -> Unit, - ) = coroutineScope { - val isComposePendingActions = isCompositePendingActions( - networkId = cryptoCurrencyStatus.currency.network.rawId, - pendingActions = confirmationState.pendingActions, - ) - if (isComposePendingActions) { - confirmationState.pendingActions?.map { action -> - async { - getStakingTransaction( - state = state, - action = action, - confirmationState = confirmationState, - onConstructError = onConstructError, - ) - } - }?.awaitAll()?.flatten() - } else { - getStakingTransaction( - state = state, - confirmationState = confirmationState, - onConstructError = onConstructError, - ) - } - } - - private suspend fun getConstructedTransactions( - stakingTransactions: List?, - fee: Fee, - amount: BigDecimal, - onConstructError: (StakingError) -> Unit, - ) = coroutineScope { - stakingTransactions - ?.filterNot { - it.type == StakingTransactionType.APPROVAL || it.status == StakingTransactionStatus.SKIPPED - } - ?.map { transaction -> - async { - getConstructedStakingTransactionUseCase( - networkId = cryptoCurrencyStatus.currency.network.rawId, - fee = fee, - amount = amount.convertToSdkAmount(cryptoCurrencyStatus), - transactionId = transaction.id, - ).fold( - ifRight = { (constructedTransaction, transactionData) -> - FullTransactionData( - stakeKitTransaction = constructedTransaction, - tangemTransaction = transactionData, - ) - }, - ifLeft = { - onConstructError(it) - null - }, - ) - } - } - ?.awaitAll() - ?.filterNotNull() - } - - private suspend fun getStakingTransaction( - state: StakingUiState, - confirmationState: StakingStates.ConfirmationState.Data, - action: PendingAction? = confirmationState.pendingAction, - onConstructError: (StakingError) -> Unit, - ): List { - val validatorState = state.validatorState as? StakingStates.ValidatorState.Data - ?: error("No validator provided") - val fee = (confirmationState.feeState as? FeeState.Content)?.fee - ?: error("No fee provided") - val defaultAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value - ?: error("No available address") - val amountState = state.amountState as? AmountState.Data - ?: error("No amount provided") - - val validatorAddress = validatorState.chosenValidator.address - val amount = getAmount(amountState, fee, confirmationState.reduceAmountBy) - - return getStakingTransactionsUseCase( - userWalletId = userWallet.walletId, - network = cryptoCurrencyStatus.currency.network, - params = ActionParams( - actionCommonType = state.actionType, - integrationId = yield.id, - amount = amount, - address = defaultAddress, - validatorAddress = validatorAddress, - token = yield.getCurrentToken(cryptoCurrencyStatus.currency.id.rawCurrencyId), - passthrough = action?.passthrough, - type = action?.type, - ), - ).getOrElse { - onConstructError(it) - return emptyList() - } - } - - private suspend fun sendStakingTransaction( - fullTransactionsData: List, - onSendSuccess: (txUrl: String) -> Unit, - onSendError: (SendTransactionError) -> Unit, - ) { - if (fullTransactionsData.isEmpty()) return - - val sortedTransactions = fullTransactionsData.sortedBy { it.stakeKitTransaction.stepIndex } - - val firstTransaction = sortedTransactions.first() - val network = firstTransaction.stakeKitTransaction.network - - val sendMode = if (network == NetworkType.SOLANA && - firstTransaction.stakeKitTransaction.type == StakingTransactionType.SPLIT - ) { - TransactionSender.MultipleTransactionSendMode.WAIT_AFTER_FIRST - } else { - TransactionSender.MultipleTransactionSendMode.DEFAULT - } - - sendTransactionUseCase( - txsData = sortedTransactions.map { it.tangemTransaction }, - userWallet = userWallet, - network = cryptoCurrencyStatus.currency.network, - sendMode = sendMode, - ).fold( - ifLeft = { error -> - onSendError(error) - }, - ifRight = { transactionHashes -> - submitHash( - transactions = sortedTransactions.map { it.stakeKitTransaction }, - transactionHashes = transactionHashes, - ) - - val txUrl = getExplorerTransactionUrlUseCase( - txHash = transactionHashes.last(), - networkId = cryptoCurrencyStatus.currency.network.id, - ).getOrNull() ?: "" - - balanceUpdater.updateAfterTransaction() - onSendSuccess(txUrl) - }, - ) - } - - private suspend fun submitHash(transactions: List, transactionHashes: List) { - transactions - .zip(transactionHashes) - .forEach { (transaction, transactionHash) -> - submitHashUseCase( - SubmitHashData( - transactionId = transaction.id, - transactionHash = transactionHash, - ), - ) - .onLeft { - saveUnsubmittedHashUseCase.invoke( - transactionId = transaction.id, - transactionHash = transactionHash, - ) - }.onRight { - Timber.d("Successful hash submission") - } - } - } - - private fun getAmount(amountState: AmountState.Data, fee: Fee, reduceAmountBy: BigDecimal?): BigDecimal { - val amountValue = amountState.amountTextField.cryptoAmount.value ?: error("No amount value") - val feeValue = fee.amount.value ?: error("No fee value") - val isEnterAction = stateController.value.actionType is StakingActionCommonType.Enter - - return checkAndCalculateSubtractedAmount( - isAmountSubtractAvailable = isAmountSubtractAvailable && isEnterAction, - cryptoCurrencyStatus = cryptoCurrencyStatus, - amountValue = amountValue, - feeValue = feeValue, - reduceAmountBy = reduceAmountBy.orZero(), - ) - } - - private data class FullTransactionData( - val stakeKitTransaction: StakingTransaction, - val tangemTransaction: TransactionData.Compiled, + class Callbacks( + val onConstructSuccess: (List) -> Unit, + val onConstructError: (StakingError) -> Unit, + val onSendSuccess: (String) -> Unit, + val onSendError: (SendTransactionError) -> Unit, + val onFeeIncreased: (Fee, Boolean) -> Unit, + val onTransactionExpired: () -> Unit, ) - - @AssistedFactory - interface Factory { - fun create( - cryptoCurrencyStatus: CryptoCurrencyStatus, - userWallet: UserWallet, - yield: Yield, - isAmountSubtractAvailable: Boolean, - ): StakingTransactionSender - } } \ No newline at end of file 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..c54d001d68 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt @@ -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 @@ -59,6 +60,10 @@ internal object InitialStakingStatePreview { onInfoClick = {}, yieldBalance = InnerYieldBalanceState.Empty, pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), + legalUrls = StakingStates.InitialInfoState.LegalUrls( + termsOfServiceUrl = "https://docs.yield.xyz/docs/terms-of-use", + privacyPolicyUrl = "https://docs.yield.xyz/docs/privacy-policy", + ), ) val stateWithYield = defaultState.copy( @@ -81,7 +86,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 +97,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..46ef8238ce 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt @@ -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() {} @@ -83,5 +83,9 @@ internal object StakingClickIntentsStub : StakingClickIntents { override fun onActivateTonAccountNotificationShow() {} + override fun onNotEnoughFeeNotificationShow() {} + 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..1dbb49e096 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -17,9 +17,12 @@ import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.staking.BalanceItem +import com.tangem.domain.models.staking.StakingBalanceEntry import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.staking.model.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 @@ -30,6 +33,7 @@ import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType import com.tangem.features.staking.impl.presentation.state.converters.RewardsValidatorStateConverter import com.tangem.features.staking.impl.presentation.state.converters.YieldBalancesConverter import com.tangem.features.staking.impl.presentation.state.utils.getRewardScheduleText +import com.tangem.features.staking.impl.presentation.state.utils.toTextReference import com.tangem.utils.Provider import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.isNullOrZero @@ -41,12 +45,12 @@ 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, private val appCurrencyProvider: Provider, - private val balancesToShowProvider: Provider>, + private val balancesToShowProvider: Provider>, private val isAccountsModeEnabled: Boolean, private val account: Account.CryptoPortfolio?, private val isBalanceHidden: Boolean, @@ -55,7 +59,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 +67,7 @@ internal class SetInitialDataStateTransformer( cryptoCurrencyStatus = cryptoCurrencyStatus, appCurrencyProvider = appCurrencyProvider, balancesToShowProvider = balancesToShowProvider, - yield = yield, + integration = integration, ) } @@ -100,6 +104,10 @@ internal class SetInitialDataStateTransformer( onRefresh = { clickIntents.onRefreshSwipe(it.value) }, isRefreshing = false, ), + legalUrls = StakingStates.InitialInfoState.LegalUrls( + termsOfServiceUrl = integration.legalUrls.termsOfServiceUrl, + privacyPolicyUrl = integration.legalUrls.privacyPolicyUrl, + ), ) } @@ -116,27 +124,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,15 +161,11 @@ internal class SetInitialDataStateTransformer( } private fun createUnbondingPeriodItem(): RoundedListWithDividersItemData? { - val cooldownPeriodDays = yield.metadata.cooldownPeriod?.days ?: return null + val cooldownPeriod = integration.cooldownPeriod ?: return null return RoundedListWithDividersItemData( id = R.string.staking_details_unbonding_period, startText = TextReference.Res(R.string.staking_details_unbonding_period), - endText = pluralReference( - id = R.plurals.common_days, - count = cooldownPeriodDays, - formatArgs = wrappedList(cooldownPeriodDays), - ), + endText = cooldownPeriod.toTextReference(), iconClick = { clickIntents.onInfoClick(InfoType.UNBONDING_PERIOD) }, ) } @@ -169,7 +173,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 +187,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 +199,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 +216,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 +256,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 +294,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/UpdateConfirmationFeeTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/UpdateConfirmationFeeTransformer.kt new file mode 100644 index 0000000000..9c00ba3234 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/UpdateConfirmationFeeTransformer.kt @@ -0,0 +1,28 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.features.staking.impl.presentation.state.FeeState +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.utils.transformer.Transformer + +internal class UpdateConfirmationFeeTransformer( + private val newFee: Fee, +) : Transformer { + + override fun transform(prevState: StakingUiState): StakingUiState { + val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data + ?: return prevState + + val currentFeeState = confirmationState.feeState as? FeeState.Content + ?: return prevState + + val updatedFeeState = currentFeeState.copy(fee = newFee) + + return prevState.copy( + confirmationState = confirmationState.copy( + feeState = updatedFeeState, + ), + ) + } +} \ No newline at end of file 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..6477acf20c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt @@ -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, @@ -184,6 +226,7 @@ internal class AddStakingNotificationsTransformer( isSubtractionAvailable = isSubtractAvailable, cryptoCurrencyStatus = cryptoCurrencyStatus, onClick = prevState.clickIntents::openTokenDetails, + onNotEnoughFeeNotificationShow = prevState.clickIntents::onNotEnoughFeeNotificationShow, ) addTonExtraFeeErrorNotification() addExceedsBalanceNotification( @@ -191,7 +234,7 @@ internal class AddStakingNotificationsTransformer( cryptoCurrencyStatus = cryptoCurrencyStatus, shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(network.backendId), onClick = prevState.clickIntents::openTokenDetails, - onAnalyticsEvent = { /* no-op */ }, + onAnalyticsEvent = { prevState.clickIntents.onNotEnoughFeeNotificationShow() }, ) if (!BlockchainUtils.isCardano(network.rawId)) { addDustWarningNotification( @@ -256,12 +299,14 @@ internal class AddStakingNotificationsTransformer( isSubtractionAvailable: Boolean, cryptoCurrencyStatus: CryptoCurrencyStatus, onClick: (CryptoCurrency) -> Unit, + onNotEnoughFeeNotificationShow: () -> Unit, ) { val balance = cryptoCurrencyStatus.value.amount.orZero() if (!isSubtractionAvailable) return val showNotification = sendingAmount + feeAmount > balance if (showNotification) { + onNotEnoughFeeNotificationShow() val notification = if (actionType is StakingActionCommonType.Enter) { NotificationUM.Error.TotalExceedsBalance } else { 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..8628070ef4 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt @@ -1,20 +1,24 @@ 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 import com.tangem.features.staking.impl.presentation.state.StakingNotification import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.utils.toTextReference import com.tangem.lib.crypto.BlockchainUtils.isCardano import com.tangem.lib.crypto.BlockchainUtils.isCosmos import com.tangem.lib.crypto.BlockchainUtils.isTon @@ -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,17 +115,11 @@ internal class StakingInfoNotificationsFactory( resourceReference(R.string.staking_notification_withdraw_text) } StakingActionType.UNLOCK_LOCKED -> { - val cooldownPeriodDays = yield.metadata.cooldownPeriod?.days - if (cooldownPeriodDays != null) { + val cooldownPeriod = integration.cooldownPeriod + if (cooldownPeriod != null) { resourceReference(R.string.staking_unlocked_locked) to resourceReference( R.string.staking_notification_unlock_text, - wrappedList( - pluralReference( - id = R.plurals.common_days, - count = cooldownPeriodDays, - formatArgs = wrappedList(cooldownPeriodDays), - ), - ), + wrappedList(cooldownPeriod.toTextReference()), ) } else { null to null @@ -194,15 +209,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,24 +259,24 @@ 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 cooldownPeriod = integration.cooldownPeriod val cryptoCurrencyNetworkIdValue = cryptoCurrencyStatusProvider().currency.network.rawId - if (cooldownPeriodDays != null) { + if (cooldownPeriod != null) { add( StakingNotification.Info.Unstake( - cooldownPeriodDays = cooldownPeriodDays, + cooldownPeriod = cooldownPeriod, subtitleRes = if (isCosmos(cryptoCurrencyNetworkIdValue)) { R.string.staking_notification_unstake_cosmos_text } else { @@ -248,18 +294,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 +332,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/CooldownPeriodUtils.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/CooldownPeriodUtils.kt new file mode 100644 index 0000000000..f31d5d3b6e --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/CooldownPeriodUtils.kt @@ -0,0 +1,28 @@ +package com.tangem.features.staking.impl.presentation.state.utils + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.staking.model.CooldownPeriod +import com.tangem.features.staking.impl.R +import com.tangem.utils.StringsSigns.MINUS +import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE + +internal fun CooldownPeriod.toTextReference(): TextReference { + return when (this) { + is CooldownPeriod.Fixed -> pluralReference( + id = R.plurals.common_days, + count = days, + formatArgs = wrappedList(days), + ) + is CooldownPeriod.Range -> combinedReference( + stringReference("$minDays$MINUS$maxDays$NON_BREAKING_SPACE"), + pluralReference( + id = R.plurals.common_days_no_param, + count = maxDays, + ), + ) + } +} \ No newline at end of file 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..b28afeb422 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt @@ -15,11 +15,13 @@ 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 import com.tangem.features.staking.impl.presentation.state.utils.getRewardTypeShortText +import com.tangem.features.staking.impl.presentation.ui.utils.toDrawableRes +import com.tangem.features.staking.impl.presentation.ui.utils.toImageUrl import com.tangem.utils.extensions.orZero @Composable @@ -30,7 +32,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 +44,10 @@ internal fun StakingClaimRewardsValidatorContent( caption = item.getAprTextNeutral(), infoTitle = item.formattedFiatAmount, infoSubtitle = item.formattedCryptoAmount, - imageUrl = item.validator?.image.orEmpty(), + imageUrl = item.target?.image.toImageUrl(), + iconRes = item.target?.image.toDrawableRes(), onImageError = { ValidatorImagePlaceholder() }, - modifier = modifier + modifier = Modifier .roundedShapeItemDecoration(index, state.rewards.lastIndex, false) .background(TangemTheme.colors.background.action) .clickable( @@ -65,11 +68,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 +80,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..d0604549f9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Icon import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape @@ -19,7 +20,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle @@ -32,9 +36,9 @@ import androidx.compose.ui.unit.Density import com.tangem.common.ui.navigationButtons.NavigationButtonsState import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerW12 import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer import com.tangem.core.ui.components.inputrow.InputRowDefault -import com.tangem.core.ui.components.inputrow.InputRowImageInfo import com.tangem.core.ui.components.list.roundedListWithDividersItems import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.extensions.* @@ -46,7 +50,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 @@ -55,6 +59,7 @@ import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.previewdata.InitialStakingStatePreview import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub import com.tangem.features.staking.impl.presentation.state.utils.getRewardTypeShortText +import com.tangem.features.staking.impl.presentation.ui.utils.toImageReference import com.tangem.utils.StringsSigns.DOT import com.tangem.utils.extensions.orZero @@ -107,7 +112,10 @@ internal fun StakingInitialInfoContent( item(STAKE_PRIMARY_BUTTON_KEY) { SpacerH12() - StakeButtonBlock(buttonState) + StakeButtonBlock( + legalUrls = state.legalUrls, + buttonState = buttonState, + ) } } } @@ -235,6 +243,12 @@ private fun StakingRewardBlock( append(reward.rewardsCrypto.orMaskWithStars(isBalanceHidden)) } to TangemTheme.colors.text.primary1 } + RewardBlockType.EthereumEarnedRewards -> { + resourceReference( + id = R.string.staking_details_autocompound_rewards_earned, + formatArgs = wrappedList(reward.rewardsCrypto.orMaskWithStars(isBalanceHidden)), + ) to TangemTheme.colors.text.primary1 + } RewardBlockType.RewardUnavailable.DefaultRewardUnavailable -> { resourceReference(R.string.staking_details_auto_claiming_rewards_daily_text) to TangemTheme.colors.text.tertiary @@ -275,16 +289,9 @@ private fun ActiveStakingBlock( modifier: Modifier = Modifier, ) { val (icon, iconTint) = balance.type.getIcon() - InputRowImageInfo( - subtitle = balance.title, - caption = balance.subtitle ?: balance.getAprTextNeutral(), - infoTitle = balance.formattedFiatAmount.orMaskWithStars(isBalanceHidden), - infoSubtitle = balance.formattedCryptoAmount.orMaskWithStars(isBalanceHidden), - imageUrl = balance.getImage(), - iconRes = icon, - iconTint = iconTint, - subtitleEndIconRes = R.drawable.ic_staking_pending_transaction.takeIf { balance.isPending }, - onImageError = { ValidatorImagePlaceholder() }, + + Row( + verticalAlignment = Alignment.CenterVertically, modifier = modifier .background(TangemTheme.colors.background.action) .clickable( @@ -295,12 +302,103 @@ private fun ActiveStakingBlock( onAnalytic() onClick(balance) }, - ), - ) + ) + .padding(TangemTheme.dimens.spacing12), + ) { + StakingBalanceIcon( + balance = balance, + icon = icon, + iconTint = iconTint, + ) + + Column(modifier = Modifier.weight(1f)) { + Row { + Text( + text = balance.title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + if (balance.isPending) { + Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing4)) + Icon( + painter = rememberVectorPainter( + image = ImageVector.vectorResource(id = R.drawable.ic_staking_pending_transaction), + ), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size18), + ) + } + } + val caption = balance.subtitle ?: balance.getAprTextNeutral() + Text( + text = caption.resolveAnnotatedReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing2), + ) + } + + SpacerW12() + + Column(horizontalAlignment = Alignment.End) { + Text( + text = balance.formattedFiatAmount.orMaskWithStars(isBalanceHidden).resolveReference(), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + if (balance.formattedCryptoAmount != null) { + Text( + text = balance.formattedCryptoAmount.orMaskWithStars(isBalanceHidden).resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing2), + ) + } + } + } } @Composable -private fun StakeButtonBlock(buttonState: NavigationButtonsState) { +private fun RowScope.StakingBalanceIcon(balance: BalanceState, icon: Int?, iconTint: Color) { + if (balance.hasImage() || icon != null) { + StakingTargetIcon( + image = if (balance.hasImage()) balance.target?.image.toImageReference() else null, + onImageError = if (icon != null) { + { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(TangemTheme.dimens.spacing36) + .clip(TangemTheme.shapes.roundedCornersXLarge) + .background(iconTint.copy(alpha = 0.08f)), + ) { + Icon( + painter = rememberVectorPainter( + image = ImageVector.vectorResource(id = icon), + ), + tint = iconTint, + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size18), + ) + } + } + } else { + { ValidatorImagePlaceholder() } + }, + modifier = Modifier + .size(TangemTheme.dimens.spacing36) + .clip(TangemTheme.shapes.roundedCornersXLarge), + ) + SpacerW12() + } +} + +@Composable +private fun StakeButtonBlock( + legalUrls: StakingStates.InitialInfoState.LegalUrls, + buttonState: NavigationButtonsState, +) { val state = buttonState as? NavigationButtonsState.Data val primaryButton = state?.primaryButton @@ -309,7 +407,12 @@ private fun StakeButtonBlock(buttonState: NavigationButtonsState) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { - state?.onTextClick?.let { StakingTosText(it) } + state?.let { buttonsState -> + StakingTosText( + onTermsOfServiceClick = { buttonsState.onTextClick(legalUrls.termsOfServiceUrl) }, + onPrivacyPolicyClick = { buttonsState.onTextClick(legalUrls.privacyPolicyUrl) }, + ) + } NavigationPrimaryButton(primaryButton = primaryButton) } } @@ -317,11 +420,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 +432,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 @@ -341,13 +444,12 @@ private fun BalanceType.getIcon() = when (this) { else -> null to TangemTheme.colors.icon.informative } -@Composable -private fun BalanceState.getImage() = when (type) { +private fun BalanceState.hasImage() = when (type) { BalanceType.UNSTAKING, BalanceType.UNSTAKED, BalanceType.LOCKED, - -> null - else -> validator?.image + -> false + else -> target?.image != null } private val textGradientColors = listOf( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTargetIcon.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTargetIcon.kt new file mode 100644 index 0000000000..ba02bbd2fa --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTargetIcon.kt @@ -0,0 +1,35 @@ +package com.tangem.features.staking.impl.presentation.ui + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import com.tangem.core.ui.components.inputrow.inner.InputRowAsyncImage +import com.tangem.core.ui.extensions.ImageReference + +@Composable +internal fun StakingTargetIcon( + image: ImageReference?, + modifier: Modifier = Modifier, + onImageError: (@Composable () -> Unit)? = null, +) { + when (image) { + is ImageReference.Url -> { + InputRowAsyncImage( + imageUrl = image.url, + onImageError = onImageError, + modifier = modifier, + ) + } + is ImageReference.Res -> { + Image( + painter = painterResource(image.resId), + contentDescription = null, + modifier = modifier, + ) + } + null -> { + onImageError?.invoke() + } + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTosText.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTosText.kt index b09a9ab029..6a4b6f9d3c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTosText.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingTosText.kt @@ -15,11 +15,8 @@ import com.tangem.features.staking.impl.R private const val TERMS_OF_USE_KEY = "termsOfUse" private const val PRIVACY_POLICY_KEY = "privacyPolicy" -private const val TERMS_OF_USE_URL = "https://docs.yield.xyz/docs/terms-of-use#/" -private const val PRIVACY_POLICY_URL = "https://docs.yield.xyz/docs/privacy-policy#/" - @Composable -internal fun StakingTosText(onTextClick: (String) -> Unit) { +internal fun StakingTosText(onTermsOfServiceClick: () -> Unit, onPrivacyPolicyClick: () -> Unit) { val termsOfUse = stringResourceSafe(R.string.common_terms_of_use) val privacyPolicy = stringResourceSafe(R.string.common_privacy_policy) val tosText = stringResourceSafe(R.string.staking_legal, termsOfUse, privacyPolicy) @@ -45,20 +42,23 @@ internal fun StakingTosText(onTextClick: (String) -> Unit) { textAlign = TextAlign.Center, ), onClick = { offset -> - clickableAnnotation.getStringAnnotations( + val isTermsOfUseClicked = clickableAnnotation.getStringAnnotations( tag = TERMS_OF_USE_KEY, start = offset, end = offset, - ).firstOrNull()?.let { - onTextClick(TERMS_OF_USE_URL) - } + ).any() - clickableAnnotation.getStringAnnotations( + val isPrivacyPolicyClicked = clickableAnnotation.getStringAnnotations( tag = PRIVACY_POLICY_KEY, start = offset, end = offset, - ).firstOrNull()?.let { - onTextClick(PRIVACY_POLICY_URL) + ).any() + + if (isTermsOfUseClicked) { + onTermsOfServiceClick() + } + if (isPrivacyPolicyClicked) { + onPrivacyPolicyClick() } }, modifier = Modifier.testTag(StakingDetailsScreenTestTags.TOS_TEXT), 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..147bb67a96 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt @@ -3,12 +3,16 @@ package com.tangem.features.staking.impl.presentation.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -20,20 +24,22 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.inputrow.InputRowImageSelector +import com.tangem.core.ui.components.SpacerW12 import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.format 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 import com.tangem.features.staking.impl.presentation.state.previewdata.ValidatorStatePreviewData import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub import com.tangem.features.staking.impl.presentation.state.utils.getRewardTypeLongText +import com.tangem.features.staking.impl.presentation.ui.utils.toImageReference import com.tangem.utils.extensions.orZero /** @@ -54,73 +60,106 @@ 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] - - InputRowImageSelector( - subtitle = stringReference(item.name), - caption = item.getAprTextNeutral(), - imageUrl = item.image.orEmpty(), - isSelected = item == state.chosenValidator, - onSelect = { clickIntents.onValidatorSelect(item) }, - modifier = Modifier - .roundedShapeItemDecoration( - currentIndex = index, - lastIndex = validators.lastIndex, - radius = TangemTheme.dimens.radius12, - addDefaultPadding = - false, - ) - .background(TangemTheme.colors.background.action), - subtitleExtraContent = { - ValidatorLabel(item.isStrategicPartner) - }, - selectorContent = { checked, _, _ -> - AnimatedVisibility( - modifier = Modifier, - visible = checked, - ) { - Icon( - painter = rememberVectorPainter( - image = ImageVector.vectorResource(id = R.drawable.ic_check_24), - ), - tint = TangemTheme.colors.icon.accent, - contentDescription = null, - ) - } - }, - onImageError = { ValidatorImagePlaceholder() }, + ValidatorListItem( + item = targets[index], + isSelected = targets[index] == state.chosenTarget, + index = index, + lastIndex = targets.lastIndex, + onSelect = { clickIntents.onTargetSelect(targets[index]) }, ) } } } } +@Composable +private fun ValidatorListItem( + item: StakingTarget, + isSelected: Boolean, + index: Int, + lastIndex: Int, + onSelect: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = lastIndex, + radius = TangemTheme.dimens.radius12, + addDefaultPadding = false, + ) + .background(TangemTheme.colors.background.action) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = onSelect, + ) + .padding(TangemTheme.dimens.spacing12), + ) { + StakingTargetIcon( + image = item.image.toImageReference(), + onImageError = { ValidatorImagePlaceholder() }, + modifier = Modifier + .size(TangemTheme.dimens.spacing36) + .clip(TangemTheme.shapes.roundedCornersXLarge), + ) + SpacerW12() + Column(modifier = Modifier.weight(1f)) { + Row { + Text( + text = stringReference(item.name).resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + ValidatorLabel(item.isStrategicPartner) + } + Text( + text = item.getAprTextNeutral().resolveAnnotatedReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing2), + ) + } + SpacerW12() + AnimatedVisibility(visible = isSelected) { + Icon( + painter = rememberVectorPainter( + image = ImageVector.vectorResource(id = R.drawable.ic_check_24), + ), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + } + } +} + /** * For FCA fixes remove coloring for now */ @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..005d38c850 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/ValidatorBlock.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/ValidatorBlock.kt @@ -19,17 +19,19 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SpacerWMax -import com.tangem.core.ui.components.inputrow.inner.InputRowAsyncImage import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent 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.StakingTargetIcon import com.tangem.features.staking.impl.presentation.ui.ValidatorImagePlaceholder +import com.tangem.features.staking.impl.presentation.ui.utils.toImageReference import com.tangem.utils.extensions.orZero @Composable @@ -62,21 +64,21 @@ internal fun ValidatorBlock(validatorState: StakingStates.ValidatorState, isClic horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(12.dp), ) { - InputRowAsyncImage( - imageUrl = validatorState.chosenValidator.image.orEmpty(), + StakingTargetIcon( + image = state.chosenTarget.image.toImageReference(), 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 +93,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/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/utils/StakingImageUtils.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/utils/StakingImageUtils.kt new file mode 100644 index 0000000000..7c1dc4d212 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/utils/StakingImageUtils.kt @@ -0,0 +1,23 @@ +package com.tangem.features.staking.impl.presentation.ui.utils + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.extensions.ImageReference +import com.tangem.core.ui.R as CoreR +import com.tangem.domain.staking.model.StakingLocalImageType +import com.tangem.domain.staking.model.StakingTargetImage + +fun StakingTargetImage?.toImageReference(): ImageReference? = when (this) { + is StakingTargetImage.Url -> url.takeIf { it.isNotBlank() }?.let { ImageReference.Url(it) } + is StakingTargetImage.Local -> ImageReference.Res(type.toDrawableRes()) + null -> null +} + +fun StakingTargetImage?.toImageUrl(): String? = (this as? StakingTargetImage.Url)?.url?.takeIf { it.isNotBlank() } + +@DrawableRes +fun StakingTargetImage?.toDrawableRes(): Int? = (this as? StakingTargetImage.Local)?.type?.toDrawableRes() + +@DrawableRes +fun StakingLocalImageType.toDrawableRes(): Int = when (this) { + StakingLocalImageType.P2P_VAULT -> CoreR.drawable.ic_p2p_logo +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapUtils.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapUtils.kt index a8a7a45905..fee7ec431c 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapUtils.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapUtils.kt @@ -14,7 +14,10 @@ internal object SwapUtils { const val INCREASE_GAS_LIMIT_FOR_CEX = 105 // 5% /** List of supported provider types in Send with Swap */ - internal val SEND_WITH_SWAP_PROVIDER_TYPES = listOf(ExpressProviderType.CEX) + internal val SEND_WITH_SWAP_PROVIDER_TYPES = listOf( + ExpressProviderType.CEX, + ExpressProviderType.DEX, + ) fun getExpressErrorMessage(expressError: ExpressError): TextReference { return when (expressError) { diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index 0b4b6b742e..5d53b1d40e 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -90,12 +90,14 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( params = FeeSelectorBlockParams( state = model.uiState.value.feeSelectorUM, onLoadFee = model::loadFee, + onLoadFeeExtended = model::loadFeeExtended, feeCryptoCurrencyStatus = model.primaryFeePaidCurrencyStatus, cryptoCurrencyStatus = model.primaryCurrencyStatus, feeStateConfiguration = FeeStateConfiguration.ExcludeLow, feeDisplaySource = FeeDisplaySource.Screen, analyticsCategoryName = params.analyticsCategoryName, analyticsSendSource = params.analyticsSendSource, + userWalletId = params.userWallet.walletId, ), onResult = model::onFeeResult, ) @@ -106,7 +108,6 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( analyticsCategoryName = params.analyticsCategoryName, userWalletId = params.userWallet.walletId, cryptoCurrencyStatus = model.primaryCurrencyStatus, - feeCryptoCurrencyStatus = model.primaryFeePaidCurrencyStatus, appCurrency = params.appCurrency, callback = model, notificationData = SendNotificationsComponent.Params.NotificationData( @@ -120,6 +121,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( isIgnoreReduce = model.confirmData.isIgnoreReduce, fee = model.confirmData.fee, feeError = model.confirmData.feeError, + feeCryptoCurrencyStatus = model.primaryFeePaidCurrencyStatus, ), ), ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 3fcd6d870c..47225aeb2e 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -26,7 +26,10 @@ import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.swap.models.SwapDirection.Companion.withSwapDirection import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.EstimateFeeUseCase +import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase +import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.features.send.v2.api.SendNotificationsComponent import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData @@ -64,6 +67,7 @@ import jakarta.inject.Inject import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.math.BigDecimal +import com.tangem.features.send.v2.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned import com.tangem.utils.transformer.update as transformerUpdate @Suppress("LongParameterList", "LargeClass") @@ -73,6 +77,8 @@ internal class SendWithSwapConfirmModel @Inject constructor( private val router: Router, private val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase, private val estimateFeeUseCase: EstimateFeeUseCase, + private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase, + private val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val sendNotificationsUpdateTrigger: SendNotificationsUpdateTrigger, @@ -106,6 +112,8 @@ internal class SendWithSwapConfirmModel @Inject constructor( get() = uiState.value.destinationUM as? DestinationUM.Content private val feeSelectorUM get() = uiState.value.feeSelectorUM as? FeeSelectorUM.Content + private val feeUMV2 + get() = uiState.value.feeSelectorUM as? FeeSelectorUMRedesigned.Content val secondaryCurrencyStatus: CryptoCurrencyStatus? = amountUM?.secondaryCryptoCurrencyStatus val secondaryCurrency: CryptoCurrency = requireNotNull(amountUM?.secondaryCryptoCurrencyStatus?.currency) { @@ -143,7 +151,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( } init { - initAmountSubtractAvailability() + updateAmountSubtractAvailability() configConfirmNavigation() initialState() subscribeOnNotificationUpdates() @@ -152,6 +160,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( override fun onFeeResult(feeSelectorUM: FeeSelectorUM) { uiState.update { it.copy(feeSelectorUM = feeSelectorUM) } + updateAmountSubtractAvailability() updateConfirmNotifications() } @@ -243,11 +252,44 @@ internal class SendWithSwapConfirmModel @Inject constructor( } } + suspend fun loadFeeExtended(maybeToken: CryptoCurrencyStatus?): Either { + val defaultError = GetFeeError.UnknownError.left() + val provider = (confirmData.quote as? SwapQuoteUM.Content)?.provider ?: return defaultError + val amountValue = confirmData.enteredAmount ?: return defaultError + + return when (val providerType = provider.type) { + ExpressProviderType.CEX -> { + if (maybeToken != null) { + estimateFeeForTokenUseCase( + amount = amountValue, + userWallet = params.userWallet, + feeTokenCurrencyStatus = maybeToken, + sendingTokenCurrencyStatus = primaryCurrencyStatus, + ) + } else { + estimateFeeForGaslessTxUseCase( + amount = amountValue, + userWallet = params.userWallet, + sendingTokenCurrencyStatus = primaryCurrencyStatus, + ) + } + } + ExpressProviderType.DEX, + ExpressProviderType.DEX_BRIDGE, + ExpressProviderType.ONRAMP, + -> GetFeeError.DataError( + cause = IllegalStateException("Provider $providerType is not supported in Send With Swap"), + ).left() + } + } + private fun onSendClick() { val provider = confirmData.quote?.provider ?: return modelScope.launch { uiState.transformerUpdate(SendWithSwapConfirmSendingStateTransformer(true)) + val feeExtended = feeUMV2?.feeExtraInfo?.transactionFeeExtended swapTransactionSender.sendTransaction( + feeExtended = feeExtended, confirmData = confirmData, isAmountSubtractAvailable = isAmountSubtractAvailable, onExpressError = { expressError -> @@ -305,12 +347,16 @@ internal class SendWithSwapConfirmModel @Inject constructor( } } - private fun initAmountSubtractAvailability() { + private fun updateAmountSubtractAvailability() { modelScope.launch { + val fee = feeUMV2?.feeExtraInfo?.transactionFeeExtended?.transactionFee?.normal + val feeTokenId = + feeUMV2?.feeExtraInfo?.transactionFeeExtended?.feeTokenId ?: primaryCurrencyStatus.currency.id isAmountSubtractAvailable = isAmountSubtractAvailableUseCase( - params.userWallet.walletId, - primaryCurrencyStatus.currency, + userWalletId = params.userWallet.walletId, + currency = primaryCurrencyStatus.currency, + maybeGaslessFee = fee?.let { feeTokenId to fee }, ).getOrElse { false } } } @@ -357,6 +403,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( isIgnoreReduce = confirmData.isIgnoreReduce, fee = confirmData.fee, feeError = confirmData.feeError, + feeCryptoCurrencyStatus = params.primaryFeePaidCurrencyStatusFlow.value, ), ) swapNotificationsUpdateTrigger.triggerUpdate( @@ -379,9 +426,10 @@ internal class SendWithSwapConfirmModel @Inject constructor( val hasError = hasSendError || hasSwapError uiState.update { state -> val feeUM = state.feeSelectorUM as? FeeSelectorUM.Content + val isTransactionInProcess = (state.confirmUM as? ConfirmUM.Content)?.isTransactionInProcess == true state.copy( confirmUM = (state.confirmUM as? ConfirmUM.Content)?.copy( - isPrimaryButtonEnabled = !hasError && feeUM != null, + isPrimaryButtonEnabled = !hasError && feeUM != null && !isTransactionInProcess, ) ?: state.confirmUM, ) } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt index 15b59a6ef4..50a94cdb36 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt @@ -17,8 +17,10 @@ import com.tangem.domain.swap.models.SwapTxType import com.tangem.domain.swap.usecase.GetSwapDataUseCase import com.tangem.domain.swap.usecase.SwapTransactionSentUseCase import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils import com.tangem.features.swap.v2.impl.common.ConfirmData @@ -35,6 +37,7 @@ internal class SwapTransactionSender @AssistedInject constructor( private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val swapTransactionSentUseCase: SwapTransactionSentUseCase, private val sendTransactionUseCase: SendTransactionUseCase, + private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, @Assisted private val userWallet: UserWallet, ) { @@ -45,6 +48,7 @@ internal class SwapTransactionSender @AssistedInject constructor( onExpressError: (ExpressError) -> Unit, onSendError: (SendTransactionError?) -> Unit, expressOperationType: ExpressOperationType, + feeExtended: TransactionFeeExtended?, ) { val provider = confirmData.quote?.provider ?: return @@ -56,6 +60,7 @@ internal class SwapTransactionSender @AssistedInject constructor( onSendSuccess = onSendSuccess, onSendError = onSendError, expressOperationType = expressOperationType, + feeExtended = feeExtended, ) ExpressProviderType.DEX, ExpressProviderType.DEX_BRIDGE, @@ -74,6 +79,7 @@ internal class SwapTransactionSender @AssistedInject constructor( onExpressError: (ExpressError) -> Unit, onSendError: (SendTransactionError?) -> Unit, expressOperationType: ExpressOperationType, + feeExtended: TransactionFeeExtended?, ) { val fromStatus = confirmData.fromCryptoCurrencyStatus ?: return val toStatus = confirmData.toCryptoCurrencyStatus ?: return @@ -114,6 +120,7 @@ internal class SwapTransactionSender @AssistedInject constructor( onSendSuccess = onSendSuccess, onExpressError = onExpressError, onSendError = onSendError, + feeExtended = feeExtended, ) } @@ -123,6 +130,7 @@ internal class SwapTransactionSender @AssistedInject constructor( toStatus: CryptoCurrencyStatus, fromAccount: Account.CryptoPortfolio?, fee: Fee, + feeExtended: TransactionFeeExtended?, provider: ExpressProvider, swapData: SwapDataModel, onSendSuccess: (String, Long, SwapDataModel) -> Unit, @@ -161,11 +169,22 @@ internal class SwapTransactionSender @AssistedInject constructor( txData.destinationAddress } - sendTransactionUseCase( - txData = txData, - userWallet = userWallet, - network = fromStatus.currency.network, - ).fold( + val isFeeInTokenCurrency = feeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency + + val result = if (feeExtended != null && isFeeInTokenCurrency) { + createAndSendGaslessTransactionUseCase( + userWallet = userWallet, + transactionData = txData, + fee = feeExtended, + ) + } else { + sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = fromStatus.currency.network, + ) + } + result.fold( ifLeft = { onSendError(it) }, ifRight = { txHash -> val timestamp = System.currentTimeMillis() diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt index c106e29968..44ca767ac2 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt @@ -333,6 +333,7 @@ private fun SendWithSwapSuccessContent_Preview() { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, + feeCryptoCurrencyStatus = SwapAmountContentPreview.cryptoCurrencyStatus, ), feeFiatRateUM = null, feeNonce = FeeNonce.None, diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt index 2d0f54f7b2..d2ef17de45 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt @@ -2,7 +2,9 @@ package com.tangem.features.swap import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import java.math.BigDecimal @@ -15,6 +17,8 @@ interface SwapComponent : ComposableContentComponent { val isInitialReverseOrder: Boolean = false, val screenSource: String, val tangemPayInput: TangemPayInput? = null, + val preselectedToToken: CryptoCurrencyStatus? = null, + val preselectedAccount: Account.CryptoPortfolio? = null, ) { data class TangemPayInput( val cryptoAmount: BigDecimal, diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt index e0fe076fab..d782e276ca 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt @@ -1,3 +1,5 @@ package com.tangem.features.swap -interface SwapFeatureToggles \ No newline at end of file +interface SwapFeatureToggles { + val isMarketListFeatureEnabled: Boolean +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt index 8ec8958bd3..5c0b55b780 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt @@ -170,6 +170,10 @@ internal class SavedSwapTransactionListConverter( } private fun UserTokensResponse.Token.getDerivationIndex(): DerivationIndex? { - return accountId?.toIntOrNull()?.let { DerivationIndex(it).getOrNull() } + val blockchain = Blockchain.fromNetworkId(networkId) ?: return null + val accountNodeRecognizer = AccountNodeRecognizer(blockchain) + return derivationPath + ?.let { accountNodeRecognizer.recognize(it) } + ?.let { DerivationIndex(it.toInt()).getOrNull() } } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 6f4381b8a6..d8ba2cc67b 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -1,10 +1,14 @@ package com.tangem.feature.swap.domain +import arrow.core.Either +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount import com.tangem.feature.swap.domain.models.domain.PermissionOptions @@ -37,7 +41,7 @@ interface SwapInteractor { * @param providers list of providers to find quote * @param amountToSwap amount you want to swap * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) - * @param selectedFee selected fee to swap + * @param txFeeSealedState selected fee to swap * @return */ @Suppress("LongParameterList") @@ -50,7 +54,7 @@ interface SwapInteractor { providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, - selectedFee: FeeType = FeeType.NORMAL, + txFeeSealedState: TxFeeSealedState, ): Map /** @@ -132,6 +136,50 @@ interface SwapInteractor { averageDuration: Int? = null, ) + /** + * Loads fee for swap transaction + * + * @param fromToken token from which want to swap + * @param fromAccount account from which swap will be made + * @param toToken token that receive after swap + * @param toAccount account to which receive token after swap + * @param amount amount you want to swap + * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) + * @param selectedFeeToken selected token to pay fee or null to pay fee with coin + */ + @Suppress("LongParameterList") + suspend fun loadFeeForSwapTransaction( + fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, + amount: String, + reduceBalanceBy: BigDecimal, + provider: SwapProvider, + selectedFeeToken: CryptoCurrencyStatus?, + ): Either + + /** + * Loads fee for swap transaction + * + * @param fromToken token from which want to swap + * @param fromAccount account from which swap will be made + * @param toToken token that receive after swap + * @param toAccount account to which receive token after swap + * @param amount amount you want to swap + * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) + */ + @Suppress("LongParameterList") + suspend fun loadFeeForSwapTransaction( + fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, + amount: String, + reduceBalanceBy: BigDecimal, + provider: SwapProvider, + ): Either + interface Factory { fun create(selectedWalletId: UserWalletId): SwapInteractor } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 067870cc0e..286471b0a0 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -3,6 +3,7 @@ package com.tangem.feature.swap.domain import android.util.Base64 import arrow.core.Either import arrow.core.getOrElse +import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper import com.tangem.blockchain.common.Amount @@ -42,10 +43,14 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.* +import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase +import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase +import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase +import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.usecase.GetUserWalletUseCase -import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount @@ -55,6 +60,7 @@ import com.tangem.feature.swap.domain.models.ui.* import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES import com.tangem.lib.crypto.UserWalletManager import com.tangem.lib.crypto.models.ProxyAmount +import com.tangem.utils.coroutines.runSuspendCatching import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -87,6 +93,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val initialToCurrencyResolver: InitialToCurrencyResolver, private val validateTransactionUseCase: ValidateTransactionUseCase, private val estimateFeeUseCase: EstimateFeeUseCase, + private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase, + private val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase, + private val getFeeForTokenUseCase: GetFeeForTokenUseCase, + private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, private val getFeeUseCase: GetFeeUseCase, private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, @@ -385,7 +395,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, - selectedFee: FeeType, + txFeeSealedState: TxFeeSealedState, ): Map { Timber.i( """ @@ -394,7 +404,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( |- toToken: $toToken |- providers: $providers |- amountToSwap: $amountToSwap - |- selectedFee: $selectedFee + |- selectedFee: $txFeeSealedState """.trimIndent(), ) @@ -416,7 +426,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toToken = toToken, toAccount = toAccount, provider = provider, - selectedFee = selectedFee, + txFeeSealedState = txFeeSealedState, amount = amount, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, expressOperationType = ExpressOperationType.SWAP, @@ -429,7 +439,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toToken = toToken, toAccount = toAccount, provider = provider, - selectedFee = selectedFee, + txFeeSealedState = txFeeSealedState, amount = amount, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, expressOperationType = ExpressOperationType.SWAP, @@ -447,7 +457,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( amount = amount, reduceBalanceBy = reduceBalanceBy, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - selectedFee = selectedFee, + txFeeSealedState = txFeeSealedState, ) } } @@ -462,7 +472,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toToken: CryptoCurrencyStatus, toAccount: Account.CryptoPortfolio?, provider: SwapProvider, - selectedFee: FeeType, + txFeeSealedState: TxFeeSealedState, amount: SwapAmount, isBalanceWithoutFeeEnough: Boolean, expressOperationType: ExpressOperationType, @@ -517,7 +527,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toToken = toToken, toAccount = toAccount, amount = amount, - selectedFee = selectedFee, + txFeeSealedState = txFeeSealedState, expressOperationType = expressOperationType, ) } else { @@ -532,10 +542,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( networkId = networkId, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - txFee = TxFeeState.Empty, - transactionFee = null, + txFeeSealedState = txFeeSealedState, includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex - selectedFee = selectedFee, ) } } @@ -547,7 +555,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toToken: CryptoCurrencyStatus, toAccount: Account.CryptoPortfolio?, provider: SwapProvider, - selectedFee: FeeType, + txFeeSealedState: TxFeeSealedState, amount: SwapAmount, isBalanceWithoutFeeEnough: Boolean, expressOperationType: ExpressOperationType, @@ -574,7 +582,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toToken = toToken, toAccount = toAccount, amount = amount, - selectedFee = selectedFee, + txFeeSealedState = txFeeSealedState, expressOperationType = expressOperationType, ) } else { @@ -589,10 +597,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( networkId = networkId, isAllowedToSpend = true, isBalanceWithoutFeeEnough = false, - txFee = TxFeeState.Empty, - transactionFee = null, + txFeeSealedState = txFeeSealedState, includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex - selectedFee = selectedFee, ) } } @@ -607,7 +613,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( amount: SwapAmount, reduceBalanceBy: BigDecimal, isBalanceWithoutFeeEnough: Boolean, - selectedFee: FeeType, + txFeeSealedState: TxFeeSealedState, ): Pair { return provider to loadCexQuoteData( networkId = networkId, @@ -620,22 +626,34 @@ internal class SwapInteractorImpl @AssistedInject constructor( isAllowedToSpend = true, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, provider = provider, - selectedFee = selectedFee, + txFeeSealedState = txFeeSealedState, ) } private suspend fun manageWarnings( fromTokenStatus: CryptoCurrencyStatus, amount: SwapAmount, - feeState: TxFeeState, - selectedFee: FeeType, + txFeeSealed: TxFeeSealedState?, includeFeeInAmount: IncludeFeeInAmount, ): CryptoCurrencyCheck { - val fee = when (feeState) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> feeState.getFeeByType(selectedFee).feeValue - is TxFeeState.SingleFeeState -> feeState.fee.feeValue - } + val fee = when (txFeeSealed) { + is TxFeeSealedState.Component -> { + if (txFeeSealed.txFee.selectedToken?.currency is CryptoCurrency.Token) { + BigDecimal.ZERO + } else { + txFeeSealed.txFee.fee.amount.value + } + } + is TxFeeSealedState.Legacy -> { + when (val feeState = txFeeSealed.txFeeState) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> feeState.getFeeByType(txFeeSealed.selectedFee).fee.amount.value + is TxFeeState.SingleFeeState -> feeState.fee.fee.amount.value + } + } + null -> BigDecimal.ZERO + } ?: BigDecimal.ZERO + val balanceAfterTransaction = getCoinBalanceAfterTransaction( fromTokenStatus = fromTokenStatus, amount = amount, @@ -703,7 +721,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageTransactionValidationWarnings( fromToken: CryptoCurrencyStatus, amount: SwapAmount, - feeState: TxFeeState, + txFeeSealedState: TxFeeSealedState, userWalletId: UserWalletId, ): Throwable? { val currency = fromToken.currency @@ -712,13 +730,20 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (blockchain == Blockchain.Stellar) { return null } + val feeValue = when (txFeeSealedState) { + is TxFeeSealedState.Component -> txFeeSealedState.txFee.fee.amount.value + is TxFeeSealedState.Legacy -> { + when (val feeState = txFeeSealedState.txFeeState) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> feeState.normalFee.fee.amount.value + is TxFeeState.SingleFeeState -> feeState.fee.fee.amount.value + } + } + } + val fee = Fee.Common( amount = Amount( - value = when (feeState) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> feeState.normalFee.feeValue - is TxFeeState.SingleFeeState -> feeState.fee.feeValue - }, + value = feeValue, blockchain = blockchain, ), ) @@ -793,7 +818,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (isSolana(networkId)) { onSwapSolanaDex( provider = swapProvider, - networkId = currencyToSend.currency.network.backendId, swapData = requireNotNull(swapData), currencyToSendStatus = currencyToSend, currencyToGetStatus = currencyToGet, @@ -805,7 +829,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (fee == null) return SwapTransactionState.Error.UnknownError onSwapDex( provider = swapProvider, - networkId = currencyToSend.currency.network.backendId, swapData = requireNotNull(swapData), currencyToSendStatus = currencyToSend, currencyToGetStatus = currencyToGet, @@ -819,50 +842,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } - // override suspend fun updateQuotesStateWithSelectedFee( - // state: SwapState.QuotesLoadedState, - // selectedFee: FeeType, - // fromToken: CryptoCurrencyStatus, - // amountToSwap: String, - // reduceBalanceBy: BigDecimal, - // ): SwapState.QuotesLoadedState { - // val amountDecimal = toBigDecimalOrNull(amountToSwap) - // if (amountDecimal == null || amountDecimal.signum() == 0) { - // return state - // } - // val amount = SwapAmount(amountDecimal, fromToken.currency.decimals) - // val includeFeeInAmount = getIncludeFeeInAmount( - // networkId = fromToken.currency.network.backendId, - // txFee = state.txFee, - // amount = amount, - // reduceBalanceBy = reduceBalanceBy, - // fromToken = fromToken.currency, - // selectedFee = selectedFee, - // ) - // val fee = when (val txFee = state.txFee) { - // TxFeeState.Empty -> BigDecimal.ZERO - // is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeIncludeOtherNativeFee - // is TxFeeState.SingleFeeState -> txFee.fee.feeIncludeOtherNativeFee - // } - // val feeState = getFeeState( - // fee = fee, - // spendAmount = amount, - // networkId = fromToken.currency.network.backendId, - // fromTokenStatus = fromToken, - // ) - // return state.copy( - // permissionState = PermissionDataState.Empty, - // preparedSwapConfigState = state.preparedSwapConfigState.copy( - // feeState = feeState, - // isBalanceEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough, - // includeFeeInAmount = includeFeeInAmount, - // ), - // ) - // } - private suspend fun onSwapDex( provider: SwapProvider, - networkId: String, swapData: SwapDataModel, currencyToSendStatus: CryptoCurrencyStatus, currencyToGetStatus: CryptoCurrencyStatus, @@ -874,7 +855,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" } val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) - val derivationPath = currencyToSendStatus.currency.network.derivationPath.value val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX val dataToSign = dexTransaction.txData val amountToSend = createNativeAmountForDex(txValue, currencyToSendStatus.currency.network) @@ -893,14 +873,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( return handleSwapResult( provider = provider, - networkId = networkId, swapData = swapData, currencyToSendStatus = currencyToSendStatus, currencyToGetStatus = currencyToGetStatus, fromAccount = fromAccount, toAccount = toAccount, amount = amount, - derivationPath = derivationPath, txData = txData, payInAddress = getPayoutAddress(txData), ) @@ -908,7 +886,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun onSwapSolanaDex( provider: SwapProvider, - networkId: String, swapData: SwapDataModel, currencyToSendStatus: CryptoCurrencyStatus, currencyToGetStatus: CryptoCurrencyStatus, @@ -920,20 +897,17 @@ internal class SwapInteractorImpl @AssistedInject constructor( val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val txDataBase64 = requireNotNull(dexTransaction?.txData) { "txData is null" } val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) - val derivationPath = currencyToSendStatus.currency.network.derivationPath.value val compiledTransaction = TransactionData.Compiled( value = TransactionData.Compiled.Data.Bytes(Base64.decode(txDataBase64, Base64.NO_WRAP)), ) return handleSwapResult( provider = provider, - networkId = networkId, swapData = swapData, currencyToSendStatus = currencyToSendStatus, currencyToGetStatus = currencyToGetStatus, fromAccount = fromAccount, toAccount = toAccount, amount = amount, - derivationPath = derivationPath, txData = compiledTransaction, payInAddress = swapData.transaction.txTo, ) @@ -941,14 +915,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun handleSwapResult( provider: SwapProvider, - networkId: String, swapData: SwapDataModel, currencyToSendStatus: CryptoCurrencyStatus, currencyToGetStatus: CryptoCurrencyStatus, fromAccount: Account.CryptoPortfolio?, toAccount: Account.CryptoPortfolio?, amount: SwapAmount, - derivationPath: String?, txData: TransactionData, payInAddress: String, ): SwapTransactionState { @@ -995,7 +967,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( currencyToGetStatus.currency.symbol, ), toAmountValue = swapData.toTokenAmount.value, - txHash = userWalletManager.getLastTransactionHash(networkId, derivationPath).orEmpty(), + txHash = txHash, timestamp = System.currentTimeMillis(), ) }, @@ -1082,10 +1054,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) { return SwapTransactionState.Error.UnknownError } - + val fee = requireNotNull(txFee) val txData = createTransferTransactionUseCase( amount = amount.value.convertToSdkAmount(currencyToSend), - fee = requireNotNull(txFee).fee, + fee = fee.fee, memo = exchangeDataCex.txExtraId, destination = exchangeDataCex.txTo, userWalletId = userWalletId, @@ -1099,13 +1071,33 @@ internal class SwapInteractorImpl @AssistedInject constructor( return SwapTransactionState.Error.UnknownError } - val result = sendTransactionUseCase( - txData = txData, - userWallet = userWallet, - network = currencyToSend.currency.network, - ) + val result = when (fee) { + is TxFee.FeeComponent -> { + if (fee.selectedToken?.currency is CryptoCurrency.Token && + fee.transactionFeeResult is TransactionFeeResult.LoadedExtended + ) { + createAndSendGaslessTransactionUseCase.invoke( + transactionData = txData, + userWallet = userWallet, + fee = fee.transactionFeeResult.fee, + ) + } else { + sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = currencyToSend.currency.network, + ) + } + } + is TxFee.Legacy -> { + sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = currencyToSend.currency.network, + ) + } + } - val derivationPath = currencyToSend.currency.network.derivationPath.value val cexNetworkAddress = currencyToSend.value.networkAddress val cexFromAddress = cexNetworkAddress?.defaultAddress?.value.orEmpty() return result.fold( @@ -1146,10 +1138,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( currencyToGet.currency.symbol, ), toAmountValue = exchangeData.toTokenAmount.value, - txHash = userWalletManager.getLastTransactionHash( - currencyToSend.currency.network.backendId, - derivationPath, - ).orEmpty(), + txHash = txHash, txExternalUrl = txExternalUrl, timestamp = timestamp, ) @@ -1194,6 +1183,113 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } + @Suppress("LongParameterList") + override suspend fun loadFeeForSwapTransaction( + fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, + amount: String, + reduceBalanceBy: BigDecimal, + provider: SwapProvider, + selectedFeeToken: CryptoCurrencyStatus?, + ): Either = either { + when (provider.type) { + ExchangeProviderType.DEX, + ExchangeProviderType.DEX_BRIDGE, + -> raise(GetFeeError.GaslessError.NetworkIsNotSupported) + ExchangeProviderType.CEX -> { + val amountDecimal = toBigDecimalOrNull(amount) + if (amountDecimal == null || amountDecimal.signum() == 0) { + raise(GetFeeError.UnknownError) + } + + return if (selectedFeeToken != null) { + estimateFeeForTokenUseCase( + userWallet = userWallet, + feeTokenCurrencyStatus = selectedFeeToken, + sendingTokenCurrencyStatus = fromToken, + amount = amountDecimal, + ) + } else { + estimateFeeForGaslessTxUseCase( + amount = amountDecimal, + userWallet = userWallet, + sendingTokenCurrencyStatus = fromToken, + ) + } + } + } + } + + override suspend fun loadFeeForSwapTransaction( + fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, + toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, + amount: String, + reduceBalanceBy: BigDecimal, + provider: SwapProvider, + ): Either = either { + return when (provider.type) { + ExchangeProviderType.DEX, + ExchangeProviderType.DEX_BRIDGE, + -> { + val fromNetworkAddress = fromToken.value.networkAddress + val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() + val toNetworkAddress = toToken.value.networkAddress + val dexToAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() + val amountBigDecimal = toBigDecimalOrNull(amount) + if (amountBigDecimal == null || amountBigDecimal.signum() == 0) { + raise(GetFeeError.UnknownError) + } + val swapAmount = SwapAmount(amountBigDecimal, fromToken.currency.decimals) + + repository.getExchangeData( + userWallet = userWallet, + fromContractAddress = fromToken.currency.getContractAddress(), + fromNetwork = fromToken.currency.network.backendId, + toContractAddress = toToken.currency.getContractAddress(), + fromAddress = dexFromAddress, + toNetwork = toToken.currency.network.backendId, + fromAmount = swapAmount.toStringWithRightOffset(), + fromDecimals = swapAmount.decimals, + toDecimals = toToken.currency.decimals, + providerId = provider.providerId, + rateType = RateType.FLOAT, + toAddress = dexToAddress, + refundAddress = fromToken.value.networkAddress?.defaultAddress?.value, + expressOperationType = ExpressOperationType.SWAP, + ).map { swapData -> + val networkId = fromToken.currency.network.backendId + val transaction = swapData.transaction as ExpressTransactionModel.DEX + + loadFeeForDex( + networkId = networkId, + transaction = transaction, + fromToken = fromToken, + ).getOrElse { raise(GetFeeError.UnknownError) } + }.mapLeft { + GetFeeError.UnknownError + } + } + ExchangeProviderType.CEX -> { + val amountDecimal = toBigDecimalOrNull(amount) + if (amountDecimal == null || amountDecimal.signum() == 0) { + raise(GetFeeError.UnknownError) + } + + estimateFeeUseCase.invoke( + amount = amountDecimal, + userWallet = userWallet, + cryptoCurrencyStatus = fromToken, + ).map { + it.patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_SEND) + } + } + } + } + private suspend fun storeLastCryptoCurrencyId(cryptoCurrency: CryptoCurrency) { swapTransactionRepository.storeLastSwappedCryptoCurrencyId( UserWalletId(userWalletManager.getWalletId()), @@ -1291,31 +1387,25 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider: SwapProvider, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, - selectedFee: FeeType, + txFeeSealedState: TxFeeSealedState, ): SwapState { val fromToken = fromTokenStatus.currency val toToken = toTokenStatus.currency return coroutineScope { - val txFeeResult = getUnhandledFee( - amount = amount.value, - userWallet = userWallet, - cryptoCurrencyStatus = fromTokenStatus, + val txFeeSealedStateUpdated = updateTxFeeStateIfNeededForCEX( + txFeeSealedState = txFeeSealedState, + amount = amount, + fromTokenStatus = fromTokenStatus, ) - val txFee = if (provider.type == ExchangeProviderType.CEX) { - getFeeForCex(txFeeResult, fromTokenStatus) - } else { - TxFeeState.Empty - } - val includeFeeInAmount = getIncludeFeeInAmount( networkId = networkId, - txFee = txFee, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromToken = fromToken, - selectedFee = selectedFee, + fromToken = fromTokenStatus, + txFeeSealedState = txFeeSealedStateUpdated, ) + val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) { includeFeeInAmount.amountSubtractFee } else { @@ -1346,14 +1436,39 @@ internal class SwapInteractorImpl @AssistedInject constructor( networkId = networkId, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - txFee = txFee, - transactionFee = txFeeResult.getOrNull(), + txFeeSealedState = txFeeSealedState, includeFeeInAmount = includeFeeInAmount, - selectedFee = selectedFee, ) } } + private suspend fun updateTxFeeStateIfNeededForCEX( + txFeeSealedState: TxFeeSealedState, + amount: SwapAmount, + fromTokenStatus: CryptoCurrencyStatus, + ): TxFeeSealedState { + return when (txFeeSealedState) { + is TxFeeSealedState.Component -> txFeeSealedState + is TxFeeSealedState.Legacy -> { + if (txFeeSealedState.txFeeState is TxFeeState.Empty) { + val txFeeResult = estimateFeeUseCase( + amount = amount.value, + userWallet = userWallet, + cryptoCurrencyStatus = fromTokenStatus, + ) + val txFee = getFeeForCex(txFeeResult, fromTokenStatus) + + TxFeeSealedState.Legacy( + txFeeState = txFee, + selectedFee = txFeeSealedState.selectedFee, + ) + } else { + txFeeSealedState + } + } + } + } + @Suppress("LongMethod") private suspend fun getQuotesState( provider: SwapProvider, @@ -1366,10 +1481,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( networkId: String, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, - txFee: TxFeeState, - transactionFee: TransactionFee?, + txFeeSealedState: TxFeeSealedState, includeFeeInAmount: IncludeFeeInAmount, - selectedFee: FeeType, ): SwapState { return quoteDataModel.fold( ifRight = { quoteModel -> @@ -1382,23 +1495,35 @@ internal class SwapInteractorImpl @AssistedInject constructor( fromTokenAmount = amount, toTokenAmount = quoteModel.toTokenAmount, swapData = null, - txFeeState = txFee, + txFeeSealedState = txFeeSealedState, provider = provider, ).copy( currencyCheck = manageWarnings( fromTokenStatus = fromToken, amount = amount, - feeState = txFee, - selectedFee = selectedFee, + txFeeSealed = txFeeSealedState, includeFeeInAmount = includeFeeInAmount, ), validationResult = manageTransactionValidationWarnings( fromToken = fromToken, amount = amount, - feeState = txFee, + txFeeSealedState = txFeeSealedState, userWalletId = userWalletId, ), - minAdaValue = (transactionFee?.normal as? Fee.CardanoToken)?.minAdaValue, + minAdaValue = when (txFeeSealedState) { + is TxFeeSealedState.Component -> { + (txFeeSealedState.txFee.fee as? Fee.CardanoToken)?.minAdaValue + } + is TxFeeSealedState.Legacy -> { + when (txFeeSealedState.txFeeState) { + TxFeeState.Empty -> null + is TxFeeState.MultipleFeeState -> + (txFeeSealedState.txFeeState.normalFee.fee as? Fee.CardanoToken)?.minAdaValue + is TxFeeState.SingleFeeState -> + (txFeeSealedState.txFeeState.fee.fee as? Fee.CardanoToken)?.minAdaValue + } + } + }, ) when (provider.type) { @@ -1423,11 +1548,17 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } ExchangeProviderType.CEX -> { - val fee = when (txFee) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue - is TxFeeState.SingleFeeState -> txFee.fee.feeValue + val fee = when (txFeeSealedState) { + is TxFeeSealedState.Component -> txFeeSealedState.txFee.fee.amount.value + is TxFeeSealedState.Legacy -> { + when (val txFee = txFeeSealedState.txFeeState) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> txFee.priorityFee.fee.amount.value + is TxFeeState.SingleFeeState -> txFee.fee.fee.amount.value + } + } } + val feeState = getFeeState( fee = fee, spendAmount = amount, @@ -1477,20 +1608,76 @@ internal class SwapInteractorImpl @AssistedInject constructor( return SwapState.SwapError(fromTokenSwapInfo, expressDataError, includeFeeInAmount) } - @Suppress("CyclomaticComplexMethod") + @Suppress("CyclomaticComplexMethod", "NestedBlockDepth", "CastNullableToNonNullableType") private suspend fun getIncludeFeeInAmount( networkId: String, - txFee: TxFeeState, + amount: SwapAmount, + reduceBalanceBy: BigDecimal, + fromToken: CryptoCurrencyStatus, + txFeeSealedState: TxFeeSealedState, + ): IncludeFeeInAmount { + return when (txFeeSealedState) { + is TxFeeSealedState.Component -> { + if (fromToken.currency.id == txFeeSealedState.txFee.selectedToken?.currency?.id) { + val fee = txFeeSealedState.txFee.fee.amount.value ?: BigDecimal.ZERO + if (txFeeSealedState.txFee.selectedToken.currency is CryptoCurrency.Coin) { + getIncludeFeeInAmountForNative( + networkId = networkId, + amount = amount, + reduceBalanceBy = reduceBalanceBy, + fromToken = fromToken.currency, + feeValue = fee, + ) + } else { + // we have a token selected for fee payment the same as sending token + val reducedBalance = fromToken.value.amount as BigDecimal - reduceBalanceBy + when { + amount.value > reducedBalance -> IncludeFeeInAmount.BalanceNotEnough + amount.value + fee <= reducedBalance -> IncludeFeeInAmount.Excluded + else -> { + if (fee < amount.value) { + IncludeFeeInAmount.Included( + amountSubtractFee = SwapAmount( + value = reducedBalance - fee, + decimals = fromToken.currency.decimals, + ), + ) + } else { + IncludeFeeInAmount.Excluded + } + } + } + } + } else { + IncludeFeeInAmount.Excluded + } + } + is TxFeeSealedState.Legacy -> { + val feeValue = when (val txFee = txFeeSealedState.txFeeState) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> txFee.getFeeByType( + txFeeSealedState.selectedFee, + ).feeIncludeOtherNativeFee + is TxFeeState.SingleFeeState -> txFee.fee.feeIncludeOtherNativeFee + } + getIncludeFeeInAmountForNative( + networkId = networkId, + amount = amount, + reduceBalanceBy = reduceBalanceBy, + fromToken = fromToken.currency, + feeValue = feeValue, + ) + } + } + } + + private suspend fun getIncludeFeeInAmountForNative( + networkId: String, amount: SwapAmount, reduceBalanceBy: BigDecimal, fromToken: CryptoCurrency, - selectedFee: FeeType, + feeValue: BigDecimal, ): IncludeFeeInAmount { - val feeValue = when (txFee) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> txFee.getFeeByType(selectedFee).feeIncludeOtherNativeFee - is TxFeeState.SingleFeeState -> txFee.fee.feeIncludeOtherNativeFee - } val feePaidCurrency = getFeePaidCurrency( currency = fromToken, ) @@ -1590,7 +1777,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toToken: CryptoCurrencyStatus, toAccount: Account.CryptoPortfolio?, amount: SwapAmount, - selectedFee: FeeType, + txFeeSealedState: TxFeeSealedState, expressOperationType: ExpressOperationType, ): SwapState { val fromNetworkAddress = fromToken.value.networkAddress @@ -1621,37 +1808,28 @@ internal class SwapInteractorImpl @AssistedInject constructor( ?.movePointLeft(nativeCoinDecimals) ?: BigDecimal.ZERO - val txFeeState = if (isSolana(networkId)) { - val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP) - - val formattedHash = getFormattedHash(transactionBytes) - - if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES && userWallet is UserWallet.Cold) { - return produceDexSwapDataError( - error = ExpressDataError.TooLargeSolanaTransactionError, - fromToken = fromToken, - fromAccount = fromAccount, - amount = amount, - ) - } - - getFeeDataForSolanaDexSwap( - network = fromToken.currency.network, - transactionBytes = transactionBytes, + val txFeeState = loadFeeForDex( + networkId = networkId, + transaction = transaction, + fromToken = fromToken, + ).getOrElse { error -> + return@fold produceDexSwapDataError( + error = error, + fromToken = fromToken, + fromAccount = fromAccount, + amount = amount, ) - .toTxFeeState(fromToken.currency, otherNativeFee) - } else { - getFeeDataForDexSwap( - network = fromToken.currency.network, - transaction = transaction, - fromToken = fromToken.currency, - ) - .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) - .toTxFeeState(fromToken.currency, otherNativeFee) - } + }.toTxFeeState(fromToken.currency, otherNativeFee) val includeFeeInAmount = IncludeFeeInAmount.Excluded // exclude for dex - val feeByPriority = selectFeeByType(feeType = selectedFee, txFeeState = txFeeState) + val feeByPriority = when (txFeeSealedState) { + is TxFeeSealedState.Component -> { + txFeeSealedState.txFee.fee.amount.value ?: BigDecimal.ZERO + } + is TxFeeSealedState.Legacy -> { + selectFeeByType(feeType = txFeeSealedState.selectedFee, txFeeState = txFeeState) + } + } val feeToCheckFunds = feeByPriority + (otherNativeFee ?: BigDecimal.ZERO) val isBalanceIncludeFeeEnough = isBalanceEnough(fromToken, amount, feeToCheckFunds) val feeState = getFeeState( @@ -1676,7 +1854,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( fromTokenAmount = amount, toTokenAmount = swapData.toTokenAmount, swapData = swapData, - txFeeState = txFeeState, + txFeeSealedState = txFeeSealedState, provider = provider, ) swapState.copy( @@ -1684,14 +1862,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( currencyCheck = manageWarnings( fromTokenStatus = fromToken, amount = amount, - feeState = txFeeState, - selectedFee = selectedFee, + txFeeSealed = txFeeSealedState, includeFeeInAmount = includeFeeInAmount, ), validationResult = manageTransactionValidationWarnings( fromToken = fromToken, amount = amount, - feeState = txFeeState, + txFeeSealedState = txFeeSealedState, userWalletId = userWalletId, ), preparedSwapConfigState = preparedSwapConfigState, @@ -1708,6 +1885,36 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } + private suspend fun loadFeeForDex( + networkId: String, + transaction: ExpressTransactionModel.DEX, + fromToken: CryptoCurrencyStatus, + ): Either = either { + if (isSolana(networkId)) { + val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP) + + val formattedHash = getFormattedHash(transactionBytes) + + if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES && userWallet is UserWallet.Cold) { + raise(ExpressDataError.TooLargeSolanaTransactionError) + } + + getFeeDataForSolanaDexSwap( + network = fromToken.currency.network, + transactionBytes = transactionBytes, + ) + } else { + getFeeDataForDexSwap( + network = fromToken.currency.network, + transaction = transaction, + fromToken = fromToken.currency, + ).map { fee -> + (fee as TransactionFeeResult.Loaded).fee + .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) + }.bind() + } + } + private suspend fun produceDexSwapDataError( error: ExpressDataError, fromToken: CryptoCurrencyStatus, @@ -1729,18 +1936,27 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } + @Suppress("CyclomaticComplexMethod") private suspend fun getFeeDataForDexSwap( network: Network, transaction: ExpressTransactionModel.DEX, fromToken: CryptoCurrency, - ): TransactionFee { - return try { + selectedToken: CryptoCurrencyStatus? = null, + ): Either = either { + val nativeBalance = userWalletManager.getNativeTokenBalance( + networkId = network.backendId, + derivationPath = fromToken.network.derivationPath.value, + ) ?: ProxyAmount.empty() + + // if native balance is zero - we can't calculate fee + if (nativeBalance.value.signum() == 0) { + raise(ExpressDataError.UnknownError) + } + + try { val txAmountValue = transaction.txValue ?: error("unable to get txValue") - val nativeBalance = userWalletManager.getNativeTokenBalance( - networkId = network.backendId, - derivationPath = fromToken.network.derivationPath.value, - ) ?: ProxyAmount.empty() val amountToSend = createNativeAmountForDex(txAmountValue, fromToken.network) + // transaction.txValue is always native coin if (nativeBalance.value < amountToSend.value) { error("It's impossible to calculate fee for nativeBalance.value < amountToSend.value") @@ -1758,17 +1974,27 @@ internal class SwapInteractorImpl @AssistedInject constructor( sourceAddress = transaction.txFrom, extras = extras, ) - getFeeUseCase( - transactionData = transactionData, - network = network, - userWallet = userWallet, - ).getOrNull() ?: error("unable to calculate fee") + if (selectedToken != null && selectedToken.currency is CryptoCurrency.Token) { + getFeeForTokenUseCase( + transactionData = transactionData, + token = selectedToken.currency, + userWallet = userWallet, + ).getOrNull()?.let { TransactionFeeResult.LoadedExtended(it) } + ?: error("unable to calculate fee for token") + } else { + getFeeUseCase( + transactionData = transactionData, + network = network, + userWallet = userWallet, + ).getOrNull()?.let { TransactionFeeResult.Loaded(it) } ?: error("unable to calculate fee") + } } catch (_: IllegalStateException) { getEthSpecificFeeUseCase( userWallet = userWallet, cryptoCurrency = fromToken, gasLimit = transaction.gas, - ).getOrNull() ?: error("can't get fee for getEthSpecificFeeUseCase") + ).getOrNull()?.let { TransactionFeeResult.Loaded(it) } + ?: error("can't get fee for getEthSpecificFeeUseCase") } } @@ -1784,7 +2010,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ).getOrNull() ?: error("unable to calculate fee") } - @Suppress("LongParameterList") + @Suppress("LongParameterList", "MaxChainedCallsOnSameLine") private suspend fun updateBalances( provider: SwapProvider, networkId: String, @@ -1795,7 +2021,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( fromTokenAmount: SwapAmount, toTokenAmount: SwapAmount, swapData: SwapDataModel?, - txFeeState: TxFeeState, + txFeeSealedState: TxFeeSealedState, ): SwapState.QuotesLoadedState { val fromToken = fromTokenStatus.currency val toToken = toTokenStatus.currency @@ -1823,8 +2049,18 @@ internal class SwapInteractorImpl @AssistedInject constructor( toRate = rates[toToken.id]?.toDouble() ?: 0.0, ), swapDataModel = swapData, - txFee = txFeeState, swapProvider = provider, + txFee = when (txFeeSealedState) { + is TxFeeSealedState.Component -> { + when (txFeeSealedState.txFee.transactionFeeResult) { + is TransactionFeeResult.Loaded -> + txFeeSealedState.txFee.transactionFeeResult.fee.toTxFeeState(fromToken, null) + is TransactionFeeResult.LoadedExtended -> + txFeeSealedState.txFee.transactionFeeResult.fee.transactionFee.toTxFeeState(fromToken, null) + } + } + is TxFeeSealedState.Legacy -> txFeeSealedState.txFeeState + }, minAdaValue = null, ) } @@ -1843,18 +2079,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) ?: TxFeeState.Empty } - private suspend fun getUnhandledFee( - amount: BigDecimal, - userWallet: UserWallet, - cryptoCurrencyStatus: CryptoCurrencyStatus, - ): Either { - return estimateFeeUseCase( - amount = amount, - userWallet = userWallet, - cryptoCurrencyStatus = cryptoCurrencyStatus, - ) - } - @Suppress("LongParameterList", "LongMethod") private suspend fun updatePermissionState( networkId: String, @@ -1929,8 +2153,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( val fee = when (feeState) { TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> feeState.normalFee.feeValue - is TxFeeState.SingleFeeState -> feeState.fee.feeValue + is TxFeeState.MultipleFeeState -> feeState.normalFee.fee.amount.value + is TxFeeState.SingleFeeState -> feeState.fee.fee.amount.value } val swapFeeState = getFeeState( fee = fee, @@ -1994,7 +2218,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) // endregion TxFeeState.MultipleFeeState( - normalFee = TxFee( + normalFee = TxFee.Legacy( feeValue = feeNormal, feeFiatFormatted = normalFiatValue, feeCryptoFormatted = normalCryptoFee, @@ -2005,7 +2229,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( feeType = FeeType.NORMAL, fee = this.normal, ), - priorityFee = TxFee( + priorityFee = TxFee.Legacy( feeValue = feePriority, feeFiatFormatted = priorityFiatValue, feeCryptoFormatted = priorityCryptoFee, @@ -2035,7 +2259,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) // endregion TxFeeState.SingleFeeState( - fee = TxFee( + fee = TxFee.Legacy( feeValue = this.normal.amount.value ?: BigDecimal.ZERO, feeFiatFormatted = normalFiatValue, feeCryptoFormatted = normalCryptoFee, @@ -2088,6 +2312,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private fun Fee.increaseEthGasLimitInNeeded(increaseBy: Int): Fee { return when (this) { + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") is Fee.Ethereum.EIP1559, is Fee.Ethereum.Legacy, -> this.increaseGasLimitBy(increaseBy) @@ -2123,6 +2348,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( return when (this) { is Fee.Ethereum.EIP1559 -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) is Fee.Ethereum.Legacy -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") } } @@ -2143,12 +2369,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): BigDecimal { return when (txFeeState) { TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.SingleFeeState -> txFeeState.fee.feeValue + is TxFeeState.SingleFeeState -> txFeeState.fee.fee.amount.value is TxFeeState.MultipleFeeState -> when (feeType) { - FeeType.NORMAL -> txFeeState.normalFee.feeValue - FeeType.PRIORITY -> txFeeState.priorityFee.feeValue + FeeType.NORMAL -> txFeeState.normalFee.fee.amount.value + FeeType.PRIORITY -> txFeeState.priorityFee.fee.amount.value } - } + } ?: BigDecimal.ZERO } private suspend fun isBalanceEnough( @@ -2376,4 +2602,24 @@ internal class SwapInteractorImpl @AssistedInject constructor( interface Factory : SwapInteractor.Factory { override fun create(selectedWalletId: UserWalletId): SwapInteractorImpl } +} + +sealed class TxFeeSealedState { + class Legacy(val txFeeState: TxFeeState, val selectedFee: FeeType) : TxFeeSealedState() + class Component(val txFee: TxFee.FeeComponent) : TxFeeSealedState() + + fun getTxFeeStateOrNull() = when (this) { + is Component -> null + is Legacy -> txFeeState + } +} + +sealed class TransactionFeeResult { + class Loaded(val fee: TransactionFee) : TransactionFeeResult() + class LoadedExtended(val fee: TransactionFeeExtended) : TransactionFeeResult() + + companion object { + fun from(fee: TransactionFee) = Loaded(fee) + fun from(fee: TransactionFeeExtended) = LoadedExtended(fee) + } } \ No newline at end of file diff --git a/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt similarity index 87% rename from features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt index 7473ad49ae..fedf814baa 100644 --- a/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt @@ -6,7 +6,12 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.swap.domain.models.ExpressDataError -import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel +import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo +import com.tangem.feature.swap.domain.models.domain.PairsWithProviders +import com.tangem.feature.swap.domain.models.domain.QuoteModel +import com.tangem.feature.swap.domain.models.domain.RateType +import com.tangem.feature.swap.domain.models.domain.SwapDataModel import java.math.BigDecimal interface SwapRepository { diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ExpressDataError.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ExpressDataError.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ExpressDataError.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ExpressDataError.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ExpressException.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ExpressException.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ExpressException.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ExpressException.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/SwapAmount.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/SwapAmount.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/SwapAmount.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/SwapAmount.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PairsWithProviders.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PairsWithProviders.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PairsWithProviders.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PairsWithProviders.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt similarity index 80% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 7c76557067..c735c821d4 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -4,6 +4,7 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.* @@ -91,11 +92,11 @@ data class RequestApproveStateData( sealed class TxFeeState { data class MultipleFeeState( - val normalFee: TxFee, - val priorityFee: TxFee, + val normalFee: TxFee.Legacy, + val priorityFee: TxFee.Legacy, ) : TxFeeState() { - fun getFeeByType(feeType: FeeType): TxFee { + fun getFeeByType(feeType: FeeType): TxFee.Legacy { return when (feeType) { FeeType.NORMAL -> normalFee FeeType.PRIORITY -> priorityFee @@ -104,23 +105,33 @@ sealed class TxFeeState { } data class SingleFeeState( - val fee: TxFee, + val fee: TxFee.Legacy, ) : TxFeeState() data object Empty : TxFeeState() } -data class TxFee( - val feeValue: BigDecimal, - val feeFiatFormatted: String, - val feeCryptoFormatted: String, - val feeIncludeOtherNativeFee: BigDecimal, - val feeFiatFormattedWithNative: String, - val feeCryptoFormattedWithNative: String, - val cryptoSymbol: String, - val feeType: FeeType, - val fee: Fee, -) +sealed class TxFee { + abstract val fee: Fee + + data class FeeComponent( + override val fee: Fee, + val transactionFeeResult: TransactionFeeResult, + val selectedToken: CryptoCurrencyStatus?, + ) : TxFee() + + data class Legacy( + val feeValue: BigDecimal, + val feeFiatFormatted: String, + val feeCryptoFormatted: String, + val feeIncludeOtherNativeFee: BigDecimal, + val feeFiatFormattedWithNative: String, + val feeCryptoFormattedWithNative: String, + val cryptoSymbol: String, + val feeType: FeeType, + override val fee: Fee, + ) : TxFee() +} enum class FeeType { NORMAL, PRIORITY diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt similarity index 100% rename from features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index 9be6f2453e..488c083c0e 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -23,6 +23,11 @@ dependencies { implementation(projects.common.ui) implementation(projects.core.decompose) // For Route supertype implementation(projects.core.error) + implementation(projects.common.uiMarkets) + implementation(projects.common.uiCharts) + + /** Data modules */ + implementation(projects.data.common) /** Domain modules **/ implementation(projects.domain.models) @@ -48,6 +53,7 @@ dependencies { implementation(projects.domain.account) implementation(projects.domain.account.status) implementation(projects.domain.visa) + implementation(projects.domain.markets) /** Feature modules */ implementation(projects.features.swap.domain) @@ -55,6 +61,9 @@ dependencies { implementation(projects.features.swap.domain.models) implementation(projects.features.wallet.api) implementation(projects.features.swap.api) + implementation(projects.features.sendV2.api) + implementation(projects.features.sendV2.impl) + implementation(projects.features.feed.api) /** AndroidX */ implementation(deps.androidx.activity.compose) @@ -84,6 +93,10 @@ dependencies { implementation(deps.kotlin.serialization) implementation(deps.kotlin.immutable.collections) implementation(deps.timber) + implementation(deps.decompose.ext.compose) + + /** Tangem libs */ + implementation(tangemDeps.blockchain) /** DI */ implementation(deps.hilt.android) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 65c5f62d82..3cde40dc0c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -3,17 +3,35 @@ package com.tangem.feature.swap import androidx.compose.animation.Crossfade import androidx.compose.foundation.background import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss import com.arkivanov.essenty.lifecycle.subscribe import com.tangem.common.ui.swapStoriesScreen.SwapStoriesScreen import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.model.SwapModel +import com.tangem.feature.swap.models.AddToPortfolioRoute +import com.tangem.feature.swap.models.SwapCardState.SwapCardData import com.tangem.feature.swap.router.SwapNavScreen import com.tangem.feature.swap.ui.SwapScreen import com.tangem.feature.swap.ui.SwapSelectTokenScreen import com.tangem.feature.swap.ui.SwapSuccessScreen +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.send.v2.api.SendFeatureToggles +import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents import com.tangem.features.swap.SwapComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -22,11 +40,22 @@ import dagger.assisted.AssistedInject @Suppress("UnusedPrivateMember") internal class DefaultSwapComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, - @Assisted params: SwapComponent.Params, + @Assisted private val params: SwapComponent.Params, + private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory, + private val sendFeatureToggles: SendFeatureToggles, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, ) : SwapComponent, AppComponentContext by appComponentContext { private val model: SwapModel = getOrCreateModel(params) + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = AddToPortfolioRoute.serializer(), + key = BOTTOM_SHEET_SLOT_KEY, + handleBackButton = false, + childFactory = { configuration, context -> bottomSheetChild(context) }, + ) + init { lifecycle.subscribe( onStart = model::onStart, @@ -34,8 +63,73 @@ internal class DefaultSwapComponent @AssistedInject constructor( ) } + val slotNavigation = SlotNavigation() + val childSlot = childSlot( + source = slotNavigation, + serializer = null, + key = FEE_SELECTOR_SLOT_KEY, + childFactory = { config, context -> + createSwapFeeSelectorBlockComponent( + context = childByContext(context), + config = config, + ) + }, + ) + + private fun createSwapFeeSelectorBlockComponent( + context: AppComponentContext, + config: FeeSelectorConfig, + ): SwapFeeSelectorBlockComponent { + return swapFeeSelectorBlockComponentFactory.create( + context = context, + params = SwapFeeSelectorBlockComponent.Params( + repository = model.feeSelectorRepository, + userWalletId = params.userWalletId, + sendingCryptoCurrencyStatus = config.sendingCurrencyStatus, + feeCryptoCurrencyStatus = config.feeCurrencyStatus, + analyticsParams = SwapFeeSelectorBlockComponent.AnalyticsParams( + analyticsCategoryName = CommonSendAnalyticEvents.SEND_CATEGORY, + analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Swap, + ), + ), + ) + } + + data class FeeSelectorConfig( + val sendingCurrencyStatus: CryptoCurrencyStatus, + val feeCurrencyStatus: CryptoCurrencyStatus, + ) + + @Suppress("LongMethod") @Composable override fun Content(modifier: Modifier) { + if (sendFeatureToggles.isGaslessTransactionsEnabled) { + val sendCardData = model.uiState.sendCardData as? SwapCardData + val feePaidCryptoCurrency = model.dataState.feePaidCryptoCurrency + LaunchedEffect(sendCardData?.token?.currency, feePaidCryptoCurrency?.currency) { + val sendingCryptoCurrencyStatus = sendCardData?.token ?: run { + slotNavigation.dismiss() + return@LaunchedEffect + } + + val feeCurrencyStatus = feePaidCryptoCurrency ?: run { + slotNavigation.dismiss() + return@LaunchedEffect + } + + slotNavigation.activate( + FeeSelectorConfig( + sendingCurrencyStatus = sendingCryptoCurrencyStatus, + feeCurrencyStatus = feeCurrencyStatus, + ), + ) + } + } + + val feeSelectorChildStackState by childSlot.subscribeAsState() + val feeSelectorBlockComponent = feeSelectorChildStackState.child?.instance + val bottomSheet by bottomSheetSlot.subscribeAsState() + Crossfade( modifier = Modifier.background(TangemTheme.colors.background.secondary), targetState = model.currentScreen, @@ -47,16 +141,30 @@ internal class DefaultSwapComponent @AssistedInject constructor( if (storiesConfig != null) { SwapStoriesScreen(config = storiesConfig) } else { - SwapScreen(stateHolder = model.uiState) + SwapScreen( + stateHolder = model.uiState, + feeSelectorBlockComponent = feeSelectorBlockComponent, + ) } } - SwapNavScreen.Main -> SwapScreen(stateHolder = model.uiState) + SwapNavScreen.Main -> SwapScreen( + stateHolder = model.uiState, + feeSelectorBlockComponent = feeSelectorBlockComponent, + ) SwapNavScreen.Success -> { val successState = model.uiState.successState + val feeSelectorState by model.feeSelectorRepository.state.collectAsStateWithLifecycle() if (successState != null) { - SwapSuccessScreen(state = successState, model.uiState.onBackClicked) + SwapSuccessScreen( + state = successState, + feeSelectorUM = feeSelectorState, + onBack = model.uiState.onBackClicked, + ) } else { - SwapScreen(stateHolder = model.uiState) + SwapScreen( + stateHolder = model.uiState, + feeSelectorBlockComponent = feeSelectorBlockComponent, + ) } } SwapNavScreen.SelectToken -> { @@ -64,15 +172,37 @@ internal class DefaultSwapComponent @AssistedInject constructor( if (tokenState != null) { SwapSelectTokenScreen(state = tokenState, onBack = model.uiState.onBackClicked) } else { - SwapScreen(stateHolder = model.uiState) + SwapScreen( + stateHolder = model.uiState, + feeSelectorBlockComponent = feeSelectorBlockComponent, + ) } } } } + + bottomSheet.child?.instance?.BottomSheet() + } + + @Suppress("UnsafeCallOnNullableType") + private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent { + return addToPortfolioComponentFactory.create( + context = childByContext(componentContext), + params = AddToPortfolioComponent.Params( + addToPortfolioManager = model.addToPortfolioManager!!, + callback = model.addToPortfolioCallback, + shouldSkipTokenActionsScreen = true, + ), + ) } @AssistedFactory interface Factory : SwapComponent.Factory { override fun create(context: AppComponentContext, params: SwapComponent.Params): DefaultSwapComponent } + + private companion object { + const val BOTTOM_SHEET_SLOT_KEY = "bottomSheetSlot" + const val FEE_SELECTOR_SLOT_KEY = "feeSelectorSlot" + } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index c202111fb6..60d50e49f0 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -1,5 +1,11 @@ package com.tangem.feature.swap +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.swap.SwapFeatureToggles -internal class DefaultSwapFeatureToggles : SwapFeatureToggles \ No newline at end of file +internal class DefaultSwapFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : SwapFeatureToggles { + override val isMarketListFeatureEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("SWAP_MARKET_LIST_ENABLED") +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt new file mode 100644 index 0000000000..19e1760238 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt @@ -0,0 +1,99 @@ +package com.tangem.feature.swap.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import arrow.core.Either +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.features.send.v2.api.FeeSelectorBlockComponent +import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.api.params.FeeSelectorParams +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class SwapFeeSelectorBlockComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: Params, + feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val feeSelectorBlockComponent = + feeSelectorBlockComponentFactory.create( + context = child("swapFeeSelectorBlock"), + params = FeeSelectorParams.FeeSelectorBlockParams( + state = params.repository.state.value, + userWalletId = params.userWalletId, + onLoadFee = params.repository::loadFee, + onLoadFeeExtended = if (params.repository is ModelRepositoryExtended) { + params.repository::loadFeeExtended + } else { + null + }, + feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen, + feeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.ExcludeLow, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, + cryptoCurrencyStatus = params.sendingCryptoCurrencyStatus, + analyticsCategoryName = params.analyticsParams.analyticsCategoryName, + analyticsSendSource = params.analyticsParams.analyticsSendSource, + bottomSheetShown = params.repository::choosingInProgress, + ), + onResult = params.repository::onResult, + ) + + init { + params.repository.state + .onEach(feeSelectorBlockComponent::updateState) + .launchIn(componentScope) + } + + @Composable + override fun Content(modifier: Modifier) { + feeSelectorBlockComponent.Content(modifier = modifier) + } + + interface ModelRepository { + val state: StateFlow + get() = MutableStateFlow(FeeSelectorUM.Loading) + + fun onResult(newState: FeeSelectorUM) + + suspend fun loadFee(): Either + + fun choosingInProgress(updatedState: Boolean) + } + + interface ModelRepositoryExtended : ModelRepository { + suspend fun loadFeeExtended( + selectedToken: CryptoCurrencyStatus? = null, + ): Either + } + + class AnalyticsParams( + val analyticsCategoryName: String, + val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, + ) + + class Params( + val userWalletId: UserWalletId, + val sendingCryptoCurrencyStatus: CryptoCurrencyStatus, + val feeCryptoCurrencyStatus: CryptoCurrencyStatus, + val analyticsParams: AnalyticsParams, + val repository: ModelRepository, + ) + + @AssistedFactory + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokenMarketInfoToParamsConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokenMarketInfoToParamsConverter.kt new file mode 100644 index 0000000000..992ad0521b --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokenMarketInfoToParamsConverter.kt @@ -0,0 +1,26 @@ +package com.tangem.feature.swap.converters + +import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.utils.converter.Converter + +internal class TokenMarketInfoToParamsConverter : Converter { + + override fun convert(value: TokenMarketInfo): TokenMarketParams { + val tokenId = CryptoCurrency.RawID(value.id) + return TokenMarketParams( + id = tokenId, + name = value.name, + symbol = value.symbol, + tokenQuotes = TokenMarketParams.Quotes( + currentPrice = value.quotes.currentPrice, + h24Percent = value.quotes.h24ChangePercent, + weekPercent = value.quotes.weekChangePercent, + monthPercent = value.quotes.monthChangePercent, + ), + imageUrl = getTokenIconUrlFromDefaultHost(tokenId), + ) + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt index 90dd691aaa..af538747a6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverterV2.kt @@ -31,32 +31,44 @@ internal class TokensDataConverterV2( override fun transform(prevState: SwapStateHolder): SwapStateHolder { val accountList = tokensDataState.accountCurrencyList + val currentMarketsState = prevState.selectTokenState?.marketsState return prevState.copy( selectTokenState = SwapSelectTokenStateHolder( availableTokens = persistentListOf(), unavailableTokens = persistentListOf(), tokensListData = if (isAccountsMode) { + val portfolioList = accountListItemConverter.convertList(accountList).toPersistentList() + val totalTokensCount = portfolioList.sumOf { it.tokens.size } TokenListUMData.AccountList( - tokensList = accountListItemConverter.convertList(accountList).toPersistentList(), + tokensList = portfolioList, + totalTokensCount = totalTokensCount, ) } else { - TokenListUMData.TokenList( - tokensList = persistentListOf( - TokensListItemUM.GroupTitle( - id = "available_tokens_title", - text = resourceReference(R.string.exchange_tokens_available_tokens_header), - ), - ) + accountList.flatMap { (_, currencyList) -> - currencyList.asSequence().map { accountSwapCurrency -> - if (accountSwapCurrency.isAvailable) { - accountListItemConverter.createAvailableItemConverter() - } else { - accountListItemConverter.createUnavailableItemConverter() - }.convert(accountSwapCurrency.cryptoCurrencyStatus) - }.map(TokensListItemUM::Token).toPersistentList() - }.toPersistentList(), - ) + val tokensList = accountList.flatMap { (_, currencyList) -> + currencyList.asSequence().map { accountSwapCurrency -> + if (accountSwapCurrency.isAvailable) { + accountListItemConverter.createAvailableItemConverter() + } else { + accountListItemConverter.createUnavailableItemConverter() + }.convert(accountSwapCurrency.cryptoCurrencyStatus) + }.map(TokensListItemUM::Token).toPersistentList() + }.toPersistentList() + + if (tokensList.isNotEmpty()) { + TokenListUMData.TokenList( + tokensList = persistentListOf( + TokensListItemUM.GroupTitle( + id = "available_tokens_title", + text = resourceReference(R.string.exchange_tokens_available_tokens_header), + ), + ) + tokensList, + totalTokensCount = tokensList.size, + ) + } else { + TokenListUMData.EmptyList + } }, + marketsState = currentMarketsState, onSearchEntered = onSearchEntered, onTokenSelected = onTokenSelected, isBalanceHidden = isBalanceHidden, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt index 990f8c8ec9..5cf4ea502f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.di +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.feature.swap.DefaultSwapComponent import com.tangem.feature.swap.DefaultSwapFeatureToggles import com.tangem.features.swap.SwapComponent @@ -17,8 +18,8 @@ internal object SwapFeatureModule { @Provides @Singleton - fun provideSwapFeatureToggles(): SwapFeatureToggles { - return DefaultSwapFeatureToggles() + fun provideSwapFeatureToggles(featureTogglesManager: FeatureTogglesManager): SwapFeatureToggles { + return DefaultSwapFeatureToggles(featureTogglesManager) } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 38d591dd72..42b072c45d 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -6,9 +6,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import arrow.core.Either import arrow.core.getOrElse +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.routing.AppRouter import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState.InProgress.getApproveTypeOrNull +import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam @@ -27,16 +33,20 @@ import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase +import com.tangem.domain.markets.GetTokenMarketInfoUseCase import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.promo.GetStoryContentUseCase @@ -51,25 +61,44 @@ import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.swap.analytics.StoriesEvents import com.tangem.feature.swap.analytics.SwapEvents +import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent +import com.tangem.feature.swap.converters.TokenMarketInfoToParamsConverter import com.tangem.feature.swap.domain.SwapInteractor +import com.tangem.feature.swap.domain.TransactionFeeResult +import com.tangem.feature.swap.domain.TxFeeSealedState import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.ExpressException import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.* +import com.tangem.feature.swap.models.AddToPortfolioRoute +import com.tangem.feature.swap.models.SwapCardState import com.tangem.feature.swap.models.SwapStateHolder import com.tangem.feature.swap.models.UiActions +import com.tangem.feature.swap.models.market.SwapMarketsListBatchFlowManager +import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.feature.swap.presentation.R import com.tangem.feature.swap.router.SwapNavScreen import com.tangem.feature.swap.router.SwapRouter import com.tangem.feature.swap.ui.StateBuilder import com.tangem.feature.swap.utils.formatToUIRepresentation +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.send.v2.api.SendFeatureToggles +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.swap.SwapComponent +import com.tangem.features.swap.SwapFeatureToggles +import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP import com.tangem.utils.coroutines.* @@ -85,6 +114,7 @@ import java.text.DecimalFormat import java.text.NumberFormat import java.util.Locale import javax.inject.Inject +import kotlin.collections.filter typealias SuccessLoadedSwapData = Map @@ -118,6 +148,15 @@ internal class SwapModel @Inject constructor( private val accountsFeatureToggles: AccountsFeatureToggles, private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase, private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, + private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, + private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, + private val sendFeatureToggles: SendFeatureToggles, + private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, + swapFeatureToggles: SwapFeatureToggles, + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, + private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, + private val excludedBlockchains: ExcludedBlockchains, + private val getUserWalletsUseCase: GetWalletsUseCase, ) : Model() { private val params = paramsContainer.require() @@ -149,6 +188,7 @@ internal class SwapModel @Inject constructor( isBalanceHiddenProvider = Provider { isBalanceHidden }, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), isAccountsModeProvider = Provider { isAccountsMode }, + iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, ) private val inputNumberFormatter = @@ -157,9 +197,10 @@ internal class SwapModel @Inject constructor( ?: error("NumberFormat is not DecimalFormat"), ) private val amountDebouncer = Debouncer() + private val searchDebouncer = Debouncer() private val singleTaskScheduler = SingleTaskScheduler>() - private var dataState by mutableStateOf(SwapProcessDataState()) + var dataState by mutableStateOf(SwapProcessDataState()) var uiState: SwapStateHolder by mutableStateOf( stateBuilder.createInitialLoadingState( @@ -170,6 +211,8 @@ internal class SwapModel @Inject constructor( ) private set + val feeSelectorRepository = FeeSelectorRepository() + // shows currency order (direct - swap initial to selected, reversed = selected to initial) private var isOrderReversed = false private val lastAmount = mutableStateOf(INITIAL_AMOUNT) @@ -190,13 +233,52 @@ internal class SwapModel @Inject constructor( private val fromTokenBalanceJobHolder = JobHolder() private val toTokenBalanceJobHolder = JobHolder() + private val addToPortfolioJobHolder = JobHolder() private var isAmountChangedByUser: Boolean = false private var lastPermissionNotificationTokens: Pair? = null + private val searchQueryState = MutableStateFlow("") + private val visibleMarketItemIds = MutableStateFlow>(emptyList()) + private val searchMarketsListManager by lazy { + SwapMarketsListBatchFlowManager( + getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, + batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, + currentAppCurrency = Provider { selectedAppCurrencyFlow.value }, + currentSearchText = Provider { searchQueryState.value }, + modelScope = modelScope, + dispatchers = dispatchers, + ) + } + val currentScreen: SwapNavScreen get() = swapRouter.currentScreen + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val addToPortfolioCallback = object : AddToPortfolioComponent.Callback { + override fun onDismiss() = bottomSheetNavigation.dismiss() + + override fun onSuccess(addedToken: CryptoCurrency) { + modelScope.launch { + bottomSheetNavigation.dismiss() + uiState.selectTokenState?.let { currentSelectState -> + uiState = uiState.copy( + selectTokenState = currentSelectState.copy( + marketsState = null, + ), + ) + } + getAccountCurrencyStatusUseCase.invoke(userWalletId, addedToken) + .firstOrNull { + it.status.value is CryptoCurrencyStatus.Loaded + }?.let { (account, status) -> + applyAddedToken(status, account) + } + } + } + } + var addToPortfolioManager: AddToPortfolioManager? = null + init { userCountry = getUserCountryUseCase.invokeSync().getOrNull() ?: UserCountry.Other(Locale.getDefault().country) @@ -256,6 +338,63 @@ internal class SwapModel @Inject constructor( uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden) } .launchIn(modelScope) + + if (swapFeatureToggles.isMarketListFeatureEnabled) { + combine( + flow = searchQueryState + .onEach { searchQuery -> + searchMarketsListManager.reload(searchQuery) + }, + flow2 = searchMarketsListManager.uiItems, + flow3 = searchMarketsListManager.isInInitialLoadingErrorState, + flow4 = searchMarketsListManager.isSearchNotFoundState, + flow5 = searchMarketsListManager.totalCount.filterNotNull(), + ) { searchQuery, uiItems, isError, isSearchNotFound, total -> + when { + searchQuery.isEmpty() -> { + visibleMarketItemIds.value = emptyList() + null + } + isError -> SwapMarketState.LoadingError( + onRetryClicked = { searchMarketsListManager.reload(searchQuery) }, + ) + isSearchNotFound -> SwapMarketState.SearchNothingFound + uiItems.isEmpty() -> SwapMarketState.Loading + else -> SwapMarketState.Content( + items = uiItems, + loadMore = { searchMarketsListManager.loadMore() }, + onItemClick = { item -> + addToPortfolioItem(item) + }, + visibleIdsChanged = { visibleMarketItemIds.value = it }, + total = total, + ) + } + } + .distinctUntilChanged() + .onEach { marketsState -> + uiState.selectTokenState?.let { currentSelectState -> + uiState = uiState.copy( + selectTokenState = currentSelectState.copy( + marketsState = marketsState, + ), + ) + } + } + .launchIn(modelScope) + } + + modelScope.launch { + visibleMarketItemIds.mapNotNull { rawIDS -> + if (rawIDS.isNotEmpty()) { + searchMarketsListManager.getBatchKeysByItemIds(rawIDS) + } else { + null + } + }.distinctUntilChanged().collectLatest { visibleBatchKeys -> + searchMarketsListManager.loadCharts(visibleBatchKeys) + } + } } fun onStart() { @@ -281,6 +420,7 @@ internal class SwapModel @Inject constructor( analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(hasAvailableTokens = isAnyAvailableTokens)) } + @Suppress("LongMethod") private fun initTokens(isReverseFromTo: Boolean) { modelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { @@ -316,21 +456,22 @@ internal class SwapModel @Inject constructor( isReverseFromTo = isReverseFromTo, ) - (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = coin, - isFromCurrency = true, + val fromCryptoCurrency = if (isOrderReversed) { + dataState.toCryptoCurrency + } else { + dataState.fromCryptoCurrency + } + + fromCryptoCurrency?.let { cryptoCurrency -> + dataState = dataState.copy( + feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoCurrency, + ).getOrNull(), ) } - (dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = coin, - isFromCurrency = false, - ) - } + subscribeToCoinBalanceUpdatesIfNeeded() }.onFailure { error -> Timber.e(error) @@ -356,6 +497,47 @@ internal class SwapModel @Inject constructor( } } + private fun applyAddedToken(addedToken: CryptoCurrencyStatus, addedAccount: Account.CryptoPortfolio?) { + modelScope.launch { + runCatching(dispatchers.io) { + swapInteractor.getTokensDataState(initialCurrencyFrom) + }.onSuccess { state -> + updateTokensState(state) + + applyInitialTokenChoice( + state = state, + selectedCurrency = addedToken, + selectedAccount = addedAccount, + isReverseFromTo = isOrderReversed, + ) + + subscribeToCoinBalanceUpdatesIfNeeded() + + swapRouter.back() + }.onFailure { error -> + Timber.e(error) + } + } + } + + private fun subscribeToCoinBalanceUpdatesIfNeeded() { + (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> + subscribeToCoinBalanceUpdates( + userWalletId = userWalletId, + coin = coin, + isFromCurrency = true, + ) + } + + (dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> + subscribeToCoinBalanceUpdates( + userWalletId = userWalletId, + coin = coin, + isFromCurrency = false, + ) + } + } + private fun initStories() { modelScope.launch { getStoryContentUseCase.invokeSync(StoryContentIds.STORY_FIRST_TIME_SWAP.id).fold( @@ -446,6 +628,7 @@ internal class SwapModel @Inject constructor( reduceBalanceBy: BigDecimal, toProvidersList: List, isSilent: Boolean = false, + updateFeeBlock: Boolean = true, ) { singleTaskScheduler.cancelTask() if (!isSilent) { @@ -457,6 +640,7 @@ internal class SwapModel @Inject constructor( toAccount = toAccount, mainTokenId = initialCurrencyFrom.id.value, ) + feeSelectorRepository.state.value = FeeSelectorUM.Loading } singleTaskScheduler.scheduleTask( modelScope, @@ -468,11 +652,12 @@ internal class SwapModel @Inject constructor( amount = amount, reduceBalanceBy = reduceBalanceBy, toProvidersList = toProvidersList, + updateFeeBlock = updateFeeBlock, ), ) } - private fun startLoadingQuotesFromLastState(isSilent: Boolean = false) { + private fun startLoadingQuotesFromLastState(isSilent: Boolean = false, updateFeeBlock: Boolean = true) { val fromCurrency = dataState.fromCryptoCurrency val toCurrency = dataState.toCryptoCurrency val amount = dataState.amount @@ -486,6 +671,7 @@ internal class SwapModel @Inject constructor( isSilent = isSilent, reduceBalanceBy = dataState.reduceBalanceBy, toProvidersList = findSwapProviders(fromCurrency, toCurrency), + updateFeeBlock = updateFeeBlock, ) } } @@ -498,6 +684,7 @@ internal class SwapModel @Inject constructor( amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, + updateFeeBlock: Boolean = true, ): PeriodicTask> { return PeriodicTask( delay = UPDATE_DELAY, @@ -518,7 +705,7 @@ internal class SwapModel @Inject constructor( providers = toProvidersList, amountToSwap = amount, reduceBalanceBy = reduceBalanceBy, - selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL, + txFeeSealedState = getSelectedFeeState(), ) } }, @@ -534,12 +721,17 @@ internal class SwapModel @Inject constructor( tokenSwapInfoForProviders = successStates.entries .associate { it.key.providerId to it.value.toTokenInfo }, ) + if (updateFeeBlock) { + modelScope.launch { feeSelectorReloadTrigger.triggerUpdate() } + } } else { + feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) Timber.e("Accidentally empty quotes list") } }, onError = { error -> Timber.e("Error when loading quotes: $error") + feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) uiState = stateBuilder.addNotification(uiState, null) { startLoadingQuotesFromLastState() } }, ) @@ -579,7 +771,7 @@ internal class SwapModel @Inject constructor( swapProvider = provider, bestRatedProviderId = bestRatedProviderId, isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1, - selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL, + selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, isReverseSwapPossible = isReverseSwapPossible(), needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), hideFee = tangemPayInput?.isWithdrawal == true, @@ -721,8 +913,8 @@ internal class SwapModel @Inject constructor( } } - private fun updateOrSelectFee(state: SwapState.QuotesLoadedState): TxFee? { - val selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL + private fun updateOrSelectFee(state: SwapState.QuotesLoadedState): TxFee.Legacy? { + val selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL return when (val txFee = state.txFee) { TxFeeState.Empty -> null is TxFeeState.MultipleFeeState -> { @@ -749,7 +941,7 @@ internal class SwapModel @Inject constructor( return } val fromCurrency = requireNotNull(dataState.fromCryptoCurrency) - val fee = dataState.selectedFee + val fee = getSelectedFee() if (fee == null && tangemPayInput?.isWithdrawal != true) { makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) @@ -777,7 +969,10 @@ internal class SwapModel @Inject constructor( makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) return@onSuccess } - sendSuccessSwapEvent(fromCurrency.currency, fee.feeType) + sendSuccessSwapEvent( + fromCurrency.currency, + (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, + ) val url = getExplorerTransactionUrlUseCase( txHash = swapTransactionState.txHash, networkId = fromCurrency.currency.network.id, @@ -889,7 +1084,7 @@ internal class SwapModel @Inject constructor( private fun sendSuccessEvent() { val provider = dataState.selectedProvider ?: return - val fee = dataState.selectedFee?.feeType ?: return + val fee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL val fromCurrency = dataState.fromCryptoCurrency?.currency ?: return val toCurrency = dataState.toCryptoCurrency?.currency ?: return val fromDerivationIndex = dataState.fromAccount?.derivationIndex?.value @@ -949,6 +1144,7 @@ internal class SwapModel @Inject constructor( }.onSuccess { swapTransactionState -> when (swapTransactionState) { is SwapTransactionState.TxSent -> { + // TODO [REDACTED_TASK_KEY] gasless analytics sendApproveSuccessEvent(fromToken, feeForPermission.feeType, approveType) updateWalletBalance() uiState = stateBuilder.loadingPermissionState(uiState) @@ -984,8 +1180,10 @@ internal class SwapModel @Inject constructor( } private fun onSearchEntered(searchQuery: String) { - modelScope.launch(dispatchers.io) { - val tokenDataState = dataState.tokensDataState ?: return@launch + searchDebouncer.debounce(modelScope, DEBOUNCE_SEARCH_DELAY) { + searchQueryState.value = searchQuery + + val tokenDataState = dataState.tokensDataState ?: return@debounce val group = if (isOrderReversed) { tokenDataState.fromGroup } else { @@ -1162,12 +1360,14 @@ internal class SwapModel @Inject constructor( .onEach { (account, currencyStatus) -> Timber.d("${coin.id} balance is ${currencyStatus.value.amount ?: "null"}") - dataState = dataState.copy( - feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = currencyStatus, - ).getOrNull() ?: currencyStatus, - ) + if (isFromCurrency) { + dataState = dataState.copy( + feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = currencyStatus, + ).getOrNull() ?: currencyStatus, + ) + } uiState = when { isFromCurrency && currencyStatus.currency.id == dataState.fromCryptoCurrency?.currency?.id -> { @@ -1200,12 +1400,14 @@ internal class SwapModel @Inject constructor( .onEach { status -> Timber.d("${coin.id} balance is ${status.value.amount ?: "null"}") - dataState = dataState.copy( - feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = status, - ).getOrNull() ?: status, - ) + if (isFromCurrency) { + dataState = dataState.copy( + feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = status, + ).getOrNull() ?: status, + ) + } uiState = when { isFromCurrency && status.currency.id == dataState.fromCryptoCurrency?.currency?.id -> { @@ -1412,7 +1614,7 @@ internal class SwapModel @Inject constructor( uiState = stateBuilder.updateApproveType(uiState, approveType) }, onClickFee = { - val selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL + val selectedFee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL val txFeeState = dataState.getCurrentLoadedSwapState()?.txFee as? TxFeeState.MultipleFeeState ?: return@UiActions uiState = stateBuilder.showSelectFeeBottomSheet( @@ -1423,9 +1625,9 @@ internal class SwapModel @Inject constructor( uiState = stateBuilder.dismissBottomSheet(uiState) } }, - onSelectFeeType = { feeType -> + onSelectFeeType = { txFee -> uiState = stateBuilder.dismissBottomSheet(uiState) - dataState = dataState.copy(selectedFee = feeType) + dataState = dataState.copy(selectedFee = txFee) modelScope.launch(dispatchers.io) { startLoadingQuotesFromLastState(false) } @@ -1447,6 +1649,10 @@ internal class SwapModel @Inject constructor( val swapState = dataState.lastLoadedSwapStates[provider] val fromToken = dataState.fromCryptoCurrency if (provider != null && swapState != null && fromToken != null) { + modelScope.launch { + feeSelectorRepository.state.value = FeeSelectorUM.Loading + feeSelectorReloadTrigger.triggerUpdate() + } analyticsEventHandler.send(SwapEvents.ProviderChosen(provider)) uiState = stateBuilder.dismissBottomSheet(uiState) setupLoadedState( @@ -1780,7 +1986,11 @@ internal class SwapModel @Inject constructor( destinationAddress = transaction?.txTo.orEmpty(), tokenSymbol = fromCurrencyStatus.currency.symbol, amount = dataState.amount.orEmpty(), - fee = dataState.selectedFee?.feeCryptoFormatted.orEmpty(), + fee = when (val fee = getSelectedFee()) { + is TxFee.FeeComponent -> fee.fee.amount.value?.toString() + is TxFee.Legacy -> fee.feeCryptoFormatted + null -> "" + }, ), ) @@ -1797,6 +2007,42 @@ internal class SwapModel @Inject constructor( } } + private fun addToPortfolioItem(item: MarketsListItemUM) { + modelScope.launch { + val tokenInfo = getTokenMarketInfoUseCase( + selectedAppCurrencyFlow.value, + item.id, + item.currencySymbol, + ).getOrNull() ?: return@launch + + val converter = TokenMarketInfoToParamsConverter() + val param = converter.convert(tokenInfo) + val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } + + val networks = tokenInfo.networks?.filter { network -> + BlockchainUtils.isSupportedNetworkId( + blockchainId = network.networkId, + excludedBlockchains = excludedBlockchains, + hotExcludedBlockchains = hotWalletExcludedBlockchains, + hasOnlyHotWallets = hasOnlyHotWallets, + ) + }.orEmpty() + + addToPortfolioManager = addToPortfolioManagerFactory + .create( + scope = modelScope, + token = param, + analyticsParams = null, + ).apply { + setTokenNetworks(networks) + } + + addToPortfolioManager?.state + ?.firstOrNull { it is AddToPortfolioManager.State.AvailableToAdd } + ?.run { bottomSheetNavigation.activate(AddToPortfolioRoute) } + }.saveIn(addToPortfolioJobHolder) + } + private fun CryptoCurrency.getNetworkInfo(): NetworkInfo { return NetworkInfo( name = this.network.name, @@ -1820,10 +2066,161 @@ internal class SwapModel @Inject constructor( } } + private fun getSelectedFeeState(): TxFeeSealedState { + if (!sendFeatureToggles.isGaslessTransactionsEnabled) { + return TxFeeSealedState.Legacy( + txFeeState = TxFeeState.Empty, + selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL, + ) + } + + val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content + ?: return TxFeeSealedState.Legacy( + txFeeState = TxFeeState.Empty, + selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL, + ) + + val transactionFeeExtended = feeStateUM.feeExtraInfo.transactionFeeExtended + return TxFeeSealedState.Component( + txFee = TxFee.FeeComponent( + transactionFeeResult = transactionFeeExtended?.let { TransactionFeeResult.from(it) } + ?: TransactionFeeResult.from(feeStateUM.fees), + fee = feeStateUM.selectedFeeItem.fee, + selectedToken = feeStateUM.feeExtraInfo.feeCryptoCurrencyStatus, + ), + ) + } + + private fun getSelectedFee(): TxFee? { + if (!sendFeatureToggles.isGaslessTransactionsEnabled) { + return dataState.selectedFee + } + + val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content ?: return null + val transactionFeeExtended = feeStateUM.feeExtraInfo.transactionFeeExtended + + return TxFee.FeeComponent( + transactionFeeResult = transactionFeeExtended?.let { TransactionFeeResult.from(it) } + ?: TransactionFeeResult.from(feeStateUM.fees), + fee = feeStateUM.selectedFeeItem.fee, + selectedToken = feeStateUM.feeExtraInfo.feeCryptoCurrencyStatus, + ) + } + + inner class FeeSelectorRepository : SwapFeeSelectorBlockComponent.ModelRepositoryExtended { + + override val state = MutableStateFlow(FeeSelectorUM.Loading) + + override suspend fun loadFeeExtended( + selectedToken: CryptoCurrencyStatus?, + ): Either { + val sendCardData = + uiState.sendCardData as? SwapCardState.SwapCardData ?: return Either.Left(GetFeeError.UnknownError) + val receiveCardData = + uiState.receiveCardData as? SwapCardState.SwapCardData ?: return Either.Left(GetFeeError.UnknownError) + val fromToken = sendCardData.token ?: return Either.Left(GetFeeError.UnknownError) + val toToken = receiveCardData.token ?: return Either.Left(GetFeeError.UnknownError) + val selectedProvider = dataState.selectedProvider ?: return Either.Left(GetFeeError.UnknownError) + + if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) { + return Either.Left(GetFeeError.UnknownError) + } + + if (isPermissionNotificationShown()) { + return Either.Left(GetFeeError.UnknownError) + } + + return swapInteractor.loadFeeForSwapTransaction( + fromToken = fromToken, + fromAccount = dataState.fromAccount, + toToken = toToken, + toAccount = dataState.toAccount, + provider = selectedProvider, + amount = lastAmount.value, + reduceBalanceBy = lastReducedBalanceBy.value, + selectedFeeToken = selectedToken, + ) + } + + override fun onResult(newState: FeeSelectorUM) { + if (isPermissionNotificationShown()) { + state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) + return + } + + state.value = newState + + // If fee currency is same as from currency, we need to reload quotes to update fee info + if (newState is FeeSelectorUM.Content && + dataState.fromCryptoCurrency?.currency?.id == newState.feeExtraInfo.feeCryptoCurrencyStatus.currency.id + ) { + // block swap button until fee is loaded + uiState = uiState.copy( + swapButton = uiState.swapButton.copy( + isEnabled = false, + isInProgress = false, + ), + ) + modelScope.launch { + startLoadingQuotesFromLastState( + isSilent = true, + updateFeeBlock = false, + ) + } + } + } + + private fun isPermissionNotificationShown(): Boolean { + val permissionState = dataState.getCurrentLoadedSwapState()?.permissionState + return permissionState != null && permissionState !is PermissionDataState.Empty + } + + override suspend fun loadFee(): Either { + val sendCardData = + uiState.sendCardData as? SwapCardState.SwapCardData ?: return Either.Left(GetFeeError.UnknownError) + val receiveCardData = + uiState.receiveCardData as? SwapCardState.SwapCardData ?: return Either.Left(GetFeeError.UnknownError) + val fromToken = sendCardData.token ?: return Either.Left(GetFeeError.UnknownError) + val toToken = receiveCardData.token ?: return Either.Left(GetFeeError.UnknownError) + val selectedProvider = dataState.selectedProvider ?: return Either.Left(GetFeeError.UnknownError) + + if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) { + return Either.Left(GetFeeError.UnknownError) + } + + if (isPermissionNotificationShown()) { + return Either.Left(GetFeeError.UnknownError) + } + + return swapInteractor.loadFeeForSwapTransaction( + fromToken = fromToken, + fromAccount = dataState.fromAccount, + toToken = toToken, + toAccount = dataState.toAccount, + provider = selectedProvider, + amount = lastAmount.value, + reduceBalanceBy = lastReducedBalanceBy.value, + ) + } + + override fun choosingInProgress(updatedState: Boolean) { + // We shouldn't load quotes while user is choosing fee + if (updatedState) { + singleTaskScheduler.cancelTask() + } else { + startLoadingQuotesFromLastState( + isSilent = true, + updateFeeBlock = false, + ) + } + } + } + private companion object { const val INITIAL_AMOUNT = "" const val UPDATE_DELAY = 10000L const val DEBOUNCE_AMOUNT_DELAY = 1000L + const val DEBOUNCE_SEARCH_DELAY = 500L const val UPDATE_BALANCE_DELAY_MILLIS = 11000L const val CHANGELLY_PROVIDER_ID = "changelly" } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 731e036f94..f7a1817cb3 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -12,8 +12,10 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount import com.tangem.feature.swap.domain.models.domain.SwapFeeState import com.tangem.feature.swap.domain.models.ui.* @@ -32,6 +34,7 @@ import java.math.BigDecimal @Suppress("LargeClass") internal class SwapNotificationsFactory( private val actions: UiActions, + private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) { fun getInitialErrorStateNotifications(code: Int, onRefreshClick: () -> Unit): ImmutableList { @@ -162,7 +165,7 @@ internal class SwapNotificationsFactory( addExistentialWarningNotification( existentialDeposit = quoteModel.currencyCheck?.existentialDeposit, - feeAmount = fee?.feeValue.orZero(), + feeAmount = fee?.fee?.amount?.value.orZero(), sendingAmount = amountToRequest.value, cryptoCurrencyStatus = fromCurrencyStatus, onReduceClick = { reduceBy, reduceByDiff, _ -> @@ -185,7 +188,7 @@ internal class SwapNotificationsFactory( if (!isCardano) { addDustWarningNotification( dustValue = quoteModel.currencyCheck?.dustValue, - feeValue = fee?.feeValue.orZero(), + feeValue = fee?.fee?.amount?.value.orZero(), sendingAmount = amountToRequest.value, cryptoCurrencyStatus = fromCurrencyStatus, feeCurrencyStatus = feeCryptoCurrencyStatus, @@ -273,7 +276,7 @@ internal class SwapNotificationsFactory( } } - private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee? { + private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee.Legacy? { return when (txFeeState) { TxFeeState.Empty -> null is TxFeeState.SingleFeeState -> txFeeState.fee @@ -294,7 +297,10 @@ internal class SwapNotificationsFactory( val shouldShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough && quoteModel.permissionState !is PermissionDataState.PermissionLoading && feeEnoughState.feeCurrency != fromToken - if (shouldShowCoverWarning) { + + val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromToken.network) && + quoteModel.swapProvider.type == ExchangeProviderType.CEX + if (shouldShowCoverWarning && !isGaslessAvailable) { add( SwapNotificationUM.Error.UnableToCoverFeeWarning( fromToken = fromToken, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt index d18df192c1..f352427c56 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt @@ -22,7 +22,7 @@ data class SwapProcessDataState( val reduceBalanceBy: BigDecimal = BigDecimal.ZERO, val approveDataModel: RequestApproveStateData? = null, val swapDataModel: SwapDataModel? = null, - val selectedFee: TxFee? = null, + val selectedFee: TxFee.Legacy? = null, val tokensDataState: TokensDataStateExpress? = null, val selectedProvider: SwapProvider? = null, val lastLoadedSwapStates: Map = emptyMap(), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt new file mode 100644 index 0000000000..afebcd52cf --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt @@ -0,0 +1,7 @@ +package com.tangem.feature.swap.models + +import com.tangem.core.decompose.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +internal data object AddToPortfolioRoute : Route \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt index 8d9c1bf701..96d1358d0a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt @@ -3,12 +3,14 @@ package com.tangem.feature.swap.models import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.feature.swap.models.market.state.SwapMarketState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf internal data class SwapSelectTokenStateHolder( val availableTokens: ImmutableList, val unavailableTokens: ImmutableList, + val marketsState: SwapMarketState? = null, val tokensListData: TokenListUMData, val isBalanceHidden: Boolean, val isAfterSearch: Boolean, @@ -39,16 +41,34 @@ internal data class TokenBalanceData( internal sealed interface TokenListUMData { val tokensList: ImmutableList + val totalTokensCount: Int data class AccountList( override val tokensList: ImmutableList, + override val totalTokensCount: Int, ) : TokenListUMData data class TokenList( override val tokensList: ImmutableList, + override val totalTokensCount: Int, ) : TokenListUMData data object EmptyList : TokenListUMData { override val tokensList: ImmutableList = persistentListOf() + override val totalTokensCount: Int = EMPTY_TOKENS_COUNT } -} \ No newline at end of file + + private companion object { + const val EMPTY_TOKENS_COUNT = 0 + } +} + +internal val SwapSelectTokenStateHolder.isNotFoundState: Boolean + get() = availableTokens.isEmpty() && unavailableTokens.isEmpty() && + tokensListData.tokensList.isEmpty() && isAfterSearch && + marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading + +internal val SwapSelectTokenStateHolder.isEmptyState: Boolean + get() = availableTokens.isEmpty() && unavailableTokens.isEmpty() && + tokensListData.tokensList.isEmpty() && !isAfterSearch && + marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 48a1a717c6..f37d7dd2fe 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -78,6 +78,7 @@ sealed class SwapCardState { data class SwapButton( @DrawableRes val walletInteractionIcon: Int?, val isEnabled: Boolean, + val isInProgress: Boolean = false, val onClick: () -> Unit, ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt index 5156ce3e1e..1d50867cc6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt @@ -7,7 +7,7 @@ import com.tangem.core.ui.extensions.TextReference data class SwapSuccessStateHolder( val timestamp: Long, val txUrl: String, - val fee: TextReference, + val fee: TextReference?, val rate: TextReference, val shouldShowStatusButton: Boolean, val providerName: TextReference, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt index 25c2225451..7330e3ed67 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -24,7 +24,7 @@ data class UiActions( val onStoriesClose: (Int) -> Unit, val onRetryClick: () -> Unit, val onClickFee: () -> Unit, - val onSelectFeeType: (TxFee) -> Unit, + val onSelectFeeType: (TxFee.Legacy) -> Unit, val onProviderClick: (String) -> Unit, val onProviderSelect: (String) -> Unit, val onBuyClick: (CryptoCurrency) -> Unit, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt new file mode 100644 index 0000000000..3e7b9af39f --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt @@ -0,0 +1,243 @@ +package com.tangem.feature.swap.models.market + +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.* +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.feature.swap.models.market.converter.SwapMarketsTokenItemConverter +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchAction +import com.tangem.pagination.PaginationStatus +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +@Suppress("LongParameterList") +internal class SwapMarketsListBatchFlowManager( + getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, + private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType, + private val currentAppCurrency: Provider, + private val currentSearchText: Provider, + private val modelScope: CoroutineScope, + private val dispatchers: CoroutineDispatcherProvider, +) { + private val actionsFlow = MutableSharedFlow>() + private val updateStateJob = JobHolder() + + private val batchFlow = getMarketsTokenListFlowUseCase( + batchingContext = TokenListBatchingContext( + actionsFlow = actionsFlow, + coroutineScope = modelScope, + ), + batchFlowType = batchFlowType, + ) + + private val resultBatches = MutableStateFlow(ResultBatches()) + private val uiBatches = resultBatches.map { it.uiBatches } + + val uiItems: StateFlow> + get() = uiBatches + .map { batches -> + batches.asSequence() + .map { it.data } + .flatten() + .toImmutableList() + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = persistentListOf(), + ) + + val isInInitialLoadingErrorState = batchFlow.state + .map { it.status is PaginationStatus.InitialLoadingError } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + + val isSearchNotFoundState = batchFlow.state + .map { batchListState -> + currentSearchText().isNullOrEmpty().not() && + batchListState.status is PaginationStatus.EndOfPagination && + batchListState.data.isEmpty() + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + + val totalCount: StateFlow = batchFlow.state + .map { it.totalCount } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) + + init { + batchFlow.state + .map { it.data } + .distinctUntilChanged { a, b -> + a.size == b.size && + a.map { it.key } == b.map { it.key } && + a.map { it.data }.flatten() == b.map { it.data }.flatten() + } + .onEach { + coroutineScope { + launch { + updateState(it) + }.saveIn(updateStateJob) + } + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private suspend fun updateState(newList: List>>, forceUpdate: Boolean = false) = + withContext(dispatchers.default) { + resultBatches.update { resultBatches -> + val items = resultBatches.uiBatches + val previousList = resultBatches.processedItems + + val converter = SwapMarketsTokenItemConverter(appCurrency = currentAppCurrency()) + + if (newList.isEmpty()) { + return@update ResultBatches(processedItems = emptyList()) + } + + val isInitialLoading = + forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key + + val outItems = if (isInitialLoading) { + newList.map { batch -> + Batch( + key = batch.key, + data = converter.convertList(batch.data), + ) + } + } else { + if (previousList.size != newList.size) { + val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) + val newBatches = newList.filter { keysToAdd.contains(it.key) } + + items + newBatches.map { batch -> + Batch( + key = batch.key, + data = converter.convertList(batch.data), + ) + } + } else { + items.mapIndexed { batchIndex, batch -> + val prevBatch = previousList[batchIndex] + val newBatch = newList[batchIndex] + if (prevBatch == newBatch) return@mapIndexed batch + + Batch( + key = batch.key, + data = batch.data.mapIndexed { index, marketsListItemUM -> + val prevItem = prevBatch.data.getOrNull(index) + val newItem = newBatch.data.getOrNull(index) + if (prevItem != null && newItem != null) { + converter.update(prevItem, marketsListItemUM, newItem) + } else { + newItem?.let { converter.convert(it) } ?: marketsListItemUM + } + }, + ) + } + } + } + + currentCoroutineContext().ensureActive() + + ResultBatches( + uiBatches = outItems, + processedItems = newList, + ) + } + } + + fun reload(searchText: String? = null) { + modelScope.launch { + resultBatches.value = ResultBatches() + actionsFlow.emit( + BatchAction.Reload( + requestParams = TokenMarketListConfig( + fiatPriceCurrency = currentAppCurrency().code, + searchText = if (currentSearchText() == null) { + null + } else { + searchText ?: currentSearchText() + }, + priceChangeInterval = TokenMarketListConfig.Interval.H24, + order = TokenMarketListConfig.Order.ByRating, + ), + ), + ) + } + } + + fun loadMore() { + modelScope.launch { + actionsFlow.emit(BatchAction.LoadMore()) + } + } + + fun loadCharts(batchKeys: Set) { + if (batchKeys.isEmpty()) return + + modelScope.launch { + val currentData = batchFlow.state.value.data + val alreadyLoadedChartsBatchKeys = currentData + .filter { batch -> + val first = batch.data.firstOrNull() ?: return@filter false + first.tokenCharts.h24 != null + } + .map { it.key } + .toSet() + + val batchesKeysToLoad = batchKeys.minus(alreadyLoadedChartsBatchKeys) + + if (batchesKeysToLoad.isNotEmpty()) { + actionsFlow.emit( + BatchAction.UpdateBatches( + keys = batchesKeysToLoad, + updateRequest = TokenMarketUpdateRequest.UpdateChart( + interval = TokenMarketListConfig.Interval.H24, + currency = currentAppCurrency().code, + ), + async = true, + operationId = batchesKeysToLoad.toString() + "h24", + ), + ) + } + } + } + + fun getBatchKeysByItemIds(ids: List): Set { + val currentData = batchFlow.state.value.data + + return currentData + .filter { d -> d.data.any { ids.contains(it.id) } } + .map { it.key } + .toSet() + } + + private data class ResultBatches( + val uiBatches: List>> = emptyList(), + val processedItems: List>>? = null, + ) +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt new file mode 100644 index 0000000000..78c52b2aab --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt @@ -0,0 +1,148 @@ +package com.tangem.feature.swap.models.market.converter + +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter +import com.tangem.common.ui.charts.state.sorted +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.compact +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.core.ui.format.bigdecimal.price +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarket +import com.tangem.feature.swap.presentation.R +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal +import java.math.RoundingMode + +internal class SwapMarketsTokenItemConverter( + private val appCurrency: AppCurrency, +) : Converter { + + private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false) + + override fun convert(value: TokenMarket): MarketsListItemUM { + return MarketsListItemUM( + id = value.id, + name = value.name, + currencySymbol = value.symbol, + ratingPosition = value.marketRating?.toString(), + marketCap = value.getMarketCap(), + iconUrl = value.imageUrlLarge, + price = value.getCurrentPrice(), + trendPercentText = value.getTrendPercent(), + trendType = value.getTrendType(), + chartData = value.getChartData(), + isUnder100kMarketCap = value.isUnderMarketCapLimit, + stakingRate = value.yieldRate?.format { percent() }?.let { + resourceReference(R.string.markets_apy_placeholder, wrappedList(it)) + }, + updateTimestamp = value.updateTimestamp, + ) + } + + fun update(prev: TokenMarket, prevUI: MarketsListItemUM, new: TokenMarket): MarketsListItemUM { + require(prev.id == new.id) { + "Ids is not the same during update TokenMarket item: previousItem[${prev.id}] != newItem[${new.id}]" + } + + return prevUI.copy( + name = new.name, + currencySymbol = new.symbol, + ratingPosition = new.marketRating?.toString(), + marketCap = ifChanged(prev.marketCap, new.marketCap, prevUI.marketCap) { new.getMarketCap() }, + iconUrl = new.imageUrlLarge, + price = ifChanged(prev = prev.tokenQuotesShort, new = new.tokenQuotesShort, prevR = prevUI.price) { + new.getCurrentPrice( + prev = prev, + ) + }, + trendPercentText = ifChanged( + prev.tokenQuotesShort, + new.tokenQuotesShort, + prevUI.trendPercentText, + ) { new.getTrendPercent() }, + trendType = ifChanged(prev.tokenQuotesShort, new.tokenQuotesShort, prevUI.trendType) { new.getTrendType() }, + chartData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chartData) { new.getChartData() }, + ) + } + + private inline fun ifChanged(prev: T, new: T, prevR: R, force: Boolean = false, change: (T) -> R): R { + return if (force || prev != new) change(new) else prevR + } + + private fun TokenMarket.getMarketCap(): String? { + val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null + + return value.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ).compact( + threeDigitsMethod = true, + ) + } + } + + private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price { + val prevPrice = prev?.tokenQuotesShort?.currentPrice + + val priceText = tokenQuotesShort.currentPrice.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ).price() + } + + val changeType = if (prevPrice != null) { + if (tokenQuotesShort.currentPrice > prevPrice) { + PriceChangeType.UP + } else { + PriceChangeType.DOWN + } + } else { + null + } + + return MarketsListItemUM.Price( + text = priceText, + changeType = changeType, + ) + } + + private fun TokenMarket.getChartData(): MarketChartRawData? { + val chart = tokenCharts.h24 + + return chart?.let { ct -> + priceAndTimePointValuesConverter.convert( + MarketChartData.Data( + y = ct.priceY.toImmutableList(), + x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(), + ).sorted(), + ) + } + } + + @Suppress("MagicNumber") + private fun TokenMarket.getTrendType(): PriceChangeType { + val percent = tokenQuotesShort.h24ChangePercent + val scaled = percent?.setScale(4, RoundingMode.HALF_UP) + return when { + scaled == null -> PriceChangeType.NEUTRAL + scaled > BigDecimal.ZERO -> PriceChangeType.UP + scaled < BigDecimal.ZERO -> PriceChangeType.DOWN + else -> PriceChangeType.NEUTRAL + } + } + + private fun TokenMarket.getTrendPercent(): String { + val percent = tokenQuotesShort.h24ChangePercent + return percent.format { percent() } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt new file mode 100644 index 0000000000..50e6d14a74 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt @@ -0,0 +1,26 @@ +package com.tangem.feature.swap.models.market.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.domain.models.currency.CryptoCurrency +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class SwapMarketState { + + data class Content( + val items: ImmutableList, + val total: Int, + val loadMore: () -> Unit, + val onItemClick: (MarketsListItemUM) -> Unit, + val visibleIdsChanged: (List) -> Unit, + ) : SwapMarketState() + + data object Loading : SwapMarketState() + + data class LoadingError( + val onRetryClicked: () -> Unit, + ) : SwapMarketState() + + data object SearchNothingFound : SwapMarketState() +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/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/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 6ab1d4af06..33bf20ea2b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -25,6 +25,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.promo.models.StoryContent +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter import com.tangem.feature.swap.converters.TokensDataConverter import com.tangem.feature.swap.converters.TokensDataConverterV2 @@ -64,6 +65,7 @@ internal class StateBuilder( private val isBalanceHiddenProvider: Provider, private val appCurrencyProvider: Provider, private val isAccountsModeProvider: Provider, + private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) { private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) @@ -76,7 +78,7 @@ internal class StateBuilder( ) private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) { - SwapNotificationsFactory(actions) + SwapNotificationsFactory(actions, iGaslessFeeSupportedForNetwork) } fun createInitialLoadingState( @@ -606,6 +608,7 @@ internal class StateBuilder( return uiState.copy( swapButton = uiState.swapButton.copy( isEnabled = false, + isInProgress = true, ), ) } @@ -615,13 +618,14 @@ internal class StateBuilder( fromToken: CryptoCurrency, tokensDataState: CurrenciesGroup, ): SwapStateHolder { + val currentMarketsState = uiState.selectTokenState?.marketsState return uiState.copy( selectTokenState = tokensDataConverter.convert( value = CurrenciesGroupWithFromCurrency( fromCurrency = fromToken, group = tokensDataState, ), - ), + ).copy(marketsState = currentMarketsState), ) } @@ -808,6 +812,7 @@ internal class StateBuilder( return uiState.copy( swapButton = uiState.swapButton.copy( isEnabled = false, + isInProgress = false, ), permissionState = GiveTxPermissionState.InProgress, notifications = notificationsFactory.getApprovalInProgressStateNotification(uiState.notifications), @@ -823,7 +828,6 @@ internal class StateBuilder( onStatusClick: () -> Unit, txUrl: String, ): SwapStateHolder { - val fee = requireNotNull(dataState.selectedFee) val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency) val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency) val fromAmount = swapTransactionState.fromAmountValue ?: BigDecimal.ZERO @@ -843,7 +847,9 @@ internal class StateBuilder( shouldShowStatusButton = shouldShowStatus, providerIcon = providerState.iconUrl, rate = providerState.subtitle, - fee = stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})"), + fee = dataState.selectedFee?.let { fee -> + stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})") + }, fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount), toTitle = getToCardAccountTitle(toAccount = dataState.toAccount), fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt index e474858740..78ad44f4f9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt @@ -1,24 +1,27 @@ package com.tangem.feature.swap.ui import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.WindowInsetsZero +import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.models.SwapStateHolder import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig import com.tangem.feature.swap.presentation.R @Composable -internal fun SwapScreen(stateHolder: SwapStateHolder) { +internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?) { BackHandler(onBack = stateHolder.onBackClicked) Scaffold( @@ -36,6 +39,17 @@ internal fun SwapScreen(stateHolder: SwapStateHolder) { SwapScreenContent( state = stateHolder, + feeBlock = if (feeSelectorBlockComponent != null) { + @Composable { modifier: Modifier -> + feeSelectorBlockComponent.Content( + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) + } + } else { + null + }, modifier = Modifier.padding(scaffoldPaddings), ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 3aa2c4d059..a1cb85f872 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -49,7 +49,11 @@ import kotlinx.collections.immutable.persistentListOf @Suppress("LongMethod") @Composable -internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modifier) { +internal fun SwapScreenContent( + state: SwapStateHolder, + modifier: Modifier = Modifier, + feeBlock: @Composable ((Modifier) -> Unit)? = null, +) { val keyboard by keyboardAsState() Box( @@ -74,7 +78,11 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi ProviderItemBlock(state = state.providerState) - FeeItemBlock(state = state.fee) + if (feeBlock != null) { + feeBlock(Modifier.fillMaxWidth()) + } else { + FeeItemBlock(state = state.fee) + } if (state.notifications.isNotEmpty()) SwapNotifications(notifications = state.notifications) @@ -372,7 +380,11 @@ private fun MainButton(state: SwapStateHolder) { } else { PrimaryButtonIconEnd( modifier = Modifier.fillMaxWidth(), - text = stringResourceSafe(id = R.string.swapping_swap_action), + text = if (state.swapButton.isInProgress) { + stringResourceSafe(id = R.string.swapping_swap_action_in_progress) + } else { + stringResourceSafe(id = R.string.swapping_swap_action) + }, iconResId = state.swapButton.walletInteractionIcon, enabled = state.swapButton.isEnabled, onClick = state.swapButton.onClick, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt index 6b0fa946f4..2bc3cc5c61 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt @@ -7,26 +7,38 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH32 import com.tangem.core.ui.components.SpacerW2 import com.tangem.core.ui.components.appbar.ExpandableSearchView +import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.currency.icon.CurrencyIcon -import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.tokenlist.PortfolioListItem import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem import com.tangem.core.ui.components.tokenlist.TokenListItem @@ -38,12 +50,18 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BuyTokenScreenTestTags import com.tangem.core.ui.utils.lazyListItemPosition import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.TokenBalanceData import com.tangem.feature.swap.models.TokenListUMData import com.tangem.feature.swap.models.TokenToSelectState +import com.tangem.feature.swap.models.isEmptyState +import com.tangem.feature.swap.models.isNotFoundState +import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.presentation.R +import com.tangem.feature.swap.ui.market.swapMarketsListItems +import com.tangem.feature.swap.ui.preview.SwapSelectTokenPreviewProvider import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.persistentListOf + +private const val LOAD_MORE_BUFFER = 25 @Composable internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) { @@ -56,17 +74,14 @@ internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () content = { padding -> val modifier = Modifier.padding(padding) when { - state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && - state.tokensListData.tokensList.isEmpty() && state.isAfterSearch -> { - TokensNotFound(modifier) - } - state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && - state.tokensListData.tokensList.isEmpty() && !state.isAfterSearch -> { - EmptyTokensList(modifier) - } - else -> { - ListOfTokens(state = state, modifier = modifier) - } + state.isNotFoundState -> TokensNotFound(modifier) + state.isEmptyState -> EmptyTokensList(modifier) + state.marketsState != null -> ListOfTokensWithMarkets( + state = state, + marketsState = state.marketsState, + modifier = modifier, + ) + else -> ListOfTokens(state = state, modifier = modifier) } }, topBar = { @@ -135,6 +150,7 @@ private fun TokensNotFound(modifier: Modifier = Modifier) { @Composable private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) { val screenBackgroundColor = TangemTheme.colors.background.secondary + LazyColumn( modifier = modifier .background(color = screenBackgroundColor) @@ -142,29 +158,129 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = .imePadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { - when (val list = state.tokensListData) { - is TokenListUMData.AccountList -> list.tokensList.forEach { item -> - portfolioTokensList( - portfolio = item, - isBalanceHidden = state.isBalanceHidden, - ) - } - is TokenListUMData.TokenList -> { - tokensList( - items = list.tokensList, - isBalanceHidden = state.isBalanceHidden, - ) - } - TokenListUMData.EmptyList -> Unit - } + tokensListItems( + tokensListData = state.tokensListData, + isBalanceHidden = state.isBalanceHidden, + ) tokensToSelectItems(state.availableTokens, state.onTokenSelected) item { SpacerH12() } tokensToSelectItems(state.unavailableTokens, state.onTokenSelected) + } +} - item { SpacerH12() } +@Composable +private fun ListOfTokensWithMarkets( + state: SwapSelectTokenStateHolder, + marketsState: SwapMarketState, + modifier: Modifier = Modifier, +) { + val screenBackgroundColor = TangemTheme.colors.background.secondary + val lazyListState = rememberLazyListState() + + LazyColumn( + modifier = modifier + .background(color = screenBackgroundColor) + .fillMaxSize() + .imePadding(), + horizontalAlignment = Alignment.CenterHorizontally, + state = lazyListState, + ) { + if (state.tokensListData !is TokenListUMData.EmptyList) { + assetsTitle(count = state.tokensListData.totalTokensCount) + } + + tokensListItems( + tokensListData = state.tokensListData, + isBalanceHidden = state.isBalanceHidden, + ) + + if (state.tokensListData is TokenListUMData.EmptyList) { + item { SpacerH12() } + } else { + item { SpacerH32() } + } + + swapMarketsListItems(marketsState) + } + + (marketsState as? SwapMarketState.Content)?.let { content -> + VisibleItemsTracker( + lazyListState = lazyListState, + marketState = content, + ) + + InfiniteListHandler( + listState = lazyListState, + buffer = LOAD_MORE_BUFFER, + triggerLoadMoreCheckOnItemsCountChange = true, + onLoadMore = remember(content) { + { + content.loadMore() + true + } + }, + ) + } +} + +@Composable +private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapMarketState.Content) { + val visibleItems by remember { + derivedStateOf { + lazyListState.layoutInfo.visibleItemsInfo + .mapNotNull { itemInfo -> + marketState.items.find { it.getComposeKey() == itemInfo.key }?.id + } + } + } + + LaunchedEffect(visibleItems) { + marketState.visibleIdsChanged(visibleItems) + } +} + +private fun LazyListScope.assetsTitle(count: Int) { + item(key = "assets_title") { + Text( + text = buildAnnotatedString { + append(stringResourceSafe(R.string.swap_your_assets_title)) + withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { + append(" $count") + } + }, + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .fillMaxWidth() + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing12, + ), + ) + } +} + +private fun LazyListScope.tokensListItems(tokensListData: TokenListUMData, isBalanceHidden: Boolean) { + when (tokensListData) { + is TokenListUMData.AccountList -> { + tokensListData.tokensList.forEach { item -> + portfolioTokensList( + portfolio = item, + isBalanceHidden = isBalanceHidden, + ) + } + } + is TokenListUMData.TokenList -> { + tokensList( + items = tokensListData.tokensList, + isBalanceHidden = isBalanceHidden, + ) + } + TokenListUMData.EmptyList -> Unit } } @@ -387,50 +503,45 @@ private fun TokenItem( } } -private val token = TokenToSelectState.TokenToSelect( - tokenIcon = CurrencyIconState.CoinIcon( - url = "", - fallbackResId = 0, - isGrayscale = false, - shouldShowCustomBadge = false, - ), - id = "", - name = "Optimistic Ethereum (ETH)", - symbol = "USDC", - addedTokenBalanceData = TokenBalanceData( - amount = "15 000 $", - amountEquivalent = "15 000 USDT", - isBalanceHidden = false, - ), -) - -private val title = TokenToSelectState.Title( - title = stringReference("MY TOKENS"), -) - -@Preview -@Composable -private fun TokenScreenPreview() { - TangemThemePreview { - SwapSelectTokenScreen( - state = SwapSelectTokenStateHolder( - availableTokens = listOf(title, token, token, token).toImmutableList(), - unavailableTokens = listOf(title, token, token, token).toImmutableList(), - tokensListData = TokenListUMData.EmptyList, - isAfterSearch = false, - isBalanceHidden = false, - onSearchEntered = {}, - onTokenSelected = {}, - ), - onBack = {}, - ) - } +private class SwapSelectTokenScreenPreviewProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + // Content state with tokens and markets + SwapSelectTokenPreviewProvider().provideSwapSelectTokenState(), + // Empty state + SwapSelectTokenStateHolder( + availableTokens = persistentListOf(), + unavailableTokens = persistentListOf(), + tokensListData = TokenListUMData.EmptyList, + marketsState = null, + isAfterSearch = false, + isBalanceHidden = false, + onSearchEntered = {}, + onTokenSelected = {}, + ), + // Not found state + SwapSelectTokenStateHolder( + availableTokens = persistentListOf(), + unavailableTokens = persistentListOf(), + tokensListData = TokenListUMData.EmptyList, + marketsState = null, + isAfterSearch = true, + isBalanceHidden = false, + onSearchEntered = {}, + onTokenSelected = {}, + ), + ) } @Preview @Composable -private fun EmptyTokensListPreview() { +private fun TokenScreenPreview( + @PreviewParameter(SwapSelectTokenScreenPreviewProvider::class) + state: SwapSelectTokenStateHolder, +) { TangemThemePreview { - EmptyTokensList() + SwapSelectTokenScreen( + state = state, + onBack = {}, + ) } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt index 2d51569af0..9caf68f4c0 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt @@ -29,14 +29,16 @@ import com.tangem.core.ui.utils.toTimeFormat import com.tangem.feature.swap.models.SwapSuccessStateHolder import com.tangem.feature.swap.presentation.R import com.tangem.feature.swap.preview.SwapSuccessStatePreview +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.common.ui.FeeBlockSuccess @Composable -fun SwapSuccessScreen(state: SwapSuccessStateHolder, onBack: () -> Unit) { +fun SwapSuccessScreen(state: SwapSuccessStateHolder, feeSelectorUM: FeeSelectorUM?, onBack: () -> Unit) { Scaffold( modifier = Modifier.systemBarsPadding(), containerColor = TangemTheme.colors.background.secondary, content = { padding -> - SwapSuccessScreenContent(padding = padding, state = state) + SwapSuccessScreenContent(padding = padding, feeSelectorUM = feeSelectorUM, state = state) }, topBar = { AppBarWithBackButton( @@ -58,7 +60,11 @@ fun SwapSuccessScreen(state: SwapSuccessStateHolder, onBack: () -> Unit) { } @Composable -private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: PaddingValues) { +private fun SwapSuccessScreenContent( + state: SwapSuccessStateHolder, + feeSelectorUM: FeeSelectorUM?, + padding: PaddingValues, +) { Column( modifier = Modifier .fillMaxSize() @@ -101,7 +107,10 @@ private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: Pad .background(TangemTheme.colors.background.action), ) SpacerH16() - if (state.fee != TextReference.EMPTY) { + + if (feeSelectorUM != null) { + FeeBlockSuccess(feeSelectorUM) + } else if (state.fee != null && state.fee != TextReference.EMPTY) { InputRowDefault( title = TextReference.Res(R.string.common_network_fee_title), text = state.fee, @@ -205,7 +214,7 @@ private fun SwapSuccessScreenButtons( @Composable private fun Preview_Success() { TangemThemePreview { - SwapSuccessScreen(SwapSuccessStatePreview.state) {} + SwapSuccessScreen(SwapSuccessStatePreview.state, null) {} } } // endregion preview \ No newline at end of file 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/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt new file mode 100644 index 0000000000..0f69e31f8b --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt @@ -0,0 +1,105 @@ +package com.tangem.feature.swap.ui.market + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import com.tangem.common.ui.markets.MarketsListItem +import com.tangem.common.ui.markets.MarketsListItemPlaceholder +import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.models.market.state.SwapMarketState +import com.tangem.feature.swap.presentation.R + +internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) { + item { + val totalCount = (state as? SwapMarketState.Content)?.total + Text( + text = buildAnnotatedString { + append(stringResourceSafe(R.string.markets_common_title)) + if (totalCount != null) { + withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { + append(" $totalCount") + } + } + }, + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.fillMaxWidth().padding(horizontal = TangemTheme.dimens.spacing16), + ) + } + when (state) { + is SwapMarketState.Loading -> { + items(count = 100, key = { "market_placeholder_$it" }) { + MarketsListItemPlaceholder() + } + } + is SwapMarketState.LoadingError -> { + item(key = "market_loading_error") { + LoadingErrorItem( + modifier = Modifier.fillParentMaxWidth(), + onTryAgain = state.onRetryClicked, + ) + } + } + SwapMarketState.SearchNothingFound -> { + item(key = "market_not_found") { + SearchNothingFoundText( + modifier = Modifier.fillParentMaxWidth(), + ) + } + } + is SwapMarketState.Content -> { + itemsIndexed( + items = state.items, + key = { _, item -> item.getComposeKey() }, + ) { index, item -> + MarketsListItem( + model = item, + onClick = { state.onItemClick(item) }, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + backgroundColor = TangemTheme.colors.background.action, + ), + ) + } + } + } +} + +@Composable +private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData(onRetryClick = onTryAgain) + } +} + +@Composable +private fun SearchNothingFoundText(modifier: Modifier = Modifier) { + Box( + modifier = modifier.padding(TangemTheme.dimens.spacing16), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResourceSafe(R.string.markets_search_token_no_result_title), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt new file mode 100644 index 0000000000..b9a6ab2d40 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt @@ -0,0 +1,157 @@ +package com.tangem.feature.swap.ui.preview + +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.feature.swap.models.SwapSelectTokenStateHolder +import com.tangem.feature.swap.models.TokenBalanceData +import com.tangem.feature.swap.models.TokenListUMData +import com.tangem.feature.swap.models.TokenToSelectState +import com.tangem.feature.swap.models.market.state.SwapMarketState +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal class SwapSelectTokenPreviewProvider { + + fun provideSwapSelectTokenState(): SwapSelectTokenStateHolder { + return SwapSelectTokenStateHolder( + availableTokens = listOf(previewTitle, previewToken, previewToken, previewToken).toImmutableList(), + unavailableTokens = listOf(previewTitle, previewToken, previewToken, previewToken).toImmutableList(), + tokensListData = TokenListUMData.EmptyList, + isAfterSearch = false, + isBalanceHidden = false, + onSearchEntered = {}, + onTokenSelected = {}, + marketsState = createPreviewMarketsState(), + ) + } + + private fun createPreviewMarketsState() = SwapMarketState.Content( + items = createPreviewMarketItems(), + loadMore = { }, + onItemClick = { }, + visibleIdsChanged = { }, + total = TOTAL_ITEMS, + ) + + private fun createPreviewMarketItems() = listOf( + createMarketItem( + id = "1", + iconUrl = "", + ratingPosition = "10", + marketCap = "$6.233 B", + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "2", + ratingPosition = "10", + marketCap = "$6.233 B", + trendType = PriceChangeType.NEUTRAL, + chartData = null, + ), + createMarketItem( + id = "3", + name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin", + ratingPosition = "10", + marketCap = "$6.23348172384781234 B", + trendType = PriceChangeType.DOWN, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "4", + ratingPosition = "10", + marketCap = null, + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "5", + ratingPosition = null, + marketCap = "$6.233 B", + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "6", + ratingPosition = null, + marketCap = null, + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + ).toImmutableList() + + private fun createMarketItem( + id: String, + name: String = "Bitcoin", + iconUrl: String? = null, + ratingPosition: String?, + marketCap: String?, + trendType: PriceChangeType, + chartData: MarketChartRawData?, + ) = MarketsListItemUM( + id = CryptoCurrency.RawID(id), + name = name, + currencySymbol = "BTC", + iconUrl = iconUrl, + ratingPosition = ratingPosition, + marketCap = marketCap, + price = MarketsListItemUM.Price(text = "31 285.72$"), + trendPercentText = "12.43%", + trendType = trendType, + chartData = chartData, + isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), + updateTimestamp = 0, + ) + + companion object { + private const val CHART_VALUE_1 = 0.4 + private const val CHART_VALUE_2 = 0.2 + private const val CHART_VALUE_3 = 0.1 + private const val CHART_VALUE_4 = 2.0 + private const val CHART_VALUE_5 = 5.0 + private const val CHART_VALUE_6 = 3.0 + private const val TOTAL_ITEMS = 322 + + private val PREVIEW_CHART_DATA = MarketChartRawData( + y = persistentListOf( + CHART_VALUE_1, + CHART_VALUE_2, + CHART_VALUE_1, + CHART_VALUE_3, + CHART_VALUE_1, + CHART_VALUE_4, + CHART_VALUE_5, + CHART_VALUE_3, + CHART_VALUE_4, + CHART_VALUE_4, + CHART_VALUE_6, + ), + ) + + private val previewToken = TokenToSelectState.TokenToSelect( + tokenIcon = CurrencyIconState.CoinIcon( + url = "", + fallbackResId = 0, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + id = "", + name = "Optimistic Ethereum (ETH)", + symbol = "USDC", + addedTokenBalanceData = TokenBalanceData( + amount = "15 000 $", + amountEquivalent = "15 000 USDT", + isBalanceHidden = false, + ), + ) + + private val previewTitle = TokenToSelectState.Title( + title = stringReference("MY TOKENS"), + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/build.gradle.kts b/features/tangempay/details/impl/build.gradle.kts index 52a85dad6b..cc18ae7c58 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(projects.features.tangempay.details.api) implementation(projects.features.tokenRecieve.api) implementation(projects.features.txhistory.api) + implementation(projects.features.tokendetails.api) /** Domain */ implementation(projects.domain.balanceHiding) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index 126f7124d8..baa27a447b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -15,16 +15,18 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.components.express.ExpressTransactionsComponentProvider import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -class DefaultTangemPayDetailsContainerComponent @AssistedInject constructor( +internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, @Assisted private val params: TangemPayDetailsContainerComponent.Params, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, + private val expressTransactionsComponentProvider: ExpressTransactionsComponentProvider, ) : AppComponentContext by appComponentContext, TangemPayDetailsContainerComponent { private val stackNavigation = StackNavigation() @@ -60,6 +62,7 @@ class DefaultTangemPayDetailsContainerComponent @AssistedInject constructor( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), params = params, tokenReceiveComponentFactory = tokenReceiveComponentFactory, + expressTransactionsComponentProvider = expressTransactionsComponentProvider, ) TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index fb099938ee..a4e44f025e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -8,6 +8,7 @@ import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss +import com.arkivanov.essenty.lifecycle.subscribe import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext @@ -17,6 +18,7 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.express.ExpressTransactionsComponentProvider import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation @@ -28,6 +30,7 @@ internal class TangemPayDetailsComponent( private val appComponentContext: AppComponentContext, private val params: TangemPayDetailsContainerComponent.Params, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, + private val expressTransactionsComponentProvider: ExpressTransactionsComponentProvider, ) : AppComponentContext by appComponentContext, ComposableContentComponent { private val model: TangemPayDetailsModel = getOrCreateModel(params = params) @@ -52,6 +55,21 @@ internal class TangemPayDetailsComponent( params = TangemPayCardDetailsBlockComponent.Params(params = params), ) + private val expressTransactionsComponent by lazy { + expressTransactionsComponentProvider.create( + appComponentContext = child("expressTransactionsComponent"), + userWalletId = params.userWalletId, + cryptoCurrency = model.cryptoCurrency, + ) + } + + init { + lifecycle.subscribe( + onPause = model::onPause, + onResume = model::onResume, + ) + } + @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() @@ -62,6 +80,7 @@ internal class TangemPayDetailsComponent( state = state, txHistoryComponent = txHistoryComponent, cardDetailsBlockComponent = cardDetailsBlockComponent, + expressTransactionsComponent = expressTransactionsComponent, modifier = modifier, ) bottomSheet.child?.instance?.BottomSheet() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt index 2714c0a662..9fb41015c0 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt @@ -12,7 +12,7 @@ internal interface TangemPayCardDetailsBlockComponent { val state: StateFlow @Composable - fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) + fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier) data class Params(val params: TangemPayDetailsContainerComponent.Params) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/EmptyExpressTransactionsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/EmptyExpressTransactionsComponent.kt new file mode 100644 index 0000000000..a06c8a9f4f --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/EmptyExpressTransactionsComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.features.tangempay.components.express + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM +import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.features.tokendetails.ExpressTransactionsComponent +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +@Stable +internal class EmptyExpressTransactionsComponent( + context: AppComponentContext, +) : AppComponentContext by context, ExpressTransactionsComponent { + + override val state: StateFlow = MutableStateFlow(getInitialState()) + + override fun LazyListScope.expressTransactionsContent( + state: PersistentList, + modifier: Modifier, + ) {} + + private fun getInitialState(): ExpressTransactionsBlockState { + return ExpressTransactionsBlockState( + transactions = persistentListOf(), + bottomSheetSlot = null, + dialogSlot = null, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/ExpressTransactionsComponentProvider.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/ExpressTransactionsComponentProvider.kt new file mode 100644 index 0000000000..bcf6a91874 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/ExpressTransactionsComponentProvider.kt @@ -0,0 +1,25 @@ +package com.tangem.features.tangempay.components.express + +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.tokendetails.ExpressTransactionsComponent +import javax.inject.Inject + +internal class ExpressTransactionsComponentProvider @Inject constructor( + private val expressTransactionsComponentFactory: ExpressTransactionsComponent.Factory, +) { + + fun create( + appComponentContext: AppComponentContext, + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency?, + ): ExpressTransactionsComponent = if (cryptoCurrency != null) { + expressTransactionsComponentFactory.create( + context = appComponentContext, + params = ExpressTransactionsComponent.Params(userWalletId = userWalletId, currency = cryptoCurrency), + ) + } else { + EmptyExpressTransactionsComponent(context = appComponentContext) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/PreviewEmptyExpressTransactionsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/PreviewEmptyExpressTransactionsComponent.kt new file mode 100644 index 0000000000..23ead18dcf --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/express/PreviewEmptyExpressTransactionsComponent.kt @@ -0,0 +1,32 @@ +package com.tangem.features.tangempay.components.express + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.ui.Modifier +import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM +import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState +import com.tangem.features.tokendetails.ExpressTransactionsComponent +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** Cannot really preview anything here since the UM implementation [ExchangeUM] is in token:details module + * For the actual preview @see [TokenDetailsScreen] + **/ +internal class PreviewEmptyExpressTransactionsComponent : ExpressTransactionsComponent { + + override val state: StateFlow = MutableStateFlow(getInitialState()) + + override fun LazyListScope.expressTransactionsContent( + state: PersistentList, + modifier: Modifier, + ) {} + + private fun getInitialState(): ExpressTransactionsBlockState { + return ExpressTransactionsBlockState( + transactions = persistentListOf(), + bottomSheetSlot = null, + dialogSlot = null, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt index 7904ea72d9..e79a05f253 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt @@ -3,12 +3,13 @@ package com.tangem.features.tangempay.entity import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.model.CardDataType import com.tangem.utils.StringsSigns internal class TangemPayCardDetailsBlockStateFactory( private val cardNumberEnd: String, private val onReveal: () -> Unit, - private val onCopy: (String) -> Unit, + private val onCopy: (String, CardDataType) -> Unit, ) { fun getInitialState() = TangemPayCardDetailsUM( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index 55b429059a..8fb38c33bc 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -18,6 +18,7 @@ import kotlinx.collections.immutable.toPersistentList @Suppress("LongParameterList") internal class TangemPayDetailsStateFactory( private val onBack: () -> Unit, + private val onOpenMenu: () -> Unit, private val intents: TangemPayDetailIntents, private val cardFrozenState: TangemPayCardFrozenState, private val converter: TangemPayCardFrozenStateConverter, @@ -47,6 +48,7 @@ internal class TangemPayDetailsStateFactory( return TangemPayDetailsUM( topBarConfig = TangemPayDetailsTopBarConfig( onBackClick = onBack, + onOpenMenu = onOpenMenu, items = listOfNotNull( TangemPayDetailsTopBarMenuItem( type = TangemPayDetailsTopBarMenuItemType.ChangePin, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt index 1010496a66..bd4daa10c9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt @@ -5,6 +5,7 @@ import kotlinx.collections.immutable.ImmutableList internal data class TangemPayDetailsTopBarConfig( val onBackClick: () -> Unit, + val onOpenMenu: () -> Unit, val items: ImmutableList?, ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 37663aa1f0..f67ed6b7b0 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -5,6 +5,7 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.model.CardDataType import kotlinx.collections.immutable.ImmutableList internal data class TangemPayDetailsUM( @@ -25,7 +26,7 @@ internal data class TangemPayCardDetailsUM( val cvv: String, val buttonText: TextReference = TextReference.EMPTY, val onClick: () -> Unit = {}, - val onCopy: (String) -> Unit = {}, + val onCopy: (String, CardDataType) -> Unit, val isHidden: Boolean = true, val isLoading: Boolean = false, val cardFrozenState: TangemPayCardFrozenState, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt index 5c83a87360..fcee864f89 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -9,6 +10,7 @@ import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayCardDetailsBlockStateFactory @@ -29,6 +31,7 @@ import com.tangem.utils.transformer.update as transformerUpdate private const val SHOW_DETAILS_TIME = 30_000L +@Suppress("LongParameterList") @Stable @ModelScoped internal class TangemPayCardDetailsBlockModel @Inject constructor( @@ -38,6 +41,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( private val clipboardManager: ClipboardManager, private val uiMessageSender: UiMessageSender, private val cardDetailsEventListener: CardDetailsEventListener, + private val analytics: AnalyticsEventHandler, ) : Model() { private val params: TangemPayCardDetailsBlockComponent.Params = paramsContainer.require() @@ -74,6 +78,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( } private fun revealCardDetails() { + analytics.send(TangemPayAnalyticsEvents.ViewCardDetailsClicked()) modelScope.launch { uiState.transformerUpdate( transformer = DetailsRevealProgressStateTransformer(onClickHide = ::hideCardDetails), @@ -115,7 +120,17 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( ) } - private fun copyData(text: String) { + private fun copyData(text: String, type: CardDataType) { + val event = when (type) { + CardDataType.Number -> TangemPayAnalyticsEvents.CopyCardNumberClicked() + CardDataType.Expiry -> TangemPayAnalyticsEvents.CopyCardExpiryClicked() + CardDataType.CVV -> TangemPayAnalyticsEvents.CopyCardCVVClicked() + } + analytics.send(event) clipboardManager.setText(text = text.filterNot { it.isWhitespace() }, isSensitive = true) } +} + +internal enum class CardDataType { + Number, Expiry, CVV } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt index 6d9f9d47f2..634b5a0e69 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -10,6 +11,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayChangePinUM @@ -30,17 +32,23 @@ internal class TangemPayChangePinModel @Inject constructor( private val uiMessageSender: UiMessageSender, private val router: Router, private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val analytics: AnalyticsEventHandler, ) : Model() { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() val uiState: StateFlow field = MutableStateFlow(getInitialState()) + init { + analytics.send(TangemPayAnalyticsEvents.ChangePinScreenShown()) + } + private fun onPinCodeChange(pin: String) { uiState.update(transformer = PinCodeChangeTransformer(newPin = pin)) } private fun onClickSubmit() { + analytics.send(TangemPayAnalyticsEvents.ChangePinSubmitClicked()) modelScope.launch { uiState.update { it.copy(submitButtonLoading = true) } val result = try { @@ -59,7 +67,10 @@ internal class TangemPayChangePinModel @Inject constructor( message = ToastMessage(resourceReference(R.string.tangempay_pin_validation_error_message)), ) } - SetPinResult.SUCCESS -> router.push(TangemPayDetailsInnerRoute.ChangePINSuccess) + SetPinResult.SUCCESS -> { + analytics.send(TangemPayAnalyticsEvents.ChangePinSuccessShown()) + router.push(TangemPayDetailsInnerRoute.ChangePINSuccess) + } SetPinResult.DECRYPTION_ERROR, SetPinResult.UNKNOWN_ERROR, null, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 2c2b69be13..d31e72f0a4 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -21,6 +21,8 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayTopUpData @@ -47,6 +49,8 @@ import com.tangem.features.tangempay.utils.TangemPayDetailIntents import com.tangem.features.tangempay.utils.TangemPayMessagesFactory import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions import com.tangem.features.tangempay.utils.TangemPayTxHistoryUpdateListener +import com.tangem.features.tokendetails.ExpressTransactionsEvent +import com.tangem.features.tokendetails.ExpressTransactionsEventListener import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -78,6 +82,7 @@ internal class TangemPayDetailsModel @Inject constructor( private val orderRepository: CustomerOrderRepository, private val getUserWalletUseCase: GetUserWalletUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val expressTransactionsEventListener: ExpressTransactionsEventListener, ) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener, ViewPinListener { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() @@ -85,6 +90,7 @@ internal class TangemPayDetailsModel @Inject constructor( private val cardFrozenStateConverter = TangemPayCardFrozenStateConverter(onUnfreezeClick = ::onClickUnfreezeCard) private val stateFactory = TangemPayDetailsStateFactory( onBack = router::pop, + onOpenMenu = ::onOpenMenu, intents = this, cardFrozenState = params.config.cardFrozenState, converter = cardFrozenStateConverter, @@ -99,6 +105,11 @@ internal class TangemPayDetailsModel @Inject constructor( private var balance: TangemPayCardBalance? = null + private val userWallet: UserWallet? = getUserWalletUseCase(params.userWalletId).getOrNull() + val cryptoCurrency: CryptoCurrency? = userWallet?.let { wallet -> + tangemPayCryptoCurrencyFactory.create(userWallet = wallet, chainId = params.config.chainId).getOrNull() + } + val bottomSheetNavigation: SlotNavigation = SlotNavigation() init { @@ -109,6 +120,18 @@ internal class TangemPayDetailsModel @Inject constructor( subscribeToCardFrozenState() } + fun onResume() { + modelScope.launch { + expressTransactionsEventListener.send(ExpressTransactionsEvent.Update) + } + } + + fun onPause() { + modelScope.launch { + expressTransactionsEventListener.send(ExpressTransactionsEvent.Clear) + } + } + private fun subscribeToCardFrozenState() { cardDetailsRepository .cardFrozenState(params.config.cardId) @@ -126,6 +149,7 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onClickPinCode() { + analytics.send(TangemPayAnalyticsEvents.PinCodeClicked()) if (!params.config.isPinSet) { router.push(TangemPayDetailsInnerRoute.ChangePIN) } else { @@ -139,14 +163,19 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onClickFreezeCard() { + analytics.send(TangemPayAnalyticsEvents.FreezeCardClicked()) uiMessageSender.send(TangemPayMessagesFactory.createFreezeCardMessage(onFreezeClicked = ::freezeCard)) + analytics.send(TangemPayAnalyticsEvents.FreezeCardConfirmShown()) } override fun onClickUnfreezeCard() { + analytics.send(TangemPayAnalyticsEvents.UnfreezeCardClicked()) uiMessageSender.send(TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard)) + analytics.send(TangemPayAnalyticsEvents.UnfreezeCardConfirmShown()) } private fun freezeCard() { + analytics.send(TangemPayAnalyticsEvents.FreezeCardConfirmClicked()) modelScope.launch { val result = try { cardDetailsRepository.freezeCard(userWalletId = params.userWalletId, cardId = params.config.cardId) @@ -185,6 +214,7 @@ internal class TangemPayDetailsModel @Inject constructor( } private fun unfreezeCard() { + analytics.send(TangemPayAnalyticsEvents.UnfreezeCardConfirmClicked()) modelScope.launch { val result = try { cardDetailsRepository.unfreezeCard(userWalletId = params.userWalletId, cardId = params.config.cardId) @@ -242,6 +272,7 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onClickWithdraw() { + analytics.send(TangemPayAnalyticsEvents.WithdrawClicked()) val currentBalance = balance val depositAddress = currentBalance?.depositAddress if (currentBalance == null || depositAddress == null) { @@ -252,8 +283,8 @@ internal class TangemPayDetailsModel @Inject constructor( if (hasActiveWithdrawal) { showBottomSheetError(TangemPayDetailsErrorType.WithdrawInProgress) } else { - val userWallet = getUserWalletUseCase(params.userWalletId).getOrNull() - val currency = userWallet?.let { + val userWallet = userWallet ?: getUserWalletUseCase(params.userWalletId).getOrNull() + val currency = cryptoCurrency ?: userWallet?.let { tangemPayCryptoCurrencyFactory.create(userWallet = userWallet, chainId = params.config.chainId) .getOrNull() } @@ -323,6 +354,7 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onContactSupportClicked() { + analytics.send(TangemPayAnalyticsEvents.GoToSupportOnBetaBannerClicked()) modelScope.launch { sendFeedbackEmailUseCase.invoke( type = FeedbackEmailType.Visa.FeatureIsBeta( @@ -336,6 +368,7 @@ internal class TangemPayDetailsModel @Inject constructor( modelScope.launch { uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = refreshState.value)) cardDetailsEventListener.send(CardDetailsEvent.Hide) + expressTransactionsEventListener.send(ExpressTransactionsEvent.Update) txHistoryUpdateListener.triggerUpdate() fetchBalance().join() uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = false)) @@ -343,6 +376,7 @@ internal class TangemPayDetailsModel @Inject constructor( } private fun onClickAddToWalletBlock() { + analytics.send(TangemPayAnalyticsEvents.AddToWalletClicked()) router.push(TangemPayDetailsInnerRoute.AddToWallet) } @@ -363,6 +397,10 @@ internal class TangemPayDetailsModel @Inject constructor( }.saveIn(addToWalletBannerJobHolder) } + private fun onOpenMenu() { + analytics.send(TangemPayAnalyticsEvents.CardSettingsClicked()) + } + override fun onClickSwap(data: TangemPayTopUpData) { analytics.send(TangemPayAnalyticsEvents.SwapClicked()) bottomSheetNavigation.dismiss() @@ -401,6 +439,7 @@ internal class TangemPayDetailsModel @Inject constructor( override fun onClickChangePin() { bottomSheetNavigation.dismiss() + analytics.send(TangemPayAnalyticsEvents.ChangePinOnCurrentPinClicked()) router.push(TangemPayDetailsInnerRoute.ChangePIN) } @@ -409,6 +448,13 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onTransactionClick(item: TangemPayTxHistoryItem) { + val (type, status) = when (item) { + is TangemPayTxHistoryItem.Collateral -> "collateral" to "unknown" + is TangemPayTxHistoryItem.Fee -> "fee" to "unknown" + is TangemPayTxHistoryItem.Payment -> "payment" to "unknown" + is TangemPayTxHistoryItem.Spend -> "spend" to item.status.name.lowercase() + } + analytics.send(TangemPayAnalyticsEvents.TransactionInListClicked(type = type, status = status)) bottomSheetNavigation.activate( configuration = TangemPayDetailsNavigation.TransactionDetails( transaction = item, @@ -418,6 +464,7 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onClickTermsAndLimits() { + analytics.send(TangemPayAnalyticsEvents.TermsAndLimitsClicked()) urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt index 8dae2f4f77..1d6f1f82fa 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -9,6 +10,7 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent import com.tangem.features.tangempay.entity.TangemPayTxHistoryDetailsUM import com.tangem.features.tangempay.model.transformers.TangemPayTxHistoryDetailsConverter @@ -17,6 +19,7 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @Stable @ModelScoped internal class TangemPayTxHistoryDetailsModel @Inject constructor( @@ -25,6 +28,7 @@ internal class TangemPayTxHistoryDetailsModel @Inject constructor( private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val urlOpener: UrlOpener, private val balanceHidingSettings: GetBalanceHidingSettingsUseCase, + private val analytics: AnalyticsEventHandler, paramsContainer: ParamsContainer, ) : Model() { @@ -61,6 +65,7 @@ internal class TangemPayTxHistoryDetailsModel @Inject constructor( } private fun dispute() { + analytics.send(TangemPayAnalyticsEvents.SupportOnTransactionPopupClicked()) modelScope.launch { val walletMetaInfo = getWalletMetaInfoUseCase.invoke(params.userWalletId).getOrNull() ?: return@launch diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayViewPinModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayViewPinModel.kt index 14631dc022..a610d66541 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayViewPinModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayViewPinModel.kt @@ -1,10 +1,12 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.TangemPayViewPinComponent import com.tangem.features.tangempay.entity.TangemPayViewPinUM import com.tangem.features.tangempay.model.transformers.TangemPayViewPinErrorStateTransformer @@ -22,6 +24,7 @@ internal class TangemPayViewPinModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val analytics: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -30,6 +33,7 @@ internal class TangemPayViewPinModel @Inject constructor( field = MutableStateFlow(getInitialState()) init { + analytics.send(TangemPayAnalyticsEvents.CurrentPinShown()) getCardPin() } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt index eb2a5831d4..7fd6c66c61 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt @@ -187,7 +187,7 @@ private fun PreviewTangemPayAddToWalletScreen() { numberShort = "*1245", expiry = "••/••", cvv = "•••", - onCopy = {}, + onCopy = { _, _ -> }, onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index 403e3fe778..33e91f7745 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -33,6 +33,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.model.CardDataType private val CustomCardBlockColor = Color(0x1F828282) @@ -79,9 +80,9 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M cardNumber = state.number, expiry = state.expiry, cvv = state.cvv, - onCopyCardNumber = { state.onCopy(state.number) }, - onCopyCvv = { state.onCopy(state.cvv) }, - onCopyExpiry = { state.onCopy(state.expiry) }, + onCopyCardNumber = { state.onCopy(state.number, CardDataType.Number) }, + onCopyCvv = { state.onCopy(state.cvv, CardDataType.CVV) }, + onCopyExpiry = { state.onCopy(state.expiry, CardDataType.Expiry) }, onHideDetails = state.onClick, modifier = Modifier.graphicsLayer { rotationY = 180f }, ) @@ -309,7 +310,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide expiry = "", cvv = "", buttonText = resourceReference(R.string.tangempay_card_details_show_details), - onCopy = {}, + onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Frozen, ), @@ -321,7 +322,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide expiry = "", cvv = "", buttonText = resourceReference(R.string.tangempay_card_details_show_details), - onCopy = {}, + onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Unfrozen, ), @@ -333,7 +334,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide expiry = "", cvv = "", buttonText = resourceReference(R.string.tangempay_card_details_show_details), - onCopy = {}, + onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Pending, ), @@ -341,7 +342,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide isLoading = false, onClick = {}, buttonText = resourceReference(R.string.tangempay_card_details_hide_details), - onCopy = {}, + onCopy = { _, _ -> }, isHidden = false, number = "1234 5678 9012 3456", numberShort = "*3456", diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 255f5c37a5..da34d0e786 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -34,17 +34,22 @@ import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.components.text.applyBladeBrush -import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TokenDetailsTopBarTestTags import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.express.PreviewEmptyExpressTransactionsComponent import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.* +import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.persistentListOf @@ -54,6 +59,7 @@ internal fun TangemPayDetailsScreen( state: TangemPayDetailsUM, txHistoryComponent: TangemPayTxHistoryComponent, cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, + expressTransactionsComponent: ExpressTransactionsComponent, modifier: Modifier = Modifier, ) { Scaffold( @@ -66,6 +72,9 @@ internal fun TangemPayDetailsScreen( val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } val txHistoryState by txHistoryComponent.state.collectAsStateWithLifecycle() val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() + val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle() + val expressTransactionsBottomSheetState = expressState.bottomSheetSlot + val expressTransactionsDialogState = expressState.dialogSlot TangemPullToRefreshContainer( config = state.pullToRefreshConfig, @@ -138,9 +147,19 @@ internal fun TangemPayDetailsScreen( ) }, ) + with(expressTransactionsComponent) { + expressTransactionsContent( + state = expressState.transactions, + modifier = modifier + .padding(start = 16.dp, end = 16.dp, top = 12.dp) + .fillMaxWidth(), + ) + } with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) } } } + expressTransactionsDialogState?.content() + expressTransactionsBottomSheetState?.content() } } @@ -235,7 +254,12 @@ private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modi title = {}, actions = { AnimatedVisibility(visible = config.items != null && config.items.isNotEmpty()) { - IconButton(onClick = { showDropdownMenu = true }) { + IconButton( + onClick = { + config.onOpenMenu() + showDropdownMenu = true + }, + ) { Icon( painter = painterResource(id = R.drawable.ic_more_vertical_24), tint = TangemTheme.colors.icon.primary1, @@ -286,12 +310,13 @@ private fun TangemPayDetailsScreenPreview( numberShort = "*1245", expiry = "••/••", cvv = "•••", - onCopy = {}, + onCopy = { _, _ -> }, onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, ), ), + expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), ) } } @@ -299,7 +324,7 @@ private fun TangemPayDetailsScreenPreview( private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( collection = listOf( TangemPayDetailsUM( - topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, items = null), + topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, onOpenMenu = {}, items = null), pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), balanceBlockState = TangemPayDetailsBalanceBlockState.Content( actionButtons = persistentListOf( @@ -328,7 +353,7 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider }, onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, ), ), + expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), ) } } diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt index 83700ab313..a3dc4ea532 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt @@ -13,6 +13,7 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.navigation.url.UrlOpener import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayEligibilityManager +import com.tangem.domain.pay.model.CustomerInfo.KycStatus import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents @@ -95,7 +96,7 @@ internal class TangemPayOnboardingModel @Inject constructor( .onRight { customerInfo -> uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = false)) when { - !customerInfo.isKycApproved -> { + customerInfo.kycStatus != KycStatus.APPROVED -> { when (params) { is TangemPayOnboardingComponent.Params.ContinueOnboarding -> openKyc(userWalletId) else -> startOnboarding(userWalletId) @@ -150,6 +151,7 @@ internal class TangemPayOnboardingModel @Inject constructor( if (eligibleWalletsIds.size == 1) { checkCustomerInfo(userWalletId = eligibleWalletsIds[0]) } else { + analytics.send(TangemPayAnalyticsEvents.ChooseWalletPopup()) bottomSheetNavigation.activate(TangemPayOnboardingNavigation.WalletSelector(eligibleWalletsIds)) } } @@ -171,7 +173,7 @@ internal class TangemPayOnboardingModel @Inject constructor( uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = false)) }, ifRight = { customerInfo -> - if (customerInfo.isKycApproved) { + if (customerInfo.kycStatus == KycStatus.APPROVED) { back() } else { openKyc(userWalletId) diff --git a/features/tester/impl/build.gradle.kts b/features/tester/impl/build.gradle.kts index ba40b749c4..57fe70672c 100644 --- a/features/tester/impl/build.gradle.kts +++ b/features/tester/impl/build.gradle.kts @@ -59,8 +59,6 @@ dependencies { /** Feature Apis */ implementation(projects.features.tester.api) implementation(projects.features.pushNotifications.api) - implementation(projects.features.news.newsDetails.api) - implementation(projects.features.news.newsDetails.impl) /* SDK */ implementation(tangemDeps.blockchain) diff --git a/features/tester/impl/detekt-baseline-debug.xml b/features/tester/impl/detekt-baseline-debug.xml index be0960afae..84eba5dc41 100644 --- a/features/tester/impl/detekt-baseline-debug.xml +++ b/features/tester/impl/detekt-baseline-debug.xml @@ -3,7 +3,6 @@ BooleanPropertyNaming:ExcludedBlockchainsScreenUM.kt$ExcludedBlockchainsScreenUM$val showRecoverWarning: Boolean - CanBeNonNullable:TestPushDeepLinkBottomSheet.kt$config: TangemBottomSheetConfig? CastNullableToNonNullableType:TestPushMarketTokenSearchBottomSheetTransformer.kt$TestPushMarketTokenSearchBottomSheetTransformer$as CastNullableToNonNullableType:TestPushViewModel.kt$TestPushViewModel$as ExplicitCollectionElementAccessMethod:TestPushAddKeyDataTransformer.kt$TestPushAddKeyDataTransformer$mutableData.set(index = index, updated) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt index 393fac33ad..e59702c4e2 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt @@ -35,11 +35,6 @@ import com.tangem.feature.tester.presentation.providers.ui.BlockchainProvidersSc import com.tangem.feature.tester.presentation.providers.viewmodel.BlockchainProvidersViewModel import com.tangem.feature.tester.presentation.testpush.ui.TestPushScreen import com.tangem.feature.tester.presentation.testpush.viewmodel.TestPushViewModel -import com.tangem.feature.tester.presentation.news.ui.NewsScreen -import com.tangem.feature.tester.presentation.news.viewmodel.NewsViewModel -import com.tangem.features.news.details.impl.MockArticlesFactory -import com.tangem.features.news.details.impl.ui.NewsDetailsContent -import com.tangem.features.news.details.impl.ui.NewsDetailsUM import dagger.hilt.android.AndroidEntryPoint import kotlinx.collections.immutable.persistentSetOf import javax.inject.Inject @@ -87,7 +82,6 @@ internal class TesterActivity : ComposeActivity() { ButtonUM.TESTER_ACTIONS, ButtonUM.TEST_PUSHES, ButtonUM.ACCOUNTS, - ButtonUM.NEWS, ), onButtonClick = { buttonUM -> val route = when (buttonUM) { @@ -98,7 +92,6 @@ internal class TesterActivity : ComposeActivity() { ButtonUM.TESTER_ACTIONS -> TesterScreen.TESTER_ACTIONS ButtonUM.TEST_PUSHES -> TesterScreen.TEST_PUSHES ButtonUM.ACCOUNTS -> TesterScreen.ACCOUNTS - ButtonUM.NEWS -> TesterScreen.NEWS } innerTesterRouter.open(route) @@ -169,27 +162,6 @@ internal class TesterActivity : ComposeActivity() { AccountsScreen(state) } - - composable(route = TesterScreen.NEWS.name) { - val viewModel = hiltViewModel().apply { - setupNavigation(innerTesterRouter) - } - val state by viewModel.uiState.collectAsStateWithLifecycle() - - NewsScreen(state) - } - - composable(route = TesterScreen.NEWS_DETAILS.name) { - NewsDetailsContent( - state = NewsDetailsUM( - articles = MockArticlesFactory.createMockArticles(), - selectedArticleIndex = 0, - onLikeClick = { }, - onShareClick = { }, - ), - onBackClick = { innerTesterRouter.back() }, - ) - } } } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt index 02d68f4f73..84bcfc5fa8 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SpacerH24 import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -67,6 +68,7 @@ internal fun EnvironmentTogglesScreen(uiModel: EnvironmentTogglesScreenUM) { ) } } + SpacerH24() } @Composable @@ -140,7 +142,7 @@ private fun EnvironmentButtons(uiModel: EnvironmentTogglesScreenUM.ApiInfoUM, on colors = SegmentedButtonDefaults.colors( activeContainerColor = TangemTheme.colors.control.checked, activeContentColor = TangemTheme.colors.text.primary2, - inactiveContainerColor = TangemTheme.colors.control.unchecked, + inactiveContainerColor = TangemTheme.colors.control.default, inactiveContentColor = TangemTheme.colors.text.primary1, ), border = BorderStroke(0.dp, TangemTheme.colors.background.tertiary), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuUM.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuUM.kt index 00a019e9c4..fa30058309 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuUM.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuUM.kt @@ -25,6 +25,5 @@ data class TesterMenuUM( TESTER_ACTIONS(R.string.tester_actions), TEST_PUSHES(R.string.test_push), ACCOUNTS(R.string.accounts), - NEWS(R.string.news), } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt index 854706cec2..e87d942c19 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt @@ -14,6 +14,4 @@ internal enum class TesterScreen { BLOCKCHAIN_PROVIDERS, TEST_PUSHES, ACCOUNTS, - NEWS, - NEWS_DETAILS, } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/state/NewsUM.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/state/NewsUM.kt deleted file mode 100644 index b832b363d8..0000000000 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/state/NewsUM.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tangem.feature.tester.presentation.news.state - -import androidx.annotation.StringRes -import com.tangem.feature.tester.impl.R -import kotlinx.collections.immutable.ImmutableSet - -data class NewsUM( - val onBackClick: () -> Unit, - val buttons: ImmutableSet, - val onButtonClick: (ButtonUM) -> Unit, -) { - - enum class ButtonUM(@StringRes val textResId: Int) { - NEWS_DETAILS(R.string.news_details), - } -} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/ui/NewsScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/ui/NewsScreen.kt deleted file mode 100644 index 2f0b9c690c..0000000000 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/ui/NewsScreen.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.tangem.feature.tester.presentation.news.ui - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.PrimaryButton -import com.tangem.core.ui.components.appbar.AppBarWithBackButton -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.feature.tester.impl.R -import com.tangem.feature.tester.presentation.news.state.NewsUM -import kotlinx.collections.immutable.ImmutableSet - -@OptIn(ExperimentalFoundationApi::class) -@Composable -internal fun NewsScreen(state: NewsUM, modifier: Modifier = Modifier) { - BackHandler(onBack = state.onBackClick) - - LazyColumn( - modifier = modifier - .fillMaxSize() - .background(TangemTheme.colors.background.primary), - ) { - stickyHeader { AppBar(onBackClick = state.onBackClick) } - - NewsButtons(buttons = state.buttons, onButtonClick = state.onButtonClick) - } -} - -@Composable -private fun AppBar(onBackClick: () -> Unit) { - AppBarWithBackButton( - onBackClick = onBackClick, - text = stringResourceSafe(id = R.string.news), - containerColor = TangemTheme.colors.background.primary, - ) -} - -@Suppress("FunctionNaming") -private fun LazyListScope.NewsButtons( - buttons: ImmutableSet, - onButtonClick: (NewsUM.ButtonUM) -> Unit, -) { - items(buttons.toList()) { button -> - PrimaryButton( - text = stringResourceSafe(button.textResId), - onClick = { onButtonClick(button) }, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - .fillMaxWidth(), - ) - } -} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/viewmodel/NewsViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/viewmodel/NewsViewModel.kt deleted file mode 100644 index 5ed86201e4..0000000000 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/news/viewmodel/NewsViewModel.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.tangem.feature.tester.presentation.news.viewmodel - -import androidx.lifecycle.ViewModel -import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter -import com.tangem.feature.tester.presentation.navigation.TesterScreen -import com.tangem.feature.tester.presentation.news.state.NewsUM -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.collections.immutable.persistentSetOf -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import javax.inject.Inject - -@HiltViewModel -internal class NewsViewModel @Inject constructor() : ViewModel() { - - private val _uiState = MutableStateFlow(createInitialState()) - val uiState: StateFlow = _uiState - - private var router: InnerTesterRouter? = null - - fun setupNavigation(router: InnerTesterRouter) { - this.router = router - } - - private fun createInitialState(): NewsUM { - return NewsUM( - onBackClick = ::onBackClick, - buttons = persistentSetOf( - NewsUM.ButtonUM.NEWS_DETAILS, - ), - onButtonClick = ::onButtonClick, - ) - } - - private fun onBackClick() { - router?.back() - } - - private fun onButtonClick(button: NewsUM.ButtonUM) { - when (button) { - NewsUM.ButtonUM.NEWS_DETAILS -> router?.open(TesterScreen.NEWS_DETAILS) - } - } -} \ No newline at end of file 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/tester/impl/src/main/res/values/strings.xml b/features/tester/impl/src/main/res/values/strings.xml index 15b31a07c6..901f76c14e 100644 --- a/features/tester/impl/src/main/res/values/strings.xml +++ b/features/tester/impl/src/main/res/values/strings.xml @@ -20,4 +20,5 @@ Accounts News News details + News details (Bottom Sheet) diff --git a/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/api/build.gradle.kts b/features/tokendetails/api/build.gradle.kts index 0551d08f54..72eb80b695 100644 --- a/features/tokendetails/api/build.gradle.kts +++ b/features/tokendetails/api/build.gradle.kts @@ -14,6 +14,9 @@ dependencies { implementation(projects.core.decompose) implementation(projects.core.ui) + /** Common */ + implementation(projects.common.ui) + /** Domain models */ api(projects.domain.models) implementation(projects.domain.tokens.models) @@ -21,4 +24,9 @@ dependencies { /** Compose */ implementation(deps.compose.runtime) + implementation(deps.compose.ui) + implementation(deps.compose.foundation) + + /** Other */ + implementation(deps.kotlin.immutable.collections) } \ No newline at end of file diff --git a/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsComponent.kt b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsComponent.kt new file mode 100644 index 0000000000..0ed67d34a6 --- /dev/null +++ b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsComponent.kt @@ -0,0 +1,27 @@ +package com.tangem.features.tokendetails + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM +import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.flow.StateFlow + +@Stable +interface ExpressTransactionsComponent { + + val state: StateFlow + + fun LazyListScope.expressTransactionsContent(state: PersistentList, modifier: Modifier) + + data class Params( + val userWalletId: UserWalletId, + val currency: CryptoCurrency, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsEventListener.kt b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsEventListener.kt new file mode 100644 index 0000000000..bbe0d91105 --- /dev/null +++ b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsEventListener.kt @@ -0,0 +1,14 @@ +package com.tangem.features.tokendetails + +import kotlinx.coroutines.flow.Flow + +interface ExpressTransactionsEventListener { + + val event: Flow + + suspend fun send(event: ExpressTransactionsEvent) +} + +enum class ExpressTransactionsEvent { + Update, Clear +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsModule.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsModule.kt index 356a4c6fe4..18e5a4f513 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsModule.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsModule.kt @@ -4,9 +4,14 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.feature.tokendetails.DefaultTokenDetailsComponent +import com.tangem.feature.tokendetails.presentation.DefaultExpressTransactionsComponent import com.tangem.feature.tokendetails.presentation.router.DefaultTokenDetailsRouter import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsModel import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsModel +import com.tangem.feature.tokendetails.presentation.tokendetails.state.utils.DefaultExpressTransactionsEventListener +import com.tangem.features.tokendetails.ExpressTransactionsComponent +import com.tangem.features.tokendetails.ExpressTransactionsEventListener import com.tangem.features.tokendetails.TokenDetailsComponent import dagger.Binds import dagger.Module @@ -14,18 +19,35 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.ClassKey import dagger.multibindings.IntoMap +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) internal interface TokenDetailsModule { @Binds - fun bindComponentFactory(factory: DefaultTokenDetailsComponent.Factory): TokenDetailsComponent.Factory + fun bindTokenDetailsComponentFactory(factory: DefaultTokenDetailsComponent.Factory): TokenDetailsComponent.Factory + + @Binds + fun bindExpressTransactionsComponent( + factory: DefaultExpressTransactionsComponent.Factory, + ): ExpressTransactionsComponent.Factory @Binds @IntoMap @ClassKey(TokenDetailsModel::class) - fun bindModel(model: TokenDetailsModel): Model + fun bindTokenDetailsModel(model: TokenDetailsModel): Model + + @Binds + @IntoMap + @ClassKey(ExpressTransactionsModel::class) + fun bindExpressTransactionsModel(model: ExpressTransactionsModel): Model + + @Binds + @Singleton + fun bindExpressTransactionsEventListener( + impl: DefaultExpressTransactionsEventListener, + ): ExpressTransactionsEventListener } @Module diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/DefaultExpressTransactionsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/DefaultExpressTransactionsComponent.kt new file mode 100644 index 0000000000..02b83cfe13 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/DefaultExpressTransactionsComponent.kt @@ -0,0 +1,42 @@ +package com.tangem.feature.tokendetails.presentation + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import com.tangem.common.ui.expressStatus.expressTransactionsItems +import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM +import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsModel +import com.tangem.features.tokendetails.ExpressTransactionsComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.flow.StateFlow + +@Stable +internal class DefaultExpressTransactionsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: ExpressTransactionsComponent.Params, +) : AppComponentContext by context, ExpressTransactionsComponent { + + private val model: ExpressTransactionsModel = getOrCreateModel(params = params) + override val state: StateFlow = model.uiState + + override fun LazyListScope.expressTransactionsContent( + state: PersistentList, + modifier: Modifier, + ) { + expressTransactionsItems(expressTxs = state, modifier = modifier) + } + + @AssistedFactory + interface Factory : ExpressTransactionsComponent.Factory { + override fun create( + context: AppComponentContext, + params: ExpressTransactionsComponent.Params, + ): DefaultExpressTransactionsComponent + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/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/ExpressTransactionsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt new file mode 100644 index 0000000000..6d5210c3cf --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt @@ -0,0 +1,306 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import arrow.core.right +import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig +import com.tangem.common.ui.expressStatus.state.BottomSheetSlot +import com.tangem.common.ui.expressStatus.state.DialogSlot +import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM +import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState +import com.tangem.common.ui.tokendetails.TokenDetailsDialogConfig +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase +import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory +import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express.ExpressStatusFactory +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsDialogs +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express.ExpressStatusBottomSheet +import com.tangem.features.tokendetails.ExpressTransactionsComponent +import com.tangem.features.tokendetails.ExpressTransactionsEvent +import com.tangem.features.tokendetails.ExpressTransactionsEventListener +import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.* +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +@Suppress("LongParameterList", "PropertyUsedBeforeDeclaration") +@Stable +@ModelScoped +internal class ExpressTransactionsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, + expressStatusFactory: ExpressStatusFactory.Factory, + getUserWalletUseCase: GetUserWalletUseCase, + private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val networkHasDerivationUseCase: NetworkHasDerivationUseCase, + private val router: InnerTokenDetailsRouter, + private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val getAccountCryptoCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + private val expressTransactionsEventListener: ExpressTransactionsEventListener, +) : Model(), ExpressTransactionsClickIntents { + + private val params = paramsContainer.require() + private val userWalletId: UserWalletId = params.userWalletId + private val cryptoCurrency: CryptoCurrency = params.currency + + private val userWallet: UserWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: error("UserWallet not found") + + private val marketPriceJobHolder = JobHolder() + private val expressTxJobHolder = JobHolder() + private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() + + private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null + private var account: Account.CryptoPortfolio? = null + private val expressTxStatusTaskScheduler = SingleTaskScheduler>() + + private val waitForFirstExpressStatusEmmit = MutableStateFlow(false) + + private val currentStateProvider: Provider = Provider { internalUiState.value } + + private val stateFactory by lazy(mode = LazyThreadSafetyMode.NONE) { + TokenDetailsStateFactory( + appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), + currentStateProvider = currentStateProvider, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + tokenDetailsClickIntents = EmptyTokenDetailsClickIntents(), + expressTransactionsClickIntents = this, + networkHasDerivationUseCase = networkHasDerivationUseCase, + getUserWalletUseCase = getUserWalletUseCase, + userWalletId = userWalletId, + yieldSupplyFeatureToggles = yieldSupplyFeatureToggles, + ) + } + + private val expressStatusFactory by lazy(mode = LazyThreadSafetyMode.NONE) { + expressStatusFactory.create( + clickIntents = this, + appCurrencyProvider = Provider { selectedAppCurrencyFlow.value }, + currentStateProvider = currentStateProvider, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + userWallet = userWallet, + cryptoCurrency = cryptoCurrency, + ) + } + + private val internalUiState = MutableStateFlow(stateFactory.getInitialState(cryptoCurrency)) + + val uiState: StateFlow = internalUiState + .map(::mapInnerState) + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = mapInnerState(internalUiState.value), + ) + + init { + subscribeOnExternalEvents() + subscribeOnCurrencyStatusUpdates() + subscribeOnExpressTransactionsUpdates() + } + + override fun onExpressTransactionClick(txId: String) { + val expressTxState = internalUiState.value.expressTxsToDisplay.firstOrNull { it.info.txId == txId } + ?: return + internalUiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState) + } + + override fun onGoToProviderClick(url: String) { + router.openUrl(url) + } + + override fun onGoToRefundedTokenClick(cryptoCurrency: CryptoCurrency) { + router.openTokenDetails(userWalletId, cryptoCurrency) + } + + override fun onOpenUrlClick(url: String) { + router.openUrl(url) + } + + override fun onConfirmDisposeExpressStatus() { + internalUiState.value = stateFactory.getStateWithConfirmHideExpressStatus() + } + + override fun onDisposeExpressStatus() { + val bottomSheetState = internalUiState.value.bottomSheetConfig?.content + if (bottomSheetState is ExpressStatusBottomSheetConfig) { + modelScope.launch { + expressStatusFactory.removeTransactionOnBottomSheetClosed( + expressState = bottomSheetState.value, + isForceDispose = true, + ) + } + } + internalUiState.value = stateFactory.getStateWithClosedBottomSheet() + } + + override fun onDismissBottomSheet() { + when (val bsContent = internalUiState.value.bottomSheetConfig?.content) { + is ExpressStatusBottomSheetConfig -> { + modelScope.launch(dispatchers.main) { + expressStatusFactory.removeTransactionOnBottomSheetClosed(bsContent.value) + } + } + } + internalUiState.value = stateFactory.getStateWithClosedBottomSheet() + } + + override fun onDismissDialog() { + internalUiState.value = stateFactory.getStateWithClosedDialog() + } + + override fun onDestroy() { + clear() + super.onDestroy() + } + + private fun mapInnerState(innerState: TokenDetailsState): ExpressTransactionsBlockState { + val bsContent = innerState.bottomSheetConfig?.content + return ExpressTransactionsBlockState( + transactions = innerState.expressTxsToDisplay, + bottomSheetSlot = if (bsContent != null && bsContent is ExpressStatusBottomSheetConfig) { + innerState.bottomSheetConfig.toBottomSheetSlot() + } else { + null + }, + dialogSlot = innerState.dialogConfig?.toDialogSlot(), + ) + } + + private fun TangemBottomSheetConfig.toBottomSheetSlot(): BottomSheetSlot { + val contentLambda: @Composable () -> Unit = { + when (this.content) { + is ExpressStatusBottomSheetConfig -> ExpressStatusBottomSheet(config = this) + } + } + return BottomSheetSlot(config = this, content = contentLambda) + } + + private fun TokenDetailsDialogConfig.toDialogSlot(): DialogSlot { + val contentLambda: @Composable () -> Unit = { + TokenDetailsDialogs(this) + } + return DialogSlot(config = this, content = contentLambda) + } + + private fun subscribeOnExternalEvents() { + modelScope.launch { + expressTransactionsEventListener.event.collect { event -> + when (event) { + ExpressTransactionsEvent.Update -> subscribeOnExpressTransactionsUpdates() + ExpressTransactionsEvent.Clear -> clear() + } + } + } + } + + private fun subscribeOnCurrencyStatusUpdates() { + if (accountsFeatureToggles.isFeatureEnabled) { + getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency) + .onEach { account = it.account } + .map { it.status.right() } + } else { + getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( + userWalletId = userWalletId, + currencyId = cryptoCurrency.id, + isSingleWalletWithTokens = userWallet is UserWallet.Cold && + userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken(), + ) + } + .distinctUntilChanged() + .onEach { maybeCurrencyStatus -> + internalUiState.value = stateFactory.getCurrencyLoadedBalanceState(maybeCurrencyStatus) + maybeCurrencyStatus.onRight { status -> + cryptoCurrencyStatus = status + } + } + .flowOn(dispatchers.main) + .launchIn(modelScope) + .saveIn(marketPriceJobHolder) + } + + private fun subscribeOnExpressTransactionsUpdates() { + expressTxStatusTaskScheduler.cancelTask() + expressStatusFactory.getExpressStatuses() + .distinctUntilChanged() + .onEach { waitForFirstExpressStatusEmmit.value = true } + .onEach { expressTxs -> + internalUiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs( + expressTxs = expressTxs, + updateBalance = { /* no-op */ }, + ) + expressTxStatusTaskScheduler.scheduleTask( + scope = modelScope, + task = PeriodicTask( + isDelayFirst = false, + delay = EXPRESS_STATUS_UPDATE_DELAY, + task = { + try { + Result.success( + expressStatusFactory.getUpdatedExpressStatuses(internalUiState.value.expressTxs), + ) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Result.failure(exception) + } + }, + onSuccess = { updatedTxs -> + internalUiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs( + expressTxs = updatedTxs, + updateBalance = { /* no-op */ }, + ) + }, + onError = { /* no-op */ }, + ), + ) + } + .flowOn(dispatchers.main) + .launchIn(modelScope) + .saveIn(expressTxJobHolder) + } + + private fun createSelectedAppCurrencyFlow(): StateFlow { + return getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + } + + private fun clear() { + expressTxStatusTaskScheduler.cancelTask() + expressTxJobHolder.cancel() + } + + private companion object { + const val EXPRESS_STATUS_UPDATE_DELAY = 10_000L + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index 66d495e481..bf41cabfe1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt @@ -24,8 +24,6 @@ interface TokenDetailsClickIntents { fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason) - fun onDismissDialog() - fun onHideClick() fun onHideConfirmed() @@ -42,14 +40,8 @@ interface TokenDetailsClickIntents { fun onAddressTypeSelected(addressModel: AddressModel) - fun onDismissBottomSheet() - fun onCloseRentInfoNotification() - fun onExpressTransactionClick(txId: String) - - fun onGoToProviderClick(url: String) - fun onSwapPromoDismiss(promoId: PromoId) fun onSwapPromoClick(promoId: PromoId) @@ -72,6 +64,15 @@ interface TokenDetailsClickIntents { fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig) + fun onYieldInfoClick() +} + +interface ExpressTransactionsClickIntents { + + fun onExpressTransactionClick(txId: String) + + fun onGoToProviderClick(url: String) + fun onGoToRefundedTokenClick(cryptoCurrency: CryptoCurrency) fun onOpenUrlClick(url: String) @@ -80,5 +81,69 @@ interface TokenDetailsClickIntents { fun onDisposeExpressStatus() - fun onYieldInfoClick() + fun onDismissBottomSheet() + + fun onDismissDialog() +} + +@Suppress("TooManyFunctions") +internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents { + override fun onRefreshSwipe(isRefreshing: Boolean) { /* no op */ } + + override fun onBackClick() { /* no op */ } + + override fun onBuyClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } + + override fun onBuyCoinClick(cryptoCurrency: CryptoCurrency) { /* no op */ } + + override fun onStakeBannerClick() { /* no op */ } + + override fun onReloadClick() { /* no op */ } + + override fun onSendClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } + + override fun onReceiveClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } + + override fun onStakeClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } + + override fun onGenerateExtendedKey() { /* no op */ } + + override fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } + + override fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } + + override fun onHideClick() { /* no op */ } + + override fun onHideConfirmed() { /* no op */ } + + override fun onExploreClick() { /* no op */ } + + override fun onAddressTypeSelected(addressModel: AddressModel) { /* no op */ } + + override fun onTransactionClick(txHash: String) { /* no op */ } + + override fun onCloseRentInfoNotification() { /* no op */ } + + override fun onSwapPromoDismiss(promoId: PromoId) { /* no op */ } + + override fun onSwapPromoClick(promoId: PromoId) { /* no op */ } + + override fun onRetryIncompleteTransactionClick() { /* no op */ } + + override fun onOpenTrustlineClick() { /* no op */ } + + override fun onDismissIncompleteTransactionClick() { /* no op */ } + + override fun onConfirmDismissIncompleteTransactionClick() { /* no op */ } + + override fun onAssociateClick() { /* no op */ } + + override fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig) { /* no op */ } + + override fun onYieldInfoClick() { /* no op */ } + + override fun onCopyAddress(): TextReference? { + /* no op */ + return null + } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 0b39af391f..ba5ee42ad8 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -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, @@ -155,7 +153,10 @@ internal class TokenDetailsModel @Inject constructor( private val getAccountCryptoCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val yieldSupplyGetRewardsBalanceUseCase: YieldSupplyGetRewardsBalanceUseCase, -) : Model(), TokenDetailsClickIntents, YieldSupplyDepositedWarningComponent.ModelCallback { +) : Model(), + TokenDetailsClickIntents, + ExpressTransactionsClickIntents, + YieldSupplyDepositedWarningComponent.ModelCallback { private val params = paramsContainer.require() private val userWalletId: UserWalletId = params.userWalletId @@ -186,7 +187,8 @@ internal class TokenDetailsModel @Inject constructor( currentStateProvider = Provider { uiState.value }, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, - clickIntents = this, + tokenDetailsClickIntents = this, + expressTransactionsClickIntents = this, networkHasDerivationUseCase = networkHasDerivationUseCase, getUserWalletUseCase = getUserWalletUseCase, userWalletId = userWalletId, @@ -1119,18 +1121,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/TokenDetailsState.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt index 40805e48b9..ed8cf22b9f 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt @@ -1,10 +1,10 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM +import com.tangem.common.ui.tokendetails.TokenDetailsDialogConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.marketprice.MarketPriceBlockState -import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.PersistentList diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt index 742e94dab2..52acac7843 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt @@ -20,14 +20,14 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.onramp.model.OnrampStatus import com.tangem.domain.onramp.model.cache.OnrampTransaction import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsClickIntents import com.tangem.features.tokendetails.impl.R import com.tangem.utils.Provider import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.persistentListOf internal class TokenDetailsOnrampTransactionStateConverter( - private val clickIntents: TokenDetailsClickIntents, + private val clickIntents: ExpressTransactionsClickIntents, private val cryptoCurrency: CryptoCurrency, private val cryptoCurrencyStatusProvider: Provider, private val appCurrencyProvider: Provider, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt index e87c73a48e..ba6a44c2be 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt @@ -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 }, ) @@ -174,8 +174,20 @@ internal class TokenDetailsStakingInfoConverter( private fun getRewardText(status: CryptoCurrencyStatus, stakingRewardAmount: BigDecimal?): TextReference { val blockchainId = status.currency.network.rawId + val isCoin = status.currency.id.isCoin + val stakingBalance = status.value.stakingBalance + val rewardBlockType = when { - isStakingRewardUnavailable(blockchainId) -> RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + stakingBalance is StakingBalance.Data.P2PEthPool -> { + if (stakingBalance.totalRewards.isNullOrZero()) { + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + } else { + RewardBlockType.EthereumEarnedRewards + } + } + isStakingRewardUnavailable(blockchainId, isCoin) -> { + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + } stakingRewardAmount.isNullOrZero() -> RewardBlockType.NoRewards else -> RewardBlockType.Rewards } @@ -185,6 +197,20 @@ internal class TokenDetailsStakingInfoConverter( RewardBlockType.RewardUnavailable.DefaultRewardUnavailable, RewardBlockType.RewardUnavailable.SolanaRewardUnavailable, -> TextReference.EMPTY + RewardBlockType.EthereumEarnedRewards -> { + val cryptoRewardAmount = (stakingBalance as? StakingBalance.Data.P2PEthPool)?.totalRewards + resourceReference( + R.string.staking_details_autocompound_rewards_earned, + wrappedList( + cryptoRewardAmount.format { + crypto( + symbol = status.currency.symbol, + decimals = status.currency.decimals, + ) + }, + ), + ) + } RewardBlockType.RewardsRequirementsError, RewardBlockType.Rewards, -> resourceReference( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt index da13ce39ba..87552d058e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt @@ -3,6 +3,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory import arrow.core.Either import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetConfig import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig +import com.tangem.common.ui.tokendetails.TokenDetailsDialogConfig import com.tangem.common.ui.tokens.getUnavailabilityReasonText import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem @@ -27,12 +28,12 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase import com.tangem.domain.yield.supply.models.YieldSupplyRewardBalance +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsAppBarMenuConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState -import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig import com.tangem.features.tokendetails.impl.R import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.utils.Provider @@ -43,7 +44,8 @@ internal class TokenDetailsStateFactory( private val currentStateProvider: Provider, private val appCurrencyProvider: Provider, private val cryptoCurrencyStatusProvider: Provider, - private val clickIntents: TokenDetailsClickIntents, + private val tokenDetailsClickIntents: TokenDetailsClickIntents, + private val expressTransactionsClickIntents: ExpressTransactionsClickIntents, private val networkHasDerivationUseCase: NetworkHasDerivationUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val userWalletId: UserWalletId, @@ -52,7 +54,7 @@ internal class TokenDetailsStateFactory( private val skeletonStateConverter by lazy { TokenDetailsSkeletonStateConverter( - clickIntents = clickIntents, + clickIntents = tokenDetailsClickIntents, networkHasDerivationUseCase = networkHasDerivationUseCase, getUserWalletUseCase = getUserWalletUseCase, userWalletId = userWalletId, @@ -64,7 +66,7 @@ internal class TokenDetailsStateFactory( TokenDetailsNotificationConverter( userWalletId = userWalletId, getUserWalletUseCase = getUserWalletUseCase, - clickIntents = clickIntents, + clickIntents = tokenDetailsClickIntents, ) } @@ -72,7 +74,7 @@ internal class TokenDetailsStateFactory( TokenDetailsLoadedBalanceConverter( currentStateProvider = currentStateProvider, appCurrencyProvider = appCurrencyProvider, - clickIntents = clickIntents, + clickIntents = tokenDetailsClickIntents, yieldSupplyFeatureToggles = yieldSupplyFeatureToggles, ) } @@ -80,7 +82,7 @@ internal class TokenDetailsStateFactory( private val tokenDetailsButtonsConverter by lazy { TokenDetailsActionButtonsConverter( currentStateProvider = currentStateProvider, - clickIntents = clickIntents, + clickIntents = tokenDetailsClickIntents, ) } @@ -117,7 +119,7 @@ internal class TokenDetailsStateFactory( return TokenDetailsStakingInfoConverter( currentState = state, cryptoCurrencyStatus = cryptoCurrencyStatus, - clickIntents = clickIntents, + clickIntents = tokenDetailsClickIntents, appCurrencyProvider = appCurrencyProvider, stakingEntryInfo = stakingEntryInfo, ).convert(stakingAvailability) @@ -136,11 +138,11 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( dialogConfig = TokenDetailsDialogConfig( isShow = true, - onDismissRequest = clickIntents::onDismissDialog, + onDismissRequest = expressTransactionsClickIntents::onDismissDialog, content = TokenDetailsDialogConfig.DialogContentConfig.ConfirmHideConfig( currencyTitle = currency.name, - onConfirmClick = clickIntents::onHideConfirmed, - onCancelClick = clickIntents::onDismissDialog, + onConfirmClick = tokenDetailsClickIntents::onHideConfirmed, + onCancelClick = expressTransactionsClickIntents::onDismissDialog, ), ), ) @@ -150,12 +152,12 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( dialogConfig = TokenDetailsDialogConfig( isShow = true, - onDismissRequest = clickIntents::onDismissDialog, + onDismissRequest = expressTransactionsClickIntents::onDismissDialog, content = TokenDetailsDialogConfig.DialogContentConfig.HasLinkedTokensConfig( currencyName = currency.name, currencySymbol = currency.symbol, networkName = currency.network.name, - onConfirmClick = clickIntents::onDismissDialog, + onConfirmClick = expressTransactionsClickIntents::onDismissDialog, ), ), ) @@ -165,10 +167,10 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( dialogConfig = TokenDetailsDialogConfig( isShow = true, - onDismissRequest = clickIntents::onDismissDialog, + onDismissRequest = expressTransactionsClickIntents::onDismissDialog, content = TokenDetailsDialogConfig.DialogContentConfig.RemoveIncompleteTransactionConfirmDialogConfig( - onConfirmClick = clickIntents::onConfirmDismissIncompleteTransactionClick, - onCancelClick = clickIntents::onDismissDialog, + onConfirmClick = tokenDetailsClickIntents::onConfirmDismissIncompleteTransactionClick, + onCancelClick = expressTransactionsClickIntents::onDismissDialog, ), ), ) @@ -178,10 +180,10 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( dialogConfig = TokenDetailsDialogConfig( isShow = true, - onDismissRequest = clickIntents::onDismissDialog, + onDismissRequest = expressTransactionsClickIntents::onDismissDialog, content = TokenDetailsDialogConfig.DialogContentConfig.DisabledButtonReasonDialogConfig( text = unavailabilityReason.getUnavailabilityReasonText(), - onConfirmClick = clickIntents::onDismissDialog, + onConfirmClick = expressTransactionsClickIntents::onDismissDialog, ), ), ) @@ -191,10 +193,10 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( dialogConfig = TokenDetailsDialogConfig( isShow = true, - onDismissRequest = clickIntents::onDismissDialog, + onDismissRequest = expressTransactionsClickIntents::onDismissDialog, content = TokenDetailsDialogConfig.DialogContentConfig.ErrorDialogConfig( text = text, - onConfirmClick = clickIntents::onDismissDialog, + onConfirmClick = expressTransactionsClickIntents::onDismissDialog, ), ), ) @@ -217,7 +219,7 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( bottomSheetConfig = TangemBottomSheetConfig( isShown = true, - onDismissRequest = clickIntents::onDismissBottomSheet, + onDismissRequest = expressTransactionsClickIntents::onDismissBottomSheet, content = TokenReceiveBottomSheetConfig( asset = TokenReceiveBottomSheetConfig.Asset.Currency( name = currency.name, @@ -240,7 +242,7 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( bottomSheetConfig = TangemBottomSheetConfig( isShown = true, - onDismissRequest = clickIntents::onDismissBottomSheet, + onDismissRequest = expressTransactionsClickIntents::onDismissBottomSheet, content = ChooseAddressBottomSheetConfig( asset = TokenReceiveBottomSheetConfig.Asset.Currency( name = currency.name, @@ -248,7 +250,7 @@ internal class TokenDetailsStateFactory( ), network = currency.network, networkAddress = networkAddress, - onClick = clickIntents::onAddressTypeSelected, + onClick = tokenDetailsClickIntents::onAddressTypeSelected, ), ), ) @@ -338,13 +340,13 @@ internal class TokenDetailsStateFactory( return currentStateProvider().copy( dialogConfig = TokenDetailsDialogConfig( isShow = true, - onDismissRequest = clickIntents::onDismissDialog, + onDismissRequest = expressTransactionsClickIntents::onDismissDialog, content = TokenDetailsDialogConfig.DialogContentConfig.ConfirmExpressStatusHideDialogConfig( onConfirmClick = { - clickIntents.onDisposeExpressStatus() - clickIntents.onDismissDialog() + expressTransactionsClickIntents.onDisposeExpressStatus() + expressTransactionsClickIntents.onDismissDialog() }, - onCancelClick = clickIntents::onDismissDialog, + onCancelClick = expressTransactionsClickIntents::onDismissDialog, ), ), ) @@ -367,13 +369,13 @@ internal class TokenDetailsStateFactory( TangemDropdownMenuItem( title = resourceReference(R.string.token_details_generate_xpub), textColor = themedColor { TangemTheme.colors.text.primary1 }, - onClick = clickIntents::onGenerateExtendedKey, + onClick = tokenDetailsClickIntents::onGenerateExtendedKey, ).let(::add) } TangemDropdownMenuItem( title = TextReference.Res(id = R.string.token_details_hide_token), textColor = themedColor { TangemTheme.colors.text.warning }, - onClick = clickIntents::onHideClick, + onClick = tokenDetailsClickIntents::onHideClick, ).let(::add) }.toImmutableList(), ) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt index 42e28102ef..c4204e04e1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt @@ -25,7 +25,7 @@ import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isF import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeStatusState import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM @@ -43,7 +43,7 @@ import java.util.Locale // Fixme [REDACTED_JIRA] @Suppress("LargeClass") internal class TokenDetailsSwapTransactionsStateConverter( - private val clickIntents: TokenDetailsClickIntents, + private val clickIntents: ExpressTransactionsClickIntents, private val cryptoCurrency: CryptoCurrency, private val analyticsEventsHandler: AnalyticsEventHandler, appCurrencyProvider: Provider, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt index 3aed5a00de..d2b9ffaddb 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt @@ -18,7 +18,7 @@ import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.domain.* -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter @@ -44,7 +44,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val swapTransactionStatusStore: SwapTransactionStatusStore, private val analyticsEventsHandler: AnalyticsEventHandler, - @Assisted private val clickIntents: TokenDetailsClickIntents, + @Assisted private val clickIntents: ExpressTransactionsClickIntents, @Assisted private val appCurrencyProvider: Provider, @Assisted private val currentStateProvider: Provider, @Assisted private val userWallet: UserWallet, @@ -257,7 +257,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( @AssistedFactory interface Factory { fun create( - clickIntents: TokenDetailsClickIntents, + clickIntents: ExpressTransactionsClickIntents, appCurrencyProvider: Provider, currentStateProvider: Provider, userWallet: UserWallet, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt index 46a530ddbe..bb0dbbd4c4 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt @@ -12,7 +12,7 @@ import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent import com.tangem.feature.swap.domain.models.domain.ExchangeStatus -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM @@ -33,7 +33,7 @@ import kotlinx.coroutines.withContext @Suppress("LongParameterList") internal class ExpressStatusFactory @AssistedInject constructor( @Assisted private val currentStateProvider: Provider, - @Assisted private val clickIntents: TokenDetailsClickIntents, + @Assisted private val clickIntents: ExpressTransactionsClickIntents, @Assisted private val cryptoCurrency: CryptoCurrency, @Assisted appCurrencyProvider: Provider, @Assisted userWallet: UserWallet, @@ -205,7 +205,7 @@ internal class ExpressStatusFactory @AssistedInject constructor( interface Factory { @Suppress("LongParameterList") fun create( - clickIntents: TokenDetailsClickIntents, + clickIntents: ExpressTransactionsClickIntents, appCurrencyProvider: Provider, currentStateProvider: Provider, userWallet: UserWallet, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/OnrampStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/OnrampStatusFactory.kt index 61a05030ba..4543119759 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/OnrampStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/OnrampStatusFactory.kt @@ -15,7 +15,7 @@ import com.tangem.domain.onramp.OnrampUpdateTransactionStatusUseCase import com.tangem.domain.onramp.model.OnrampStatus import com.tangem.domain.onramp.model.OnrampStatus.Status.* import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsOnrampTransactionStateConverter import com.tangem.utils.Provider @@ -37,7 +37,7 @@ internal class OnrampStatusFactory @AssistedInject constructor( @Assisted private val currentStateProvider: Provider, @Assisted private val cryptoCurrencyStatusProvider: Provider, @Assisted private val appCurrencyProvider: Provider, - @Assisted private val clickIntents: TokenDetailsClickIntents, + @Assisted private val clickIntents: ExpressTransactionsClickIntents, @Assisted private val cryptoCurrency: CryptoCurrency, @Assisted private val userWallet: UserWallet, ) { @@ -157,7 +157,7 @@ internal class OnrampStatusFactory @AssistedInject constructor( currentStateProvider: Provider, cryptoCurrencyStatusProvider: Provider, appCurrencyProvider: Provider, - clickIntents: TokenDetailsClickIntents, + clickIntents: ExpressTransactionsClickIntents, cryptoCurrency: CryptoCurrency, userWallet: UserWallet, ): OnrampStatusFactory diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/DefaultExpressTransactionsEventListener.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/DefaultExpressTransactionsEventListener.kt new file mode 100644 index 0000000000..b6a5d1da32 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/DefaultExpressTransactionsEventListener.kt @@ -0,0 +1,24 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.utils + +import com.tangem.features.tokendetails.ExpressTransactionsEvent +import com.tangem.features.tokendetails.ExpressTransactionsEventListener +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class DefaultExpressTransactionsEventListener @Inject constructor() : ExpressTransactionsEventListener { + + private val _event = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 0, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + override val event: Flow = _event + + override suspend fun send(event: ExpressTransactionsEvent) { + _event.emit(event) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index f3466d9025..2e2ea39238 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -45,7 +45,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow // TODO: Split to blocks [REDACTED_JIRA] -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable internal fun TokenDetailsScreen( state: TokenDetailsState, @@ -62,6 +62,7 @@ internal fun TokenDetailsScreen( ) { scaffoldPaddings -> val listState = rememberLazyListState() val txHistoryComponentState by txHistoryComponent.txHistoryState.collectAsStateWithLifecycle() + val dialogConfig = state.dialogConfig val betweenItemsPadding = TangemTheme.dimens.spacing12 val horizontalPadding = TangemTheme.dimens.spacing16 val itemModifier = Modifier @@ -162,7 +163,9 @@ internal fun TokenDetailsScreen( } } - TokenDetailsDialogs(state = state) + if (dialogConfig != null) { + TokenDetailsDialogs(dialogConfig = dialogConfig) + } state.bottomSheetConfig?.let { config -> when (config.content) { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsDialogs.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsDialogs.kt index 3e57199b5a..5428e5d949 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsDialogs.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsDialogs.kt @@ -1,16 +1,14 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components import androidx.compose.runtime.Composable +import com.tangem.common.ui.tokendetails.TokenDetailsDialogConfig import com.tangem.core.ui.components.BasicDialog import com.tangem.core.ui.components.DialogButtonUM import com.tangem.core.ui.extensions.resolveReference -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState -import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig @Composable -internal fun TokenDetailsDialogs(state: TokenDetailsState) { - val dialogConfig = state.dialogConfig - if (dialogConfig != null && dialogConfig.isShow) { +internal fun TokenDetailsDialogs(dialogConfig: TokenDetailsDialogConfig) { + if (dialogConfig.isShow) { TokenDetailsDialog(config = dialogConfig) } } @@ -21,7 +19,7 @@ private fun TokenDetailsDialog(config: TokenDetailsDialogConfig) { message = config.content.message.resolveReference(), confirmButton = DialogButtonUM( title = config.content.confirmButtonConfig.text.resolveReference(), - isWarning = config.content.confirmButtonConfig.warning, + isWarning = config.content.confirmButtonConfig.hasWarning, onClick = config.content.confirmButtonConfig.onClick, ), onDismissDialog = config.onDismissRequest, @@ -29,7 +27,7 @@ private fun TokenDetailsDialog(config: TokenDetailsDialogConfig) { dismissButton = config.content.cancelButtonConfig?.let { cancelButtonConfig -> DialogButtonUM( title = cancelButtonConfig.text.resolveReference(), - isWarning = cancelButtonConfig.warning, + isWarning = cancelButtonConfig.hasWarning, onClick = cancelButtonConfig.onClick, ) }, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt index 21045da86e..0c5e2cba45 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt @@ -63,6 +63,7 @@ internal class TxHistoryItemToTransactionStateConverter( is TransactionType.UnknownOperation, is TransactionType.YieldSupply.Send, TransactionType.YieldSupply.Topup, + TransactionType.GaslessFee, -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 } } @@ -104,6 +105,7 @@ internal class TxHistoryItemToTransactionStateConverter( ) } is TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation) + TransactionType.GaslessFee -> resourceReference(R.string.gasless_transaction_fee) } private fun TxInfo.extractSubtitle(): TextReference { diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index f817314f14..a403130ffa 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -198,7 +198,7 @@ internal class WalletSettingsModel @Inject constructor( isNotificationsPermissionGranted: Boolean, accountList: List, ): PersistentList { - val isAccountsFeatureEnabled = accountsFeatureToggles.isFeatureEnabled + val isAccountsFeatureEnabled = accountItemsDelegate.isAccountsSupported(userWallet) val isMultiCurrency = when (userWallet) { is UserWallet.Cold -> userWallet.isMultiCurrency is UserWallet.Hot -> true diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt index 0d40442830..aa4c1a4b46 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt @@ -16,7 +16,6 @@ import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier -import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -45,7 +44,6 @@ internal class AccountItemsDelegate @Inject constructor( private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, - private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val accountListSortingSaver: AccountListSortingSaver, private val accountsFeatureToggles: AccountsFeatureToggles, private val analyticsEventHandler: AnalyticsEventHandler, @@ -53,15 +51,16 @@ internal class AccountItemsDelegate @Inject constructor( private val userWalletId = paramsContainer.require().userWalletId + fun isAccountsSupported(wallet: UserWallet) = accountsFeatureToggles.isFeatureEnabled && wallet.isAccountsSupported + fun loadAccount(wallet: UserWallet): Flow> { - if (!accountsFeatureToggles.isFeatureEnabled || !wallet.isAccountsSupported) return flowOf(emptyList()) + if (!isAccountsSupported(wallet)) return flowOf(emptyList()) return combine( flow = singleAccountStatusListSupplier(userWalletId), flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(), flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(), - flow4 = isAccountsModeEnabledUseCase(), - flow5 = accountListSortingSaver.accountsOrderFlow, + flow4 = accountListSortingSaver.accountsOrderFlow, transform = ::buildUiList, ) } @@ -70,7 +69,6 @@ internal class AccountItemsDelegate @Inject constructor( accountStatusList: AccountStatusList, appCurrency: AppCurrency, isBalanceHidden: Boolean, - isAccountsMode: Boolean, accountsOrder: List?, ): List = buildList { fun AccountStatus.CryptoPortfolio.mapCryptoPortfolio(): WalletSettingsAccountsUM { @@ -98,10 +96,7 @@ internal class AccountItemsDelegate @Inject constructor( ) add(header) - - if (isAccountsMode) { - addAll(accounts.map(::mapAccount).applySortingOrder(order = accountsOrder)) - } + addAll(accounts.map(::mapAccount).applySortingOrder(order = accountsOrder)) val isAddAccountEnabled = accounts.size < AccountList.MAX_ACCOUNTS_COUNT val shouldShowDescription = accounts.size > 1 diff --git a/features/wallet/impl/detekt-baseline-debug.xml b/features/wallet/impl/detekt-baseline-debug.xml index f3a1111f2a..4d05f69171 100644 --- a/features/wallet/impl/detekt-baseline-debug.xml +++ b/features/wallet/impl/detekt-baseline-debug.xml @@ -25,7 +25,6 @@ BooleanPropertyNaming:WalletScreen.kt$var visible by remember { mutableStateOf(value = false) } BooleanPropertyNaming:WalletScreenState.kt$WalletScreenState$val showMarketsOnboarding: Boolean BooleanPropertyNaming:WalletWithFundsChecker.kt$WalletWithFundsChecker$val prevStatus = statusByWalletId.get(userWalletId) - CanBeNonNullable:WalletScreen.kt$bottomSheetConfig: TangemBottomSheetConfig? IgnoredReturnValue:MultiWalletTokenListStore.kt$MultiWalletTokenListStore$remove(userWalletId) MaxChainedCallsOnSameLine:HasSingleWalletSignedHashesUseCase.kt$HasSingleWalletSignedHashesUseCase$userWallet.scanResponse.card.wallets.firstOrNull()?.totalSignedHashes MultilineLambdaItParameter:BasicTokenListSubscriber.kt$BasicTokenListSubscriber${ it.getOrElse { e -> Timber.e("Failed to load app currency: $e") AppCurrency.Default } } @@ -47,11 +46,7 @@ MultilineLambdaItParameter:SingleWalletExpressStatusesSubscriber.kt$SingleWalletExpressStatusesSubscriber${ Timber.e("Unable to get primary currency status: $it") return@onEach } MultilineLambdaItParameter:TokenListAnalyticsSender.kt$TokenListAnalyticsSender${ val status = it.value if (status is CryptoCurrencyStatus.Loaded) { sendTokenBalancesForSpecificBlockchains(it, status) } } MultilineLambdaItParameter:TokenListStateConverter.kt$TokenListStateConverter${ if (isExtend) { clickIntents.onAccountCollapseClick(it) } else { clickIntents.onAccountExpandClick(it) } } - MultilineLambdaItParameter:TxHistorySubscriber.kt$TxHistorySubscriber${ SetTxHistoryCountErrorTransformer( userWallet = userWallet, error = it, pendingTransactions = status.value.pendingTransactions, clickIntents = clickIntents, ) } - MultilineLambdaItParameter:TxHistorySubscriber.kt$TxHistorySubscriber${ SetTxHistoryCountTransformer( userWalletId = userWallet.walletId, transactionsCount = it, clickIntents = clickIntents, ) } MultilineLambdaItParameter:TxHistorySubscriber.kt$TxHistorySubscriber${ SetTxHistoryItemsErrorTransformer( userWalletId = userWallet.walletId, error = it, clickIntents = clickIntents, ) } - MultilineLambdaItParameter:TxHistorySubscriberV2.kt$TxHistorySubscriberV2${ SetTxHistoryCountErrorTransformer( userWallet = userWallet, error = it, pendingTransactions = status.value.pendingTransactions, clickIntents = clickIntents, ) } - MultilineLambdaItParameter:TxHistorySubscriberV2.kt$TxHistorySubscriberV2${ SetTxHistoryCountTransformer( userWalletId = userWallet.walletId, transactionsCount = it, clickIntents = clickIntents, ) } MultilineLambdaItParameter:TxHistorySubscriberV2.kt$TxHistorySubscriberV2${ SetTxHistoryItemsErrorTransformer( userWalletId = userWallet.walletId, error = it, clickIntents = clickIntents, ) } MultilineLambdaItParameter:UpdateMultiWalletActionsTransformer.kt$UpdateMultiWalletActionsTransformer${ when (it) { is WalletManageButton.Buy -> { it.copy( enabled = buyStatus.isContent(), dimContent = !buyStatus.isContent(), ) } is WalletManageButton.Sell -> { it.copy( enabled = sellStatus.isContent(), dimContent = !sellStatus.isContent(), ) } is WalletManageButton.Swap -> { it.copy( enabled = swapStatus.isContent(), dimContent = !swapStatus.isContent(), ) } else -> it } } MultilineLambdaItParameter:UseCaseExt.kt${ Timber.e("Impossible to get primary currency status $it") null } @@ -83,7 +78,6 @@ MultilineLambdaItParameter:WalletWarningsClickIntents.kt$WalletWarningsClickIntentsImplementor${ Timber.e( """ Unable to get user wallet |- ID: $userWalletId |- Exception: $it """.trimIndent(), ) null } MultilineLambdaItParameter:WalletWarningsClickIntents.kt$WalletWarningsClickIntentsImplementor${ router.openOnboardingScreen( scanResponse = it.scanResponse, continueBackup = true, ) } MultilineLambdaItParameter:WalletWithFundsChecker.kt$WalletWithFundsChecker${ val amount = it.value.amount ?: return@any false !amount.isZero() } - NamedArguments:BasicAccountListSubscriber.kt$BasicAccountListSubscriber$updateContent(convertParams, appCurrency, yieldSupplyApyMap, stakingApyMap) NamedArguments:GetMultiWalletWarningsFactory.kt$GetMultiWalletWarningsFactory$addInformationalNotifications(userWallet, cardTypesResolver, flattenCurrencies, clickIntents) NamedArguments:GetMultiWalletWarningsFactory.kt$GetMultiWalletWarningsFactory$addWarningNotifications(cardTypesResolver, flattenCurrencies, isNeedToBackup, clickIntents) NamedArguments:TangemSnapFlingBehavior.kt$HighVelocityApproachAnimation$animateDecay(offset, animationState, decayAnimationSpec, onAnimationStep) diff --git a/features/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..8d96777c9e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt @@ -23,6 +23,7 @@ import com.tangem.feature.wallet.child.wallet.model.WalletModel import com.tangem.feature.wallet.navigation.WalletRoute import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen +import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedComponent import com.tangem.feature.walletsettings.component.RenameWalletComponent import com.tangem.features.biometry.AskBiometryComponent import com.tangem.features.feed.entry.components.FeedEntryComponent @@ -54,7 +55,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")) @@ -123,6 +127,17 @@ internal class WalletComponent @AssistedInject constructor( ), ) } + is WalletDialogConfig.KycRejected -> { + KycRejectedComponent( + appComponentContext = childByContext(componentContext), + params = KycRejectedComponent.Params( + walletId = dialogConfig.walletId, + customerId = dialogConfig.customerId, + callbacks = model.tangemPayKycRejectedCallbacks, + onDismiss = model.innerWalletRouter.dialogNavigation::dismiss, + ), + ) + } } }, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index ba456f80f5..2e9816c24a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -18,7 +18,6 @@ import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.* -import com.tangem.domain.nft.ObserveAndClearNFTCacheIfNeedUseCase import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.pay.repository.OnboardingRepository @@ -40,6 +39,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent.Dem import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState import com.tangem.feature.wallet.presentation.wallet.state.transformers.* import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender +import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedCallbacks import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider import com.tangem.features.biometry.AskBiometryComponent import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle @@ -84,7 +84,6 @@ internal class WalletModel @Inject constructor( private val onrampStatusFactory: OnrampStatusFactory, private val analyticsEventsHandler: AnalyticsEventHandler, private val walletContentFetcher: WalletContentFetcher, - private val observeAndClearNFTCacheIfNeedUseCase: ObserveAndClearNFTCacheIfNeedUseCase, private val walletDeepLinkActionListener: WalletDeepLinkActionListener, private val notificationsRepository: NotificationsRepository, private val getWalletsListForEnablingUseCase: GetWalletsForAutomaticallyPushEnablingUseCase, @@ -109,12 +108,12 @@ internal class WalletModel @Inject constructor( ) : Model() { val askBiometryModelCallbacks = AskBiometryModelCallbacks() + val tangemPayKycRejectedCallbacks = TangemPayKycRejectedCallbacks() val askForPushNotificationsModelCallbacks = AskForPushNotificationsCallbacks() val uiState: StateFlow = stateHolder.uiState private val walletsUpdateJobHolder = JobHolder() private val refreshWalletJobHolder = JobHolder() - private val clearNFTCacheJobHolder = JobHolder() private val updateTangemPayJobHolder = JobHolder() private var needToRefreshWallet = false @@ -334,7 +333,6 @@ internal class WalletModel @Inject constructor( } subscribeOnExpressTransactionsUpdates(selectedWallet) - observeAndClearNFTCacheIfNeedUseCase(selectedWallet) } .flowOn(dispatchers.main) .launchIn(modelScope) @@ -390,13 +388,6 @@ internal class WalletModel @Inject constructor( } } - private fun observeAndClearNFTCacheIfNeedUseCase(selectedWallet: UserWallet) { - observeAndClearNFTCacheIfNeedUseCase - .invoke(selectedWallet.walletId) - .launchIn(modelScope) - .saveIn(clearNFTCacheJobHolder) - } - private fun subscribeTangemPayOnWalletState() { /** * Update state each time a user opens/returns to wallet screen @@ -780,6 +771,20 @@ internal class WalletModel @Inject constructor( } } + inner class TangemPayKycRejectedCallbacks : KycRejectedCallbacks { + override fun onClickYourProfile(userWalletId: UserWalletId) { + clickIntents.onKycRejectedOpenProfileClicked(userWalletId) + } + + override fun onClickGoToSupport(customerId: String) { + clickIntents.onKycRejectedGoToSupportClicked(customerId) + } + + override fun onClickHideKyc(userWalletId: UserWalletId) { + clickIntents.onKycRejectedHideKycClicked(userWalletId) + } + } + inner class AskForPushNotificationsCallbacks : PushNotificationsModelCallbacks { override fun onAllowSystemPermission() { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt index e7a6f9325e..18c1ae190b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.child.wallet.model.intents +import com.arkivanov.decompose.router.slot.activate import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.ui.UiMessageSender @@ -13,6 +14,7 @@ import com.tangem.core.ui.message.bottomSheetMessage import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayDetailsConfig @@ -21,6 +23,7 @@ import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig import com.tangem.feature.wallet.presentation.wallet.state.transformers.TangemPayHideOnboardingStateTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.TangemPayRefreshNeededStateTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.TangemPayRefreshShowProgressTransformer @@ -38,6 +41,14 @@ internal interface TangemPayIntents { fun onKycProgressClicked(userWalletId: UserWalletId) + fun onKycRejectedClicked(userWalletId: UserWalletId, customerId: String) + + fun onKycRejectedOpenProfileClicked(userWalletId: UserWalletId) + + fun onKycRejectedGoToSupportClicked(customerId: String) + + fun onKycRejectedHideKycClicked(userWalletId: UserWalletId) + fun onIssuingCardClicked() fun onIssuingFailedClicked() @@ -137,6 +148,42 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( uiMessageSender.send(kycInfoBottomSheet) } + override fun onKycRejectedOpenProfileClicked(userWalletId: UserWalletId) { + router.openTangemPayOnboarding( + mode = AppRoute.TangemPayOnboarding.Mode.ContinueOnboarding(userWalletId), + ) + } + + override fun onKycRejectedGoToSupportClicked(customerId: String) { + goToSupportForRejectKyc(customerId) + } + + override fun onKycRejectedHideKycClicked(userWalletId: UserWalletId) { + disableTangemPay(userWalletId) + } + + override fun onKycRejectedClicked(userWalletId: UserWalletId, customerId: String) { + router.dialogNavigation.activate( + configuration = WalletDialogConfig.KycRejected( + walletId = userWalletId, + customerId = customerId, + ), + ) + } + + private fun goToSupportForRejectKyc(customerId: String) { + modelScope.launch { + sendFeedbackEmailUseCase( + type = FeedbackEmailType.Visa.KycRejected( + walletMetaInfo = WalletMetaInfo( + userWalletId = stateHolder.getSelectedWalletId(), + ), + customerId = customerId, + ), + ) + } + } + override fun onIssuingCardClicked() { val issuingBottomSheet = bottomSheetMessage { infoBlock { @@ -198,6 +245,7 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( if (isEligible) { router.openTangemPayOnboarding(mode = AppRoute.TangemPayOnboarding.Mode.FromBannerOnMain(userWalletId)) } else { + stateHolder.update(transformer = TangemPayHideOnboardingStateTransformer(userWalletId)) tangemPayMainScreenCustomerInfoUseCase.fetch(userWalletId) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index 2fa16a3f5f..b3f825d821 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -22,7 +22,6 @@ import com.tangem.domain.tokens.model.TokenActionsState import com.tangem.domain.tokens.model.details.NavigationAction import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase -import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplySetShouldShowMainPromoUseCase import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender @@ -103,7 +102,6 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val hotWalletFeatureToggles: HotWalletFeatureToggles, private val accountDependencies: AccountDependencies, - private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase, private val yieldSupplySetShouldShowMainPromoUseCase: YieldSupplySetShouldShowMainPromoUseCase, private val tokenListAnalyticsSender: TokenListAnalyticsSender, ) : BaseWalletClickIntents(), WalletContentClickIntents { @@ -199,7 +197,6 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( is NavigationAction.YieldSupply -> openYieldSupply( userWalletId = userWalletId, cryptoCurrencyStatus = currencyStatus, - navigationAction = navigationAction, apy = apy, ) } @@ -233,43 +230,21 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( } override fun onAccountExpandClick(account: Account) { - val userWalletId = stateHolder.getSelectedWalletId() analyticsEventHandler.send(MainScreenAnalyticsEvent.AccountShowTokens()) - accountDependencies.expandedAccountsHolder.expandAccount(userWalletId, account.accountId) + accountDependencies.expandedAccountsHolder.expandAccount(account.accountId) } override fun onAccountCollapseClick(account: Account) { - val userWalletId = stateHolder.getSelectedWalletId() analyticsEventHandler.send(MainScreenAnalyticsEvent.AccountHideTokens()) - accountDependencies.expandedAccountsHolder.collapseAccount(userWalletId, account.accountId) + accountDependencies.expandedAccountsHolder.collapseAccount(account.accountId) } - private fun openYieldSupply( - userWalletId: UserWalletId, - cryptoCurrencyStatus: CryptoCurrencyStatus, - navigationAction: NavigationAction.YieldSupply, - apy: String, - ) { - modelScope.launch { - val tokenEnterStatus = yieldSupplyEnterStatusUseCase(userWalletId, cryptoCurrencyStatus).getOrNull() - when { - tokenEnterStatus != null -> router.openTokenDetails( - userWalletId = userWalletId, - currencyStatus = cryptoCurrencyStatus, - navigationAction = navigationAction, - ) - navigationAction.isActive -> router.openYieldSupplyActiveScreen( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - apy = apy, - ) - else -> router.openYieldSupplyPromoScreen( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - apy = apy, - ) - } - } + private fun openYieldSupply(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, apy: String) { + router.openYieldSupplyEntryScreen( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + apy = apy, + ) } private fun sendApyLabelClickAnalytics(navigationAction: NavigationAction, currencyStatus: CryptoCurrencyStatus) { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/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/di/WalletFeatureModule.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt index 1a925f467e..913e1262d2 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt @@ -4,6 +4,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.feature.wallet.DefaultWalletEntryComponent import com.tangem.feature.wallet.child.organizetokens.model.OrganizeTokensModel import com.tangem.feature.wallet.child.wallet.model.WalletModel +import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedModel import com.tangem.feature.wallet.utils.DefaultUserWalletImageFetcher import com.tangem.feature.wallet.utils.DefaultUserWalletsFetcher import com.tangem.features.wallet.WalletEntryComponent @@ -36,6 +37,11 @@ internal interface WalletFeatureModule { @ClassKey(WalletModel::class) fun bindWalletModel(model: WalletModel): Model + @Binds + @IntoMap + @ClassKey(KycRejectedModel::class) + fun bindKycRejectedModel(model: KycRejectedModel): Model + @Binds @IntoMap @ClassKey(OrganizeTokensModel::class) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt index 47735160d8..e0f0d2f4bf 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt @@ -1,60 +1,100 @@ package com.tangem.feature.wallet.presentation.account import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.account.models.AccountExpandedState import com.tangem.domain.account.models.AccountList -import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.repository.AccountsExpandedRepository import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import javax.inject.Inject @ModelScoped internal class ExpandedAccountsHolder @Inject constructor( private val singleAccountListSupplier: SingleAccountListSupplier, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val accountsExpandedRepository: AccountsExpandedRepository, + private val dispatchers: CoroutineDispatcherProvider, ) { - private val expandedAccounts = MutableStateFlow>>(mapOf()) + private val actionChannel = MutableSharedFlow>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) fun expandedAccounts(userWallet: UserWallet): Flow> = channelFlow { - combine( - flow = walletAccounts(userWallet), - flow2 = isAccountsModeEnabledUseCase.invoke(), - transform = { accountList, isAccountsMode -> - val isSingleAccount = accountList.accounts.size == 1 - val defaultExpanded = when { - !isAccountsMode -> setOf() - isSingleAccount -> setOf(accountList.mainAccount.accountId) - else -> setOf() + val walletId = userWallet.walletId + + val storedState = accountsExpandedRepository.expandedAccounts + .map { it[walletId].orEmpty() } + .stateIn(this) + + val isAccountsMode = isAccountsModeEnabledUseCase.invoke() + .stateIn(this) + + val initExpandedState = storedState.value + .mapNotNull { it.takeIf { state -> state.isExpanded }?.accountId } + .toSet() + // main state holder + val expandedAccounts = MutableStateFlow(initExpandedState) + + actionChannel + .filter { (accountId, _) -> accountId.userWalletId == walletId } + .onEach { (accountId, isExpand) -> + val newState = AccountExpandedState(accountId, isExpand) + launch { accountsExpandedRepository.update(newState) } + if (isExpand) { + expandedAccounts.update { it.plus(accountId) } + } else { + expandedAccounts.update { it.minus(accountId) } } - expandedAccounts.update { map -> - var expandedSet = map[userWallet.walletId] ?: defaultExpanded - // force expand for single account - if (isSingleAccount || !isAccountsMode) expandedSet = defaultExpanded - map.plus(userWallet.walletId to expandedSet) + } + .launchIn(this) + + walletAccounts(walletId).onEach { accountList -> + val idsSet = accountList.accounts.mapTo(mutableSetOf()) { it.accountId } + accountsExpandedRepository.syncStore(walletId, idsSet) + + if (!isAccountsMode.value) return@onEach + + val isSingleAccount = accountList.accounts.size == 1 + val storedMainAccountState = storedState.value + .find { it.accountId == accountList.mainAccount.accountId } + + if (isSingleAccount && storedMainAccountState == null) { + // force expand for single and not stored account + expandedAccounts.update { setOf(accountList.mainAccount.accountId) } + } + }.launchIn(this) + + combine( + flow = expandedAccounts, + flow2 = isAccountsMode, + transform = { expanded, isAccountMode -> + if (isAccountMode) { + channel.send(expanded) + } else { + channel.send(setOf()) } }, - ).launchIn(this) + ).collect() + } + .flowOn(dispatchers.default) + .distinctUntilChanged() - expandedAccounts - .mapNotNull { map -> map[userWallet.walletId] } - .onEach { expanded -> channel.send(expanded) } - .collect() + fun expandAccount(accountId: AccountId) { + actionChannel.tryEmit(accountId to true) } - fun expandAccount(userWalletId: UserWalletId, accountId: AccountId) = expandedAccounts.update { map -> - val expandedSet = map[userWalletId]?.plus(accountId) ?: return@update map - map.plus(userWalletId to expandedSet) + fun collapseAccount(accountId: AccountId) { + actionChannel.tryEmit(accountId to false) } - fun collapseAccount(userWalletId: UserWalletId, accountId: AccountId) = expandedAccounts.update { map -> - val expandedSet = map[userWalletId]?.minus(accountId) ?: return@update map - map.plus(userWalletId to expandedSet) - } - - private fun walletAccounts(userWallet: UserWallet): Flow = - singleAccountListSupplier(SingleAccountListProducer.Params(userWallet.walletId)) + private fun walletAccounts(walletId: UserWalletId): Flow = singleAccountListSupplier(walletId) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index 934d258f18..49f35c817d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -133,19 +133,9 @@ internal class DefaultWalletRouter @Inject constructor( ) } - override fun openYieldSupplyActiveScreen(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, apy: String) { + override fun openYieldSupplyEntryScreen(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, apy: String) { router.push( - AppRoute.YieldSupplyActive( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrency, - apy = apy, - ), - ) - } - - override fun openYieldSupplyPromoScreen(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, apy: String) { - router.push( - AppRoute.YieldSupplyPromo( + AppRoute.YieldSupplyEntry( userWalletId = userWalletId, cryptoCurrency = cryptoCurrency, apy = apy, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index c423b80481..afad2d5fe7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -75,9 +75,6 @@ internal interface InnerWalletRouter { onWarningAcknowledged: (TokenAction) -> Unit, ) - /** Open yield supply active screen */ - fun openYieldSupplyActiveScreen(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, apy: String) - - /** Open yield supply promo screen */ - fun openYieldSupplyPromoScreen(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, apy: String) + /** Open yield supply entry screen */ + fun openYieldSupplyEntryScreen(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, apy: String) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt index e2df7c8cca..5e65dfb36a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt @@ -2,6 +2,7 @@ package com.tangem.feature.wallet.presentation.wallet.analytics.utils import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.MainScreenCustomerInfo import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.tangempay.TangemPayAnalyticsEvents @@ -25,8 +26,10 @@ internal class WalletTangemPayAnalyticsEventSender @Inject constructor( // TODO: TangemPay refactor analytics // when statement copied from TangemPayUpdateInfoStateTransformer. Be careful when editing val event = when { - customerInfo.orderStatus == OrderStatus.CANCELED -> return // ignore cancelled state on analytics - !customerInfo.info.isKycApproved -> return // ignore kyc not approved state on analytics + // ignore cancelled state on analytics + customerInfo.orderStatus == OrderStatus.CANCELED -> return + // ignore kyc not approved state on analytics + customerInfo.info.kycStatus != CustomerInfo.KycStatus.APPROVED -> return cardInfo != null && productInstance != null -> return else -> TangemPayAnalyticsEvents.IssuingBannerDisplayed() } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt index 4042c8b37e..a482dc420f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt @@ -32,4 +32,7 @@ internal sealed interface WalletDialogConfig { val tokenAction: TokenAction, val onWarningAcknowledged: (TokenAction) -> Unit, ) : WalletDialogConfig + + @Serializable + data class KycRejected(val walletId: UserWalletId, val customerId: String) : WalletDialogConfig } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt index b6ad2482ff..9a9ce4feee 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt @@ -16,6 +16,8 @@ import com.tangem.feature.wallet.child.wallet.model.intents.TangemPayIntents import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState.Progress import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState +import com.tangem.domain.pay.model.CustomerInfo.KycStatus.APPROVED +import com.tangem.domain.pay.model.CustomerInfo import java.util.Currency /** @@ -43,11 +45,13 @@ internal class TangemPayUpdateInfoStateTransformer( private fun createInitialState(): TangemPayState { val cardInfo = value.info.cardInfo val productInstance = value.info.productInstance + val customerId = value.info.customerId // when statement copied to WalletTangemPayAnalyticsEventSender. Be careful when editing. return when { value.orderStatus == OrderStatus.CANCELED -> createCancelledState() - !value.info.isKycApproved -> createKycInProgressState() + value.info.kycStatus != APPROVED && !customerId.isNullOrEmpty() -> + createKycInProgressState(kycStatus = value.info.kycStatus, customerId = customerId) cardInfo != null && productInstance != null -> getCardInfoState(cardInfo, productInstance) else -> createIssueProgressState() } @@ -80,13 +84,25 @@ internal class TangemPayUpdateInfoStateTransformer( } } - private fun createKycInProgressState(): TangemPayState = Progress( - title = TextReference.Res(R.string.tangempay_payment_account), - description = TextReference.Res(R.string.tangempay_kyc_in_progress), - buttonText = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_button), - iconRes = R.drawable.ic_promo_kyc_36, - onButtonClick = { tangemPayClickIntents.onKycProgressClicked(userWalletId) }, - ) + private fun createKycInProgressState(kycStatus: CustomerInfo.KycStatus, customerId: String): TangemPayState = + Progress( + title = TextReference.Res(R.string.tangempay_payment_account), + description = when (kycStatus) { + CustomerInfo.KycStatus.REJECTED -> TextReference.Res(R.string.tangempay_kyc_has_failed) + else -> TextReference.Res(R.string.tangempay_kyc_in_progress) + }, + buttonText = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_button), + iconRes = R.drawable.ic_promo_kyc_36, + onButtonClick = { + when (kycStatus) { + CustomerInfo.KycStatus.REJECTED -> tangemPayClickIntents.onKycRejectedClicked( + userWalletId = userWalletId, + customerId = customerId, + ) + else -> tangemPayClickIntents.onKycProgressClicked(userWalletId) + } + }, + ) private fun createIssueProgressState(): TangemPayState = Progress( title = TextReference.Res(R.string.tangempay_payment_account), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TxHistoryItemStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TxHistoryItemStateConverter.kt index b04f228e55..b679f01589 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TxHistoryItemStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TxHistoryItemStateConverter.kt @@ -73,6 +73,7 @@ internal class TxHistoryItemStateConverter( is TransactionType.UnknownOperation, is TransactionType.YieldSupply.Send, TransactionType.YieldSupply.Topup, + TransactionType.GaslessFee, -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 } } @@ -114,6 +115,7 @@ internal class TxHistoryItemStateConverter( ) } is TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation) + TransactionType.GaslessFee -> resourceReference(R.string.gasless_transaction_fee) } private fun TxInfo.extractSubtitle(): TextReference { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 1f2e8441c2..803f2d873b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -409,7 +409,7 @@ private inline fun BaseScaffoldWithMarkets( } CompositionLocalProvider( - LocalMainBottomSheetColor provides remember { mutableStateOf(background) }, + LocalMainBottomSheetColor provides remember(background) { mutableStateOf(background) }, ) { val backgroundColor = LocalMainBottomSheetColor.current var isSearchFieldFocused by remember { mutableStateOf(false) } @@ -449,20 +449,21 @@ private inline fun BaseScaffoldWithMarkets( } Column( - modifier = Modifier.sizeIn(maxHeight = maxHeight - statusBarHeight), + modifier = Modifier + // expand bottom sheet when clicked on the header + .clickable( + enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded, + indication = null, + interactionSource = null, + ) { + coroutineScope.launch { bottomSheetState.expand() } + } + .sizeIn(maxHeight = maxHeight - statusBarHeight), ) { Hand(Modifier.drawBehind { drawRect(backgroundColor.value) }) Box( modifier = Modifier - // expand bottom sheet when clicked on the header - .clickable( - enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded, - indication = null, - interactionSource = null, - ) { - coroutineScope.launch { bottomSheetState.expand() } - } .onFocusChanged { isSearchFieldFocused = it.isFocused }, @@ -492,7 +493,7 @@ private inline fun BaseScaffoldWithMarkets( color = if (state.showMarketsOnboarding) { Color.Black.copy(alpha = .65f) } else { - BottomSheetDefaults.ScrimColor + Color.Black.copy(alpha = .40f) }, visible = bottomSheetState.targetValue == TangemSheetValue.Expanded || state.showMarketsOnboarding, 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/WalletsList.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt index 73f9b11041..e09c48d1c0 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt @@ -18,12 +18,11 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.MainScreenTestTags @@ -50,7 +49,7 @@ internal fun WalletsList( isBalanceHidden: Boolean, ) { val horizontalCardPadding = TangemTheme.dimens.spacing16 - val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val screenWidth = LocalWindowSize.current.width val itemWidth by remember(screenWidth) { derivedStateOf { screenWidth - horizontalCardPadding * 2 } } LazyRow( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/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..01044070d2 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt @@ -12,7 +12,9 @@ import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -32,18 +34,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.sp -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.ConstraintLayoutScope -import androidx.constraintlayout.compose.Dimension -import com.tangem.core.ui.components.FontSizeRange +import androidx.compose.ui.unit.* 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.conditional import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemDimens @@ -51,6 +46,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.MainScreenTestTags import com.tangem.feature.wallet.presentation.common.WalletPreviewData +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDropDownItems import com.tangem.feature.wallet.presentation.wallet.ui.components.fastForEach @@ -69,89 +65,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 +79,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 +102,19 @@ private fun CardContainer( } }, ), - shape = TangemTheme.shapes.roundedCornersXMedium, - color = TangemTheme.colors.background.primary, ) { - ConstraintLayout( + Box( 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,10 +126,65 @@ private fun CardContainer( pressOffset = pressOffset, itemHeight = itemHeight, onDismissRequest = { isMenuVisible = false }, - dropDownItems = dropDownItems, + dropDownItems = state.dropDownItems, ) } +@Suppress("DestructuringDeclarationWithTooManyEntries") +@Composable +private fun CardContainer(state: WalletCardState, isBalanceHidden: Boolean, itemSize: IntSize) { + var balanceWidth by remember { mutableIntStateOf(value = Int.MIN_VALUE) } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + Modifier + .weight(1f) + .padding(vertical = 12.dp), + ) { + TitleText( + text = state.title, + modifier = Modifier, + ) + Balance( + state = state, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .onSizeChanged { balanceWidth = it.width } + .padding(vertical = TangemTheme.dimens.spacing8), + ) + + val additionalText by remember(state.additionalInfo, isBalanceHidden) { + mutableStateOf( + state.additionalInfo?.content?.orMaskWithStars( + maskWithStars = state.additionalInfo?.hideable == true && isBalanceHidden, + ), + ) + } + AdditionalInfo( + text = additionalText, + modifier = Modifier.conditional( + state.imageResId == null, + ) { fillMaxWidth() }, + ) + } + + // If balance has a large width then image must be hidden + val hasSpaceForImage by remember(key1 = balanceWidth, key2 = itemSize.width) { + mutableStateOf(value = balanceWidth < itemSize.width * HALF_OF_ITEM_WIDTH) + } + + if (hasSpaceForImage) { + Image( + id = state.imageResId, + modifier = Modifier.wrapContentWidth(), + ) + } + } +} + @Suppress("LongParameterList") @Composable private fun ManageWalletContextMenu( @@ -282,10 +254,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 @@ -401,6 +376,16 @@ private fun Preview_WalletCard( private class WalletCardStateProvider : CollectionPreviewParameterProvider( collection = listOf( WalletPreviewData.walletCardContentState, + WalletPreviewData.walletCardContentState.copy( + balance = "0.00", + ), + WalletPreviewData.walletCardContentState.copy( + title = "Title", + additionalInfo = WalletAdditionalInfo( + hideable = false, + content = TextReference.Str("3 cards"), + ), + ), WalletPreviewData.walletCardContentState.copy( isBalanceFlickering = true, ), 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/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedComponent.kt new file mode 100644 index 0000000000..5cccb5d97a --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedComponent.kt @@ -0,0 +1,49 @@ +package com.tangem.feature.wallet.presentation.wallet.ui.components.visa + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetV2 +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.wallet.UserWalletId +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class KycRejectedComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: KycRejectedModel = getOrCreateModel(params = params) + + override fun dismiss() { + model.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.uiState.collectAsStateWithLifecycle() + MessageBottomSheetV2(state = state, onDismissRequest = ::dismiss) + } + + data class Params( + val callbacks: KycRejectedCallbacks, + val walletId: UserWalletId, + val customerId: String, + val onDismiss: () -> Unit, + ) + + @AssistedFactory + interface Factory { + fun create(appComponentContext: AppComponentContext, params: Params): KycRejectedComponent + } +} + +internal interface KycRejectedCallbacks { + fun onClickYourProfile(userWalletId: UserWalletId) + fun onClickGoToSupport(customerId: String) + fun onClickHideKyc(userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedModel.kt new file mode 100644 index 0000000000..40e010fd56 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/KycRejectedModel.kt @@ -0,0 +1,77 @@ +package com.tangem.feature.wallet.presentation.wallet.ui.components.visa + +import androidx.compose.runtime.Stable +import androidx.compose.ui.text.style.TextDecoration +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.res.R +import com.tangem.core.ui.components.bottomsheets.message.* +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.styledResourceReference +import com.tangem.core.ui.message.bottomSheetMessage +import com.tangem.core.ui.res.TangemTheme +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@Stable +@ModelScoped +internal class KycRejectedModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + private fun getInitialState(): MessageBottomSheetUMV2 { + return bottomSheetMessage { + infoBlock { + icon(com.tangem.core.ui.R.drawable.ic_heart_broken_32) { + type = MessageBottomSheetUMV2.Icon.Type.Warning + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Warning + } + title = resourceReference(R.string.tangempay_kyc_rejected) + body = combinedReference( + resourceReference(R.string.tangempay_kyc_rejected_description), + stringReference(" "), + styledResourceReference( + id = R.string.tangempay_kyc_rejected_description_span, + spanStyleReference = { + TangemTheme.typography.body2.copy(TangemTheme.colors.text.accent).toSpanStyle() + .copy(textDecoration = TextDecoration.None) + }, + onClick = { + params.callbacks.onClickYourProfile(userWalletId = params.walletId) + onDismiss() + }, + ), + ) + } + primaryButton { + text = resourceReference(R.string.tangempay_go_to_support) + onClick = { + params.callbacks.onClickGoToSupport(customerId = params.customerId) + onDismiss() + } + } + secondaryButton { + text = resourceReference(R.string.tangempay_kyc_rejected_button_text) + onClick = { + params.callbacks.onClickHideKyc(userWalletId = params.walletId) + onDismiss() + } + } + }.messageBottomSheetUMV2 + } + + fun onDismiss() { + params.onDismiss() + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt index e0a0182370..bdc6f34464 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt @@ -273,6 +273,7 @@ internal class WcPairModel @Inject constructor( val (wallet, portfolioAccount) = selectedPortfolio val account = when (val account = portfolioAccount.account) { is Account.CryptoPortfolio -> account + is Account.Payment -> TODO("[REDACTED_JIRA]") } val isAccountMode = selectorController.isAccountMode.first() val icon: CryptoPortfolioIconUM? @@ -325,6 +326,7 @@ internal class WcPairModel @Inject constructor( if (selectorController.isAccountModeSync() && account != null) { val derivationIndex = when (account) { is Account.CryptoPortfolio -> account.derivationIndex.value + is Account.Payment -> TODO("[REDACTED_JIRA]") } analytics.send(WcAnalyticAccountEvents.PairButtonConnect(derivationIndex)) } else { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsAccountModeTransformer.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsAccountModeTransformer.kt index a36142ec24..598ca31911 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsAccountModeTransformer.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcSessionsAccountModeTransformer.kt @@ -64,6 +64,7 @@ internal class WcSessionsAccountModeTransformer( } val accountIcon = when (account) { is Account.CryptoPortfolio -> account.icon + is Account.Payment -> TODO("[REDACTED_JIRA]") } val accountTitle = AccountTitleUM.Account( diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt index c17c778409..fe864a450f 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt @@ -12,6 +12,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.audits.AuditLabel +import com.tangem.core.ui.components.audits.AuditLabelUM +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.features.send.v2.api.FeeSelectorBlockComponent @@ -52,6 +55,12 @@ internal class PreviewFeeSelectorBlockComponent : FeeSelectorBlockComponent { tint = TangemTheme.colors.icon.informative, ) Spacer(modifier = Modifier.weight(1f)) + AuditLabel( + state = AuditLabelUM( + text = stringReference("ETH"), + type = AuditLabelUM.Type.General, + ), + ) Text( text = "~ 0.25 \$", style = TangemTheme.typography.body1, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt index eaf30bde46..d4686d0460 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt @@ -57,6 +57,7 @@ internal fun getWcCommonScreen( feeDisplaySource = FeeDisplaySource.BottomSheet, analyticsCategoryName = WcAnalyticEvents.WC_CATEGORY_NAME, analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.WalletConnect, + userWalletId = model.userWallet.walletId, ), ) } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt index abb3dcce53..cf234d50b6 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt @@ -76,6 +76,7 @@ internal class WcSendTransactionComponent( feeDisplaySource = FeeSelectorParams.FeeDisplaySource.BottomSheet, analyticsCategoryName = WcAnalyticEvents.WC_CATEGORY_NAME, analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.WalletConnect, + userWalletId = model.userWallet.walletId, ), onResult = model::updateFee, ) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcPortfolioNameDelegate.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcPortfolioNameDelegate.kt index 7273f94398..ce57447912 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcPortfolioNameDelegate.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcPortfolioNameDelegate.kt @@ -24,6 +24,7 @@ internal class WcPortfolioNameDelegate @AssistedInject constructor( fun createAccountTitleUM(value: WcSession): AccountTitleUM? { val account = when (val account = value.account) { is Account.CryptoPortfolio -> account + is Account.Payment -> TODO("[REDACTED_JIRA]") null -> null } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt index d305e4a233..f02eb11f51 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt @@ -105,6 +105,9 @@ internal class WcSendTransactionModel @Inject constructor( private val securityStatusState = MutableStateFlow>(Lce.Loading(partialContent = null)) + val userWallet + get() = useCase.wallet + init { @Suppress("UnusedPrivateMember") modelScope.launch { 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/features/welcome/impl/detekt-baseline-debug.xml b/features/welcome/impl/detekt-baseline-debug.xml index 9c54bf2d00..4a47c5be0a 100644 --- a/features/welcome/impl/detekt-baseline-debug.xml +++ b/features/welcome/impl/detekt-baseline-debug.xml @@ -5,7 +5,6 @@ BooleanPropertyNaming:WelcomeModel.kt$WelcomeModel$private var routedOut = false BooleanPropertyNaming:WelcomeUM.kt$WelcomeUM.SelectWallet$val showUnlockWithBiometricButton: Boolean = false MultilineLambdaItParameter:WelcomeModel.kt$WelcomeModel${ if (it.isEmpty()) { router.replaceAll(AppRoute.Home()) } wallets.value = it } - MultilineLambdaItParameter:WelcomeModel.kt$WelcomeModel${ it.handle( specificWalletId = null, onUserCancelled = { tryToUnlockWithAccessCodeRightAway() }, ) setSelectWalletState() } ReusedModifierInstance:Welcome.kt$WelcomePlain(modifier = modifier) ReusedModifierInstance:Welcome.kt$WelcomeSelectWallet( state = st, modifier = modifier, ) diff --git a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyEntryComponent.kt b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyEntryComponent.kt new file mode 100644 index 0000000000..150e34aa92 --- /dev/null +++ b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyEntryComponent.kt @@ -0,0 +1,17 @@ +package com.tangem.features.yield.supply.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId + +interface YieldSupplyEntryComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, + val apy: String, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt index 93edb89872..37c2e71379 100644 --- a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt +++ b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt @@ -3,4 +3,5 @@ package com.tangem.features.yield.supply.api interface YieldSupplyFeatureToggles { val isYieldSupplyFeatureEnabled: Boolean + val isYieldSupplyPendingTransactionsEnabled: Boolean } \ No newline at end of file diff --git a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/entry/YieldSupplyEntryRoute.kt b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/entry/YieldSupplyEntryRoute.kt new file mode 100644 index 0000000000..030660a4a2 --- /dev/null +++ b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/entry/YieldSupplyEntryRoute.kt @@ -0,0 +1,24 @@ +package com.tangem.features.yield.supply.api.entry + +import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.models.currency.CryptoCurrency + +/** + * Route for switching yield supply promo and active flows. + */ +sealed class YieldSupplyEntryRoute : Route { + + /** Initial empty route with no UI */ + data object Empty : YieldSupplyEntryRoute() + + /** Route to yield supply promo screen */ + data class Promo( + val cryptoCurrency: CryptoCurrency, + val apy: String, + ) : YieldSupplyEntryRoute() + + /** Route to yield supply active screen */ + data class Active( + val cryptoCurrency: CryptoCurrency, + ) : YieldSupplyEntryRoute() +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt index db476c617b..4f0a442329 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt @@ -8,4 +8,7 @@ internal class DefaultYieldSupplyFeatureToggles( ) : YieldSupplyFeatureToggles { override val isYieldSupplyFeatureEnabled: Boolean get() = featureToggles.isFeatureEnabled("YIELD_SUPPLY_FEATURE_ENABLED") + + override val isYieldSupplyPendingTransactionsEnabled: Boolean + get() = featureToggles.isFeatureEnabled("YIELD_SUPPLY_PENDING_TRANSACTIONS_ENABLED") } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/DefaultYieldSupplyEntryComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/DefaultYieldSupplyEntryComponent.kt new file mode 100644 index 0000000000..0d5f50a0c7 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/DefaultYieldSupplyEntryComponent.kt @@ -0,0 +1,124 @@ +package com.tangem.features.yield.supply.impl.entry + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.fade +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.decompose.navigation.inner.InnerRouter +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent +import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent +import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent +import com.tangem.features.yield.supply.api.entry.YieldSupplyEntryRoute +import com.tangem.features.yield.supply.impl.entry.model.YieldSupplyEntryModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultYieldSupplyEntryComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: YieldSupplyEntryComponent.Params, + private val yieldSupplyPromoComponentFactory: YieldSupplyPromoComponent.Factory, + private val yieldSupplyActiveComponentFactory: YieldSupplyActiveComponent.Factory, +) : YieldSupplyEntryComponent, AppComponentContext by appComponentContext { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val model: YieldSupplyEntryModel = getOrCreateModel( + params = params, + router = innerRouter, + ) + + private val childStack = childStack( + key = "yieldSupplyEntryStack", + source = stackNavigation, + serializer = null, + initialConfiguration = model.initialRoute, + handleBackButton = true, + childFactory = { configuration, factoryContext -> + getChildComponent( + configuration = configuration, + factoryContext = childByContext( + componentContext = factoryContext, + router = innerRouter, + ), + ) + }, + ) + + @Composable + override fun Content(modifier: Modifier) { + val childStackValue by childStack.subscribeAsState() + + Children( + stack = childStackValue, + modifier = modifier, + animation = stackAnimation { fade() }, + ) { child -> + child.instance.Content(Modifier.fillMaxSize()) + } + } + + private fun getChildComponent( + configuration: YieldSupplyEntryRoute, + factoryContext: AppComponentContext, + ): ComposableContentComponent = when (configuration) { + is YieldSupplyEntryRoute.Empty -> EmptyComponent + is YieldSupplyEntryRoute.Promo -> yieldSupplyPromoComponentFactory.create( + context = factoryContext, + params = YieldSupplyPromoComponent.Params( + userWalletId = params.userWalletId, + currency = configuration.cryptoCurrency, + apy = configuration.apy, + ), + ) + is YieldSupplyEntryRoute.Active -> yieldSupplyActiveComponentFactory.create( + context = factoryContext, + params = YieldSupplyActiveComponent.Params( + userWalletId = params.userWalletId, + cryptoCurrency = configuration.cryptoCurrency, + ), + ) + } + + private object EmptyComponent : ComposableContentComponent { + @Composable + override fun Content(modifier: Modifier) { + Box(modifier = modifier) + } + } + + private fun onChildBack() { + if (childStack.value.backStack.isEmpty() || + childStack.value.backStack.last().configuration is YieldSupplyEntryRoute.Empty + ) { + router.pop() + } else { + stackNavigation.pop() + } + } + + @AssistedFactory + interface Factory : YieldSupplyEntryComponent.Factory { + override fun create( + context: AppComponentContext, + params: YieldSupplyEntryComponent.Params, + ): DefaultYieldSupplyEntryComponent + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/di/YieldSupplyEntryModule.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/di/YieldSupplyEntryModule.kt new file mode 100644 index 0000000000..0b8df9a27f --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/di/YieldSupplyEntryModule.kt @@ -0,0 +1,34 @@ +package com.tangem.features.yield.supply.impl.entry.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent +import com.tangem.features.yield.supply.impl.entry.DefaultYieldSupplyEntryComponent +import com.tangem.features.yield.supply.impl.entry.model.YieldSupplyEntryModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface YieldSupplyEntryBindsModule { + @Binds + @Singleton + fun provideYieldSupplyEntryComponentFactory( + impl: DefaultYieldSupplyEntryComponent.Factory, + ): YieldSupplyEntryComponent.Factory +} + +@Module +@InstallIn(ModelComponent::class) +internal interface YieldSupplyEntryModelModule { + + @Binds + @IntoMap + @ClassKey(YieldSupplyEntryModel::class) + fun provideYieldSupplyEntryModel(impl: YieldSupplyEntryModel): Model +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt new file mode 100644 index 0000000000..2bd65b5bf0 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt @@ -0,0 +1,90 @@ +package com.tangem.features.yield.supply.impl.entry.model + +import com.tangem.common.routing.AppRoute +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.model.details.NavigationAction +import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase +import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent +import com.tangem.features.yield.supply.api.entry.YieldSupplyEntryRoute +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@ModelScoped +internal class YieldSupplyEntryModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase, + private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, +) : Model() { + + private val params = paramsContainer.require() + + val initialRoute: YieldSupplyEntryRoute = YieldSupplyEntryRoute.Empty + + init { + navigateToInitialRoute() + } + + private fun navigateToInitialRoute() { + val userWalletId = params.userWalletId + val cryptoCurrency = params.cryptoCurrency + modelScope.launch(dispatchers.default) { + getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWalletId = userWalletId, + cryptoCurrencyId = cryptoCurrency.id, + ).onLeft { error -> + Timber.e("Failed to get CryptoCurrencyStatus: $error") + router.pop() + }.onRight { cryptoCurrencyStatus -> + val route = getInitialRoute(cryptoCurrencyStatus) + if (route != null) { + router.replaceCurrent(route) + } + } + } + } + + private suspend fun getInitialRoute(cryptoCurrencyStatus: CryptoCurrencyStatus): YieldSupplyEntryRoute? { + val userWalletId = params.userWalletId + val cryptoCurrency = params.cryptoCurrency + val token = cryptoCurrency as? CryptoCurrency.Token + if (token == null) { + router.pop() + return null + } + + val tokenEnterStatus = yieldSupplyEnterStatusUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).getOrNull() + val isActiveYield = cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true + + if (tokenEnterStatus != null) { + router.replaceCurrent( + AppRoute.CurrencyDetails( + userWalletId = userWalletId, + currency = token, + navigationAction = NavigationAction.YieldSupply( + isActive = isActiveYield, + ), + ), + ) + return null + } + + return if (isActiveYield) { + YieldSupplyEntryRoute.Active(cryptoCurrency = token) + } else { + YieldSupplyEntryRoute.Promo(cryptoCurrency = token, apy = params.apy) + } + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index 17fba6e4c0..6aef4d48d4 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -2,8 +2,6 @@ package com.tangem.features.yield.supply.impl.main.model import arrow.core.getOrElse import com.tangem.common.routing.AppRoute -import android.os.SystemClock -import com.tangem.common.routing.AppRoute.YieldSupplyPromo import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -25,8 +23,7 @@ import com.tangem.domain.models.yield.supply.YieldSupplyStatus import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase -import com.tangem.domain.yield.supply.YieldSupplyRepository -import com.tangem.domain.yield.supply.models.YieldSupplyEnterStatus +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import com.tangem.domain.yield.supply.usecase.* import com.tangem.features.yield.supply.api.YieldSupplyComponent import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics @@ -34,12 +31,9 @@ import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM import com.tangem.features.yield.supply.impl.main.model.transformers.YieldSupplyTokenStatusSuccessTransformer import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.DelayedWork import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import com.tangem.utils.transformer.update -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber @@ -58,12 +52,11 @@ internal class YieldSupplyModel @Inject constructor( private val getUserWalletUseCase: GetUserWalletUseCase, private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, - @DelayedWork private val coroutineScope: CoroutineScope, private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase, private val yieldSupplyIsAvailableUseCase: YieldSupplyIsAvailableUseCase, private val yieldSupplyActivateUseCase: YieldSupplyActivateUseCase, private val yieldSupplyDeactivateUseCase: YieldSupplyDeactivateUseCase, - private val yieldSupplyRepository: YieldSupplyRepository, + private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase, private val yieldSupplyMinAmountUseCase: YieldSupplyMinAmountUseCase, private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase, ) : Model(), YieldSupplyClickIntents { @@ -76,11 +69,10 @@ internal class YieldSupplyModel @Inject constructor( private val cryptoCurrency = params.cryptoCurrency private var appCurrency: AppCurrency = AppCurrency.Default var userWallet: UserWallet by Delegates.notNull() + private var latestCryptoCurrencyStatus: CryptoCurrencyStatus? = null - private val fetchCurrencyJobHolder = JobHolder() private val loadStatusJobHolder = JobHolder() - private var lastStatusCheckTimestamp = 0L private val isFirstCryptoCurrencyStatusEmission = AtomicBoolean(true) init { @@ -116,6 +108,7 @@ internal class YieldSupplyModel @Inject constructor( ).onEach { maybeCryptoCurrency -> maybeCryptoCurrency.fold( ifRight = { cryptoCurrencyStatus -> + latestCryptoCurrencyStatus = cryptoCurrencyStatus if (isFirstCryptoCurrencyStatusEmission.compareAndSet(true, false)) { sendInfoAboutProtocolStatus(cryptoCurrencyStatus) } @@ -152,108 +145,61 @@ internal class YieldSupplyModel @Inject constructor( } override fun onStartEarningClick() { - val apy = when (val yieldSupplyUM = uiState.value) { - is YieldSupplyUM.Available -> yieldSupplyUM.apy - is YieldSupplyUM.Content -> yieldSupplyUM.apy - else -> "" - } - appRouter.push( - YieldSupplyPromo( - userWalletId = params.userWalletId, - cryptoCurrency = params.cryptoCurrency, - apy = apy, - ), - ) + navigateToYieldSupplyEntry() } override fun onActiveClick() { + navigateToYieldSupplyEntry() + } + + private fun navigateToYieldSupplyEntry() { + val cryptoCurrencyStatus = latestCryptoCurrencyStatus ?: return val apy = when (val yieldSupplyUM = uiState.value) { is YieldSupplyUM.Available -> yieldSupplyUM.apy is YieldSupplyUM.Content -> yieldSupplyUM.apy else -> "" } appRouter.push( - AppRoute.YieldSupplyActive( + AppRoute.YieldSupplyEntry( userWalletId = params.userWalletId, - cryptoCurrency = params.cryptoCurrency, + cryptoCurrency = cryptoCurrencyStatus.currency, apy = apy, ), ) } - @Suppress("MaximumLineLength") private fun onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus: CryptoCurrencyStatus) = modelScope.launch( dispatchers.default, ) { - val yieldSupplyStatus = cryptoCurrencyStatus.value.yieldSupplyStatus - val tokenProtocolStatus = yieldSupplyRepository.getTokenProtocolStatus( - userWallet.walletId, - cryptoCurrency, - ) - val tokenPendingStatus = yieldSupplyRepository.getTokenPendingStatus( - userWallet.walletId, - cryptoCurrencyStatus, - ) - - val isActive = yieldSupplyStatus?.isActive == true val isCryptoCurrencyStatusFromCache = cryptoCurrencyStatus.value.sources.networkSource != StatusSource.ACTUAL val processing = uiState.value is YieldSupplyUM.Processing - Timber.d( - "YIELD " + - "yieldSupplyStatus $yieldSupplyStatus " + - "tokenProtocolStatus $tokenProtocolStatus " + - "tokenPendingStatus $tokenPendingStatus " + - "isActive $isActive " + - "processing $processing " + - "isCryptoCurrencyStatusFromCache $isCryptoCurrencyStatusFromCache", - ) if (isCryptoCurrencyStatusFromCache && processing) { return@launch } - when { - tokenProtocolStatus != null && tokenPendingStatus != null -> { - showProcessing(tokenPendingStatus) - lastStatusCheckTimestamp = 0L - } - tokenProtocolStatus == YieldSupplyEnterStatus.Exit && isActive || - tokenProtocolStatus == YieldSupplyEnterStatus.Enter && !isActive -> { - if (lastStatusCheckTimestamp != 0L) { - if (SystemClock.elapsedRealtime() - lastStatusCheckTimestamp > MAX_STATUS_CHECK_LIMIT) { - loadStatus(cryptoCurrencyStatus) - lastStatusCheckTimestamp = 0L - } else { - showProcessing(tokenProtocolStatus) - } - } else { - showProcessing(tokenProtocolStatus) - lastStatusCheckTimestamp = SystemClock.elapsedRealtime() - } - } - else -> { - loadStatus(cryptoCurrencyStatus) - lastStatusCheckTimestamp = 0L - } + val pendingStatus = yieldSupplyEnterStatusUseCase( + userWalletId = userWallet.walletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).getOrNull() + + if (pendingStatus != null) { + showProcessing(pendingStatus) + } else { + loadStatus(cryptoCurrencyStatus) } }.saveIn(loadStatusJobHolder) - private fun showProcessing(status: YieldSupplyEnterStatus) { + private fun showProcessing(status: YieldSupplyPendingStatus) { uiState.update { when (status) { - YieldSupplyEnterStatus.Enter -> YieldSupplyUM.Processing.Enter - YieldSupplyEnterStatus.Exit -> YieldSupplyUM.Processing.Exit + is YieldSupplyPendingStatus.Enter -> YieldSupplyUM.Processing.Enter + is YieldSupplyPendingStatus.Exit -> YieldSupplyUM.Processing.Exit } } - fetchCurrencyWithDelay() } private suspend fun loadStatus(cryptoCurrencyStatus: CryptoCurrencyStatus) { val yieldSupplyStatus = cryptoCurrencyStatus.value.yieldSupplyStatus - yieldSupplyRepository.saveTokenProtocolStatus( - userWalletId = userWallet.walletId, - cryptoCurrency = cryptoCurrency, - yieldSupplyEnterStatus = null, - ) if (yieldSupplyStatus?.isActive == true) { loadActiveState( cryptoCurrencyStatus = cryptoCurrencyStatus, @@ -264,20 +210,6 @@ internal class YieldSupplyModel @Inject constructor( } } - private fun fetchCurrencyWithDelay() { - coroutineScope.launch(dispatchers.io) { - delay(PROCESSING_UPDATE_DELAY) - singleNetworkStatusFetcher( - params = SingleNetworkStatusFetcher.Params( - userWalletId = userWallet.walletId, - network = cryptoCurrency.network, - ), - ).onLeft { - fetchCurrencyWithDelay() - } - }.saveIn(fetchCurrencyJobHolder) - } - private suspend fun loadActiveState( cryptoCurrencyStatus: CryptoCurrencyStatus, yieldSupplyStatus: YieldSupplyStatus, @@ -382,9 +314,4 @@ internal class YieldSupplyModel @Inject constructor( } } } - - private companion object { - const val PROCESSING_UPDATE_DELAY = 10_000L - const val MAX_STATUS_CHECK_LIMIT = 10_000L - } } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt index 9dc753015c..f6163ccc3c 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt @@ -54,7 +54,7 @@ internal class YieldSupplyNotificationsModel @Inject constructor( fee = data.feeValue.orZero(), userWalletId = params.userWalletId, tokenStatus = cryptoCurrencyStatus, - coinStatus = feeCryptoCurrencyStatus, + feeStatus = feeCryptoCurrencyStatus, ).getOrNull() addExceedsBalanceNotification( diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt index 6a2d3d2597..aae5996e1a 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt @@ -21,7 +21,7 @@ import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.yield.supply.YieldSupplyRepository -import com.tangem.domain.yield.supply.models.YieldSupplyEnterStatus +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import com.tangem.domain.yield.supply.usecase.* import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.R @@ -65,6 +65,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor( private val yieldSupplyGetMaxFeeUseCase: YieldSupplyGetMaxFeeUseCase, private val yieldSupplyGetCurrentFeeUseCase: YieldSupplyGetCurrentFeeUseCase, private val yieldSupplyRepository: YieldSupplyRepository, + private val yieldSupplyPendingTracker: YieldSupplyPendingTracker, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyStartEarningComponent.Params = paramsContainer.require() @@ -245,18 +246,21 @@ internal class YieldSupplyStartEarningModel @Inject constructor( }, ) }, - ifRight = { - onStartEarningTransactionSuccess(yieldSupplyFeeUM) + ifRight = { txsData -> + onStartEarningTransactionSuccess(yieldSupplyFeeUM, txsData) }, ) } } - private suspend fun onStartEarningTransactionSuccess(yieldSupplyFeeUM: YieldSupplyFeeUM.Content) { - yieldSupplyRepository.saveTokenProtocolStatus( - userWalletId, - cryptoCurrency, - YieldSupplyEnterStatus.Enter, + private suspend fun onStartEarningTransactionSuccess( + yieldSupplyFeeUM: YieldSupplyFeeUM.Content, + txsData: List, + ) { + yieldSupplyRepository.saveTokenProtocolPendingStatus( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + yieldSupplyPendingStatus = YieldSupplyPendingStatus.Enter(txsData), ) val event = AnalyticsParam.TxSentFrom.Earning( blockchain = cryptoCurrency.network.name, @@ -288,6 +292,11 @@ internal class YieldSupplyStartEarningModel @Inject constructor( } modelScope.launch { + yieldSupplyPendingTracker.addPending( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + txIds = txsData, + ) params.callback.onTransactionSent() } } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt index f327d6c82a..e79191dc86 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt @@ -21,8 +21,9 @@ import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.yield.supply.INCREASE_GAS_LIMIT_FOR_SUPPLY import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.increaseGasLimitBy -import com.tangem.domain.yield.supply.models.YieldSupplyEnterStatus +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import com.tangem.domain.yield.supply.usecase.YieldSupplyDeactivateUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyPendingTracker import com.tangem.domain.yield.supply.usecase.YieldSupplyStopEarningUseCase import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.R @@ -61,6 +62,7 @@ internal class YieldSupplyStopEarningModel @Inject constructor( private val yieldSupplyAlertFactory: YieldSupplyAlertFactory, private val yieldSupplyDeactivateUseCase: YieldSupplyDeactivateUseCase, private val yieldSupplyRepository: YieldSupplyRepository, + private val yieldSupplyPendingTracker: YieldSupplyPendingTracker, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyStopEarningComponent.Params = paramsContainer.require() @@ -167,18 +169,18 @@ internal class YieldSupplyStopEarningModel @Inject constructor( ) params.callback.onTransactionProgress(false) }, - ifRight = { - onStopEarningTransactionSuccess() + ifRight = { txData -> + onStopEarningTransactionSuccess(txData) }, ) } } - private suspend fun onStopEarningTransactionSuccess() { - yieldSupplyRepository.saveTokenProtocolStatus( - userWallet.walletId, - cryptoCurrency, - YieldSupplyEnterStatus.Exit, + private suspend fun onStopEarningTransactionSuccess(txId: String) { + yieldSupplyRepository.saveTokenProtocolPendingStatus( + userWalletId = userWallet.walletId, + cryptoCurrency = cryptoCurrency, + yieldSupplyPendingStatus = YieldSupplyPendingStatus.Exit(listOf(txId)), ) analytics.send( YieldSupplyAnalytics.FundsWithdrawn( @@ -203,6 +205,11 @@ internal class YieldSupplyStopEarningModel @Inject constructor( } modelScope.launch { + yieldSupplyPendingTracker.addPending( + userWalletId = userWallet.walletId, + cryptoCurrency = cryptoCurrency, + txIds = listOf(txId), + ) params.callback.onStopEarningTransactionSent() } } diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index f284f5dc0c..7e3eb32287 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" @@ -99,6 +99,7 @@ markdown = "0.7.2" markdownComposeView = "0.5.4" usedesk = "4.4.0" sumsub = "1.38.0" +haze = "1.7.1" # endregion Other libraries # region Tools @@ -304,4 +305,6 @@ markdown-composeview = { module = "com.github.jeziellago:compose-markdown", vers usedesk-chat-sdk = { module = "com.github.Usedesk.Android_SDK:chat-sdk", version.ref = "usedesk" } usedesk-chat-gui = { module = "com.github.Usedesk.Android_SDK:chat-gui", version.ref = "usedesk" } sumsub-sdk = { module = "com.sumsub.sns:idensic-mobile-sdk", version.ref = "sumsub" } +haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } +haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } # endregion Other diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt index 0e7eda2f34..a4f5188846 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt @@ -36,6 +36,9 @@ internal class WalletManagerFactoryCreator @Inject constructor( accountCreator = accountCreator, featureToggles = BlockchainFeatureToggles( isYieldSupplyEnabled = featureTogglesManager.isFeatureEnabled("YIELD_SUPPLY_FEATURE_ENABLED"), + isPendingTransactionsEnabled = featureTogglesManager.isFeatureEnabled( + "YIELD_SUPPLY_PENDING_TRANSACTIONS_ENABLED", + ), ), blockchainDataStorage = blockchainDataStorage, loggers = listOf(blockchainSDKLogger), diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt index 30f9d06686..74d6248b75 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt @@ -41,6 +41,7 @@ object BlockchainFeeUtils { private fun Fee.increaseEthGasLimitInNeeded(increaseBy: Int): Fee { return when (this) { + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") is Fee.Ethereum.EIP1559, is Fee.Ethereum.Legacy, -> this.increaseGasLimitBy(increaseBy) @@ -76,6 +77,7 @@ object BlockchainFeeUtils { return when (this) { is Fee.Ethereum.EIP1559 -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) is Fee.Ethereum.Legacy -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") } } } \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt index 91fb30dd4f..76d81ec009 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt @@ -115,6 +115,11 @@ object BlockchainUtils { return blockchain == Blockchain.BSC || blockchain == Blockchain.BSCTestnet } + fun isEthereum(blockchainId: String): Boolean { + val blockchain = Blockchain.fromId(blockchainId) + return blockchain == Blockchain.Ethereum || blockchain == Blockchain.EthereumTestnet + } + fun isClore(blockchainId: String): Boolean { return Blockchain.fromId(blockchainId) == Blockchain.Clore } @@ -159,8 +164,10 @@ object BlockchainUtils { return blockchain != Blockchain.Cardano } - fun isStakingRewardUnavailable(blockchainId: String): Boolean { - return isSolana(blockchainId) || isBSC(blockchainId) || isTon(blockchainId) + fun isStakingRewardUnavailable(blockchainId: String, isCoin: Boolean): Boolean { + val isP2PEthPool = isEthereum(blockchainId) && isCoin + + return isSolana(blockchainId) || isBSC(blockchainId) || isTon(blockchainId) || isP2PEthPool } /** Checks if the blockchain uses case-insensitive contract addresses */ diff --git a/settings.gradle.kts b/settings.gradle.kts index 50802ec815..83aadc8341 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -302,9 +302,6 @@ include(":features:yield-supply:impl") include(":features:feed:api") include(":features:feed:impl") - -include(":features:news:news-details:api") -include(":features:news:news-details:impl") // endregion Feature modules // region Domain modules diff --git a/tangem-android-tools b/tangem-android-tools index d7950d60bb..7c69a53ea8 160000 --- a/tangem-android-tools +++ b/tangem-android-tools @@ -1 +1 @@ -Subproject commit d7950d60bb4c6353f2aa3364073c6ec72167c666 +Subproject commit 7c69a53ea8f5c7d94fa83c33d40f495fa24dbe02