diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 097bcd4c84..eabd10f670 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -119,6 +119,7 @@ dependencies { implementation(projects.domain.card) implementation(projects.domain.demo) implementation(projects.domain.demo.models) + implementation(projects.domain.express) implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) implementation(projects.domain.settings) 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 62e83ab9c4..d5fe54f4a6 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -5,7 +5,10 @@ object TestConstants { const val RECIPIENT_ADDRESS = "0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0" const val BITCOIN_ADDRESS = "bc1qtg9aa6jcpqtvun0pe0uct7sxm8nq2nsxfmfxm3" - const val CARDANO_ADDRESS = "addr1q8f9499e58k4hhfd9vhawprxt3xd94x7rmlyp33ee4xkatakcl2zgkrg0p6ceqkndtkw4cumfe9enhdph8yhuswn785srksm9p" + const val CARDANO_ADDRESS = + "addr1q8f9499e58k4hhfd9vhawprxt3xd94x7rmlyp33ee4xkatakcl2zgkrg0p6ceqkndtkw4cumfe9enhdph8yhuswn785srksm9p" + const val SOLANA_RECIPIENT_ADDRESS = "5fcy9woa8Di1QHcce65CsV3XKrxdB2pD4HJx5xx82ipM" + const val POLKADOT_RECIPIENT_ADDRESS = "143TfgFYAFfM86LRzt4UcFNU3KosxCndBCVz2U5HCxpLidKZ" const val WAIT_UNTIL_TIMEOUT = 20_000L const val WAIT_UNTIL_TIMEOUT_LONG = 30_000L diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt index b8751cb679..992a417013 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt @@ -20,12 +20,12 @@ fun BaseTestCase.swipeVertical( ) } -fun BaseTestCase.pullToRefresh() { +fun BaseTestCase.pullToRefresh(steps: Int = 1000) { swipeVertical( direction = SwipeDirection.DOWN, startHeightRatio = 0.2f, endHeightRatio = 0.8f, - steps = 1000 + steps = steps ) } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt index 0c3430dc3c..82c7f1feab 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt @@ -115,4 +115,28 @@ fun BaseTestCase.checkMultiCurrencyMainScreen( step("Assert 'Organize tokens' button is displayed") { onMainScreen { organizeTokensButton().assertIsDisplayed() } } +} + +fun BaseTestCase.assertActionButtonsForMultiCurrencyWallet(isEnabled: Boolean = true) { + if (isEnabled) { + step("Assert 'Buy' button is enabled") { + onMainScreen { buyButton.assertIsEnabled() } + } + step("Assert 'Swap' button is enabled") { + onMainScreen { swapButton.assertIsEnabled() } + } + step("Assert 'Sell' button is enabled") { + onMainScreen { sellButton.assertIsEnabled() } + } + } else { + step("Assert 'Buy' button is not enabled") { + onMainScreen { buyButton.assertIsNotEnabled() } + } + step("Assert 'Swap' button is not enabled") { + onMainScreen { swapButton.assertIsNotEnabled() } + } + step("Assert 'Sell' button is not enabled") { + onMainScreen { sellButton.assertIsNotEnabled() } + } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt index c7f2d7f954..bca0130819 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/DialogScenarios.kt @@ -8,6 +8,7 @@ import com.tangem.screens.AlreadyUsedWalletDialogPageObject.requestSupportButton 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.onFailedTransactionDialog import io.qameta.allure.kotlin.Allure.step @@ -63,4 +64,16 @@ fun checkAlreadyUsedWalletDialog() { step("Assert 'Request support' button is displayed") { AlreadyUsedWalletDialogPageObject { requestSupportButton.isDisplayed() } } +} + +fun BaseTestCase.checkActionIsUnavailableDialog() { + step("Assert 'Action is unavailable' dialog title is displayed") { + onActionIsUnavailableDialog { title.assertIsDisplayed() } + } + step("Assert 'Action is unavailable' dialog text is displayed") { + onActionIsUnavailableDialog { text.assertIsDisplayed() } + } + step("Assert 'Action is unavailable' dialog 'Ok' button is displayed") { + onActionIsUnavailableDialog { okButton.assertIsDisplayed() } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SendWarningScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SendWarningScenarios.kt new file mode 100644 index 0000000000..57075d39df --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SendWarningScenarios.kt @@ -0,0 +1,57 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.screens.onSendConfirmScreen +import io.github.kakaocup.compose.node.element.KNode +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.checkSendWarning( + titleResId: Int, + messageResId: Int, + amount: String, + isDisplayed: Boolean = true, +) { + val assertDisplay = if (isDisplayed) "displayed" else "not displayed" + + step("Assert 'Send confirm screen' is displayed") { + onSendConfirmScreen { + title.assertIsDisplayed() + } + } + step("Assert warning title is $assertDisplay") { + onSendConfirmScreen { + warningTitle(titleResId).assertVisibility(isDisplayed) + } + } + step("Assert warning icon is $assertDisplay") { + onSendConfirmScreen { + sendWarningIcon(messageResId, amount).assertVisibility(isDisplayed) + } + + } + step("Assert warning message is $assertDisplay") { + onSendConfirmScreen { + sendWarningMessage(messageResId, amount).assertVisibility(isDisplayed) + } + } + if (isDisplayed) + step("Assert 'Send' button is disabled") { + onSendConfirmScreen { + sendButton.assertIsNotEnabled() + } + } + else + step("Assert 'Send' button is enabled") { + onSendConfirmScreen { + sendButton.assertIsEnabled() + } + } +} + +private fun KNode.assertVisibility(shouldBeDisplayed: Boolean) { + if (shouldBeDisplayed) { + assertIsDisplayed() + } else { + assertIsNotDisplayed() + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ActionIsUnavailableDialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ActionIsUnavailableDialogPageObject.kt new file mode 100644 index 0000000000..166e1056b9 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/ActionIsUnavailableDialogPageObject.kt @@ -0,0 +1,40 @@ +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 ActionIsUnavailableDialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val dialogContainer: KNode = child { + hasTestTag(BaseDialogTestTags.CONTAINER) + } + + val title: KNode = child { + hasTestTag(BaseDialogTestTags.TITLE) + hasText(getResourceString(CommonUIR.string.action_buttons_something_wrong_alert_title)) + useUnmergedTree = true + } + + val text: KNode = child { + hasTestTag(BaseDialogTestTags.TEXT) + hasText(getResourceString(CommonUIR.string.action_buttons_something_wrong_alert_message)) + useUnmergedTree = true + } + + val okButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_ok)) + } +} + +internal fun BaseTestCase.onActionIsUnavailableDialog(function: ActionIsUnavailableDialogPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt index 9543830d26..7f6402803d 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt @@ -17,6 +17,14 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasTestTag(BaseDialogTestTags.CONTAINER) } + val title: KNode = child { + hasTestTag(BaseDialogTestTags.TITLE) + } + + val text: KNode = child { + hasTestTag(BaseDialogTestTags.TEXT) + } + val cancelButton: KNode = child { hasTestTag(BaseButtonTestTags.BUTTON) hasText(getResourceString(R.string.common_cancel)) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SellPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SellPageObject.kt new file mode 100644 index 0000000000..ee721654ec --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SellPageObject.kt @@ -0,0 +1,24 @@ +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.* +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class SellPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasTestTag(TopAppBarTestTags.TITLE) + hasText(getResourceString(R.string.common_sell)) + useUnmergedTree = true + } + +} + +internal fun BaseTestCase.onSellScreen(function: SellPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt index 5821029a67..6d9cc4e8ef 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt @@ -2,17 +2,13 @@ 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.NotificationTestTags -import com.tangem.core.ui.test.SendConfirmScreenTestTags -import com.tangem.core.ui.test.TopAppBarTestTags +import com.tangem.core.ui.test.* +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.hasText as withText -import com.tangem.common.ui.R as CommonUiR class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -23,15 +19,25 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider } val sendButton: KNode = child { - hasTestTag(BaseButtonTestTags.TEXT) - hasText(getResourceString(R.string.common_send)) + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyDescendant(withText(getResourceString(R.string.common_send))) useUnmergedTree = true } + val primaryAmount: KNode = child { + hasTestTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT) + useUnmergedTree = true + } - val minimumSendAmountErrorTitle: KNode = child { + fun leaveDepositButton(amount: String): KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyDescendant(withText(getResourceString(R.string.send_notification_leave_button, amount))) + useUnmergedTree = true + } + + fun warningTitle(titleResId: Int): KNode = child { hasTestTag(NotificationTestTags.TITLE) - hasText(getResourceString(CommonUiR.string.send_notification_invalid_amount_title)) + hasText(getResourceString(titleResId)) useUnmergedTree = true } @@ -40,34 +46,35 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider useUnmergedTree = true } - fun minimumSendAmountErrorIcon(amount: String): KNode = child { + fun sendWarningIcon(messageResId: Int, amount: String): KNode = child { + hasTestTag(NotificationTestTags.ICON) hasAnySibling( withText( getResourceString( - CommonUiR.string.send_notification_invalid_minimum_amount_text, + messageResId, amount, amount, ) ) + ) - hasTestTag(NotificationTestTags.ICON) useUnmergedTree = true } - fun minimumSendAmountErrorMessage( + fun sendWarningMessage( + messageResId: Int, amount: String, ): KNode = child { hasTestTag(NotificationTestTags.MESSAGE) hasText( getResourceString( - CommonUiR.string.send_notification_invalid_minimum_amount_text, + messageResId, amount, amount, ) ) useUnmergedTree = true } - } internal fun BaseTestCase.onSendConfirmScreen(function: SendConfirmPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt index d12952956b..baffa23c16 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt @@ -64,6 +64,12 @@ class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : useUnmergedTree = true } + val continueButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(SendR.string.common_continue)) + useUnmergedTree = true + } + } internal fun BaseTestCase.onSendScreen(function: SendPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt index 28b46d345c..4a78ce56a9 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/BlockchainTest.kt @@ -4,11 +4,16 @@ import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.CARDANO_ADDRESS import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.pullToRefresh +import com.tangem.common.ui.R import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.checkSendWarning import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.synchronizeAddresses -import com.tangem.screens.* +import com.tangem.screens.onMainScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendScreen +import com.tangem.screens.onTokenDetailsScreen import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -29,6 +34,9 @@ class BlockchainTest : BaseTestCase() { val scenarioName = "user_tokens_api" val scenarioState = "Cardano" + val invalidAmountTitleResId = R.string.send_notification_invalid_amount_title + val invalidAmountMessageResId = R.string.send_notification_invalid_minimum_amount_text + setupHooks( additionalAfterSection = { resetWireMockScenarioState(scenarioName) @@ -64,14 +72,12 @@ class BlockchainTest : BaseTestCase() { step("Click on 'Next' button") { onSendAddressScreen { nextButton.clickWithAssertion() } } - step("Assert 'Invalid amount' error title is displayed") { - onSendConfirmScreen { minimumSendAmountErrorTitle.assertIsDisplayed() } - } - step("Assert 'Invalid amount' error icon is displayed") { - onSendConfirmScreen { minimumSendAmountErrorIcon(minAmount).assertIsDisplayed() } - } - step("Assert 'Invalid amount' error message is displayed") { - onSendConfirmScreen { minimumSendAmountErrorMessage(minAmount).assertIsDisplayed() } + step("Assert 'Invalid amount warning' is displayed") { + checkSendWarning( + titleResId = invalidAmountTitleResId, + messageResId = invalidAmountMessageResId, + amount = minAmount + ) } step("Press system 'Back' button") { device.uiDevice.pressBack() @@ -97,14 +103,13 @@ class BlockchainTest : BaseTestCase() { step("Click on 'Next' button") { onSendAddressScreen { nextButton.clickWithAssertion() } } - step("Assert 'Invalid amount' error title is not displayed") { - onSendConfirmScreen { minimumSendAmountErrorTitle.assertIsNotDisplayed() } - } - step("Assert 'Invalid amount' error icon is not displayed") { - onSendConfirmScreen { minimumSendAmountErrorIcon(minAmount).assertIsNotDisplayed() } - } - step("Assert 'Invalid amount' error message is not displayed") { - onSendConfirmScreen { minimumSendAmountErrorMessage(minAmount).assertIsNotDisplayed() } + step("Assert 'Invalid amount warning' is not displayed") { + checkSendWarning( + titleResId = invalidAmountTitleResId, + messageResId = invalidAmountMessageResId, + amount = minAmount, + isDisplayed = false + ) } } } @@ -136,10 +141,16 @@ class BlockchainTest : BaseTestCase() { setWireMockScenarioState(scenarioName = userTokensScenarioName, state = userTokensScenarioState) } step("Set WireMock scenario: '$rippleAccountInfoScenarioName' to state: '$rippleAccountInfoErrorState'") { - setWireMockScenarioState(scenarioName = rippleAccountInfoScenarioName, state = rippleAccountInfoErrorState) + setWireMockScenarioState( + scenarioName = rippleAccountInfoScenarioName, + state = rippleAccountInfoErrorState + ) } step("Set WireMock scenario: '$rippleAccountLinesScenarioName' to state: '$rippleAccountLinesErrorState'") { - setWireMockScenarioState(scenarioName = rippleAccountLinesScenarioName, state = rippleAccountLinesErrorState) + setWireMockScenarioState( + scenarioName = rippleAccountLinesScenarioName, + state = rippleAccountLinesErrorState + ) } step("Open 'Main Screen'") { openMainScreen() @@ -169,10 +180,16 @@ class BlockchainTest : BaseTestCase() { setWireMockScenarioState(scenarioName = userTokensScenarioName, state = userTokensScenarioState) } step("Set WireMock scenario: '$rippleAccountInfoScenarioName' to state: '$rippleAccountInfoStartedState'") { - setWireMockScenarioState(scenarioName = rippleAccountInfoScenarioName, state = rippleAccountInfoStartedState) + setWireMockScenarioState( + scenarioName = rippleAccountInfoScenarioName, + state = rippleAccountInfoStartedState + ) } step("Set WireMock scenario: '$rippleAccountLinesScenarioName' to state: '$rippleAccountLinesStartedState'") { - setWireMockScenarioState(scenarioName = rippleAccountLinesScenarioName, state = rippleAccountLinesStartedState) + setWireMockScenarioState( + scenarioName = rippleAccountLinesScenarioName, + state = rippleAccountLinesStartedState + ) } step("Pull to refresh") { pullToRefresh() 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 8601d27658..927bbf228a 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -5,11 +5,18 @@ import com.tangem.common.BaseTestCase 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.assertClipboardTextEquals import com.tangem.common.utils.clearClipboard +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.assertActionButtonsForMultiCurrencyWallet +import com.tangem.scenarios.checkActionIsUnavailableDialog import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.* +import com.tangem.tap.domain.sdk.mocks.MockContent +import com.tangem.tap.domain.sdk.mocks.content.TwinsMockContent import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -391,4 +398,258 @@ class MainScreenActionButtonsTest : BaseTestCase() { } } } + + @AllureId("895") + @DisplayName("Action buttons: check blockchain information by click on 'Buy' button") + @Test + fun checkClickOnBuyButtonOnMainTest() { + val cardType: MockContent = TwinsMockContent + val cardName = "Twin" + val tokenTitle = "Bitcoin" + val tokenSymbol = "BTC" + + setupHooks().run { + step("Open 'Main Screen' on '$cardName' card") { + openMainScreen(mockContent = cardType, isTwinsCard = true) + } + step("Assert 'Buy' button is displayed") { + onMainScreen { buyButton.assertIsDisplayed() } + } + step("Click on 'Buy' button") { + onMainScreen { buyButton.performClick() } + } + step("Click on 'Confirm' button in 'Dialog'") { + waitForIdle() + onDialog { confirmButton.clickWithAssertion() } + } + step("Assert top app bar title contains '$tokenTitle'") { + onBuyTokenDetailsScreen { topBarTitle.assertTextContains("Buy $tokenTitle") } + } + step("Assert fiat currency text field is displayed") { + onBuyTokenDetailsScreen { fiatAmountTextField.assertIsDisplayed() } + } + step("Assert fiat currency icon is displayed") { + onBuyTokenDetailsScreen { fiatCurrencyIcon.assertIsDisplayed() } + } + step("Assert token amount field is displayed") { + onBuyTokenDetailsScreen { tokenAmountField.assertTextContains(tokenSymbol, substring = true) } + } + step("Assert 'Continue' button") { + onBuyTokenDetailsScreen { continueButton.assertIsDisplayed() } + } + } + } + + @AllureId("4395") + @DisplayName("Action buttons (main screen): click on buttons with success response") + @Test + fun clickOnActionButtonsWithSuccessResponseTest() { + val tokenTitle = "Ethereum" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Assert 'Buy' button is displayed") { + onMainScreen { buyButton.assertIsDisplayed() } + } + step("Click on 'Buy' button") { + onMainScreen { buyButton.performClick() } + } + step("Assert 'Buy' screen title is displayed") { + onBuyTokenScreen { topAppBarTitle.assertIsDisplayed() } + } + step("Assert token with title: '$tokenTitle' is displayed") { + onBuyTokenScreen { tokenWithTitleAndFiatAmount(tokenTitle).assertIsDisplayed() } + } + step("Press 'Back' button") { + device.uiDevice.pressBack() + } + step("Assert 'Swap' button is displayed") { + onMainScreen { swapButton.assertIsDisplayed() } + } + step("Click on 'Swap' button") { + onMainScreen { swapButton.performClick() } + } + step("Click on close button on stories screen") { + onSwapStoriesScreen { closeButton.performClick() } + } + step("Assert 'Swap' token screen title is displayed") { + onSwapTokenScreen { title.assertIsDisplayed() } + } + step("Press 'Back' button") { + device.uiDevice.pressBack() + } + step("Assert 'Sell' button is displayed") { + onMainScreen { sellButton.assertIsDisplayed() } + } + step("Click on 'Sell' button") { + onMainScreen { sellButton.performClick() } + } + step("Assert 'Sell' token screen title is displayed") { + onSellScreen { title.assertIsDisplayed() } + } + } + } + + @AllureId("4396") + @DisplayName("Action buttons (main screen): click on buttons without data") + @Test + fun clickOnActionButtonsWithoutDataTest() { + setupHooks( + additionalAfterSection = { + enableWiFi() + enableMobileData() + } + ).run { + step("Turn off internet") { + disableWiFi() + disableMobileData() + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Assert 'Buy' button is displayed") { + onMainScreen { buyButton.assertIsDisplayed() } + } + step("Click on 'Buy' button") { + onMainScreen { buyButton.performClick() } + } + step("Check 'Action is unavailable' dialog") { + checkActionIsUnavailableDialog() + } + step("Click on 'Ok' button") { + onDialog { okButton.performClick() } + } + step("Assert 'Swap' button is displayed") { + onMainScreen { swapButton.assertIsDisplayed() } + } + step("Click on 'Swap' button") { + onMainScreen { swapButton.performClick() } + } + step("Check 'Action is unavailable' dialog") { + checkActionIsUnavailableDialog() + } + step("Click on 'Ok' button") { + onDialog { okButton.performClick() } + } + step("Assert 'Sell' button is displayed") { + onMainScreen { sellButton.assertIsDisplayed() } + } + step("Click on 'Sell' button") { + onMainScreen { sellButton.performClick() } + } + step("Check 'Action is unavailable' dialog") { + checkActionIsUnavailableDialog() + } + step("Click on 'Ok' button") { + onDialog { okButton.performClick() } + } + } + } + + @AllureId("4398") + @DisplayName("Action buttons (main screen): click on buttons with error response") + @Test + fun clickOnActionButtonsWithErrorResponseTest() { + val scenarioName = "express_api_assets" + val scenarioState = "Error" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName, scenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Assert 'Buy' button is displayed") { + onMainScreen { buyButton.assertIsDisplayed() } + } + step("Click on 'Buy' button") { + onMainScreen { buyButton.performClick() } + } + step("Check 'Action is unavailable' dialog") { + checkActionIsUnavailableDialog() + } + step("Click on 'Ok' button") { + onDialog { okButton.performClick() } + } + step("Assert 'Swap' button is displayed") { + onMainScreen { swapButton.assertIsDisplayed() } + } + step("Click on 'Swap' button") { + onMainScreen { swapButton.performClick() } + } + step("Check 'Action is unavailable' dialog") { + checkActionIsUnavailableDialog() + } + step("Click on 'Ok' button") { + onDialog { okButton.performClick() } + } + step("Assert 'Sell' button is displayed") { + onMainScreen { sellButton.assertIsDisplayed() } + } + step("Click on 'Sell' button") { + onMainScreen { sellButton.performClick() } + } + // ToDo("[REDACTED_JIRA] - add ability to use MoonPay mocks") + // step("Check 'Action is unavailable' dialog") { + // checkActionIsUnavailableDialog() + // } + // step("Click on 'Ok' button") { + // onDialog { okButton.performClick() } + // } + } + } + + @AllureId("3642") + @DisplayName("Action buttons (main screen): check buttons state") + @Test + fun checkButtonsStateTest() { + val scenarioName = "user_tokens_api" + val scenarioState = "EmptyTokensList" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(scenarioName) + } + ).run { + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Assert action buttons is not enabled") { + assertActionButtonsForMultiCurrencyWallet(isEnabled = false) + } + step("Reset Wiremock scenario: '$scenarioName'") { + resetWireMockScenarioState(scenarioName) + } + step("Perform pull to refresh") { + pullToRefresh(steps = 10) + waitForIdle() + } + step("Assert action buttons is enabled") { + assertActionButtonsForMultiCurrencyWallet(isEnabled = true) + } + step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { + setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) + } + step("Perform pull to refresh") { + pullToRefresh(steps = 10) + waitForIdle() + } + step("Assert action buttons is not enabled") { + assertActionButtonsForMultiCurrencyWallet(isEnabled = false) + } + } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/SolanaWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/SolanaWarningsTest.kt new file mode 100644 index 0000000000..3b58fa9151 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/SolanaWarningsTest.kt @@ -0,0 +1,249 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.SOLANA_RECIPIENT_ADDRESS +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onMainScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendScreen +import com.tangem.screens.onTokenDetailsScreen +import com.tangem.wallet.R +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SolanaWarningsTest : BaseTestCase() { + private val tokenName = "Solana" + private val amountToLeaveLessThanRent = "0.0016941" + private val amountToLeaveGreaterThanRent = "0.0000941" + private val amountToLeaveRentOnly = "0.00168934" + private val userTokensScenarioName = "user_tokens_api" + private val userTokensScenarioState = "Solana" + private val quotesScenarioName = "quotes_api" + private val quotesScenarioState = "Solana" + private val rentAmount = "0.000890880" + + private val invalidAmountTitleResId = R.string.send_notification_invalid_amount_title + private val invalidAmountMessageResId = R.string.send_notification_invalid_amount_rent_fee + + @AllureId("564") + @DisplayName("Warnings: warning is displayed, if after send balance is less than rent amount (SOLANA)") + @Test + fun warningIsDisplayedWhenLeaveLessThanRent() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenarioName) + resetWireMockScenarioState(quotesScenarioName) + } + ).run { + step("Set WireMock scenario: '$userTokensScenarioName' to state: '$userTokensScenarioState'") { + setWireMockScenarioState(scenarioName = userTokensScenarioName, state = userTokensScenarioState) + } + step("Set WireMock scenario: '$quotesScenarioName' to state: '$quotesScenarioState'") { + setWireMockScenarioState(scenarioName = quotesScenarioName, state = quotesScenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Click on 'Send' button") { + onTokenDetailsScreen { sendButton.performClick() } + } + step("Type '$amountToLeaveLessThanRent' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveLessThanRent) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(SOLANA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount warning' is displayed") { + checkSendWarning( + titleResId = invalidAmountTitleResId, + messageResId = invalidAmountMessageResId, + amount = rentAmount + ) + } + } + } + + @AllureId("567") + @DisplayName("Warnings: warning is not displayed, if after send balance is greater than rent amount (SOLANA)") + @Test + fun warningIsNotDisplayedWhenLeaveGreaterThanRent() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenarioName) + resetWireMockScenarioState(quotesScenarioName) + } + ).run { + step("Set WireMock scenario: '$userTokensScenarioName' to state: '$userTokensScenarioState'") { + setWireMockScenarioState(scenarioName = userTokensScenarioName, state = userTokensScenarioState) + } + step("Set WireMock scenario: '$quotesScenarioName' to state: '$quotesScenarioState'") { + setWireMockScenarioState(scenarioName = quotesScenarioName, state = quotesScenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Click on 'Send' button") { + onTokenDetailsScreen { sendButton.performClick() } + } + step("Type '$amountToLeaveGreaterThanRent' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveGreaterThanRent) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(SOLANA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount warning' is not displayed") { + checkSendWarning( + titleResId = invalidAmountTitleResId, + messageResId = invalidAmountMessageResId, + amount = rentAmount, + isDisplayed = false + ) + } + } + } + + @AllureId("566") + @DisplayName("Warnings: warning is not displayed, if after send balance is equal to rent amount (SOLANA)") + @Test + fun warningIsNotDisplayedWhenLeaveOnlyRent() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenarioName) + resetWireMockScenarioState(quotesScenarioName) + } + ).run { + step("Set WireMock scenario: '$userTokensScenarioName' to state: '$userTokensScenarioState'") { + setWireMockScenarioState(scenarioName = userTokensScenarioName, state = userTokensScenarioState) + } + step("Set WireMock scenario: '$quotesScenarioName' to state: '$quotesScenarioState'") { + setWireMockScenarioState(scenarioName = quotesScenarioName, state = quotesScenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Click on 'Send' button") { + onTokenDetailsScreen { sendButton.performClick() } + } + step("Type '$amountToLeaveRentOnly' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveRentOnly) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(SOLANA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount warning' is not displayed") { + checkSendWarning( + titleResId = invalidAmountTitleResId, + messageResId = invalidAmountMessageResId, + amount = rentAmount, + isDisplayed = false + ) + } + } + } + + @AllureId("565") + @DisplayName("Warnings: warning is not displayed, if after send balance is zero (SOLANA)") + @Test + fun warningIsNotDisplayedWhenLeaveZeroSol() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenarioName) + resetWireMockScenarioState(quotesScenarioName) + } + ).run { + step("Set WireMock scenario: '$userTokensScenarioName' to state: '$userTokensScenarioState'") { + setWireMockScenarioState(scenarioName = userTokensScenarioName, state = userTokensScenarioState) + } + step("Set WireMock scenario: '$quotesScenarioName' to state: '$quotesScenarioState'") { + setWireMockScenarioState(scenarioName = quotesScenarioName, state = quotesScenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Click on 'Send' button") { + onTokenDetailsScreen { sendButton.performClick() } + } + step("Type max amount in input text field") { + onSendScreen { + maxButton.performClick() + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(SOLANA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount warning' is not displayed") { + checkSendWarning( + titleResId = invalidAmountTitleResId, + messageResId = invalidAmountMessageResId, + amount = rentAmount, + isDisplayed = false + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/DefaultAnalyticsContextProxy.kt b/app/src/main/java/com/tangem/tap/common/analytics/DefaultAnalyticsContextProxy.kt index d1aecc29d0..b566a5472a 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/DefaultAnalyticsContextProxy.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/DefaultAnalyticsContextProxy.kt @@ -3,10 +3,13 @@ package com.tangem.tap.common.analytics import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.utils.AnalyticsContextProxy import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet import com.tangem.tap.common.extensions.addContext +import com.tangem.tap.common.extensions.addHotWalletContext import com.tangem.tap.common.extensions.eraseContext import com.tangem.tap.common.extensions.removeContext import com.tangem.tap.common.extensions.setContext +import com.tangem.tap.common.extensions.setHotWalletContext /** [REDACTED_AUTHOR] @@ -17,6 +20,14 @@ internal class DefaultAnalyticsContextProxy : AnalyticsContextProxy { Analytics.setContext(scanResponse) } + override fun addContext(userWallet: UserWallet) { + Analytics.addContext(userWallet) + } + + override fun setHotWalletContext() { + Analytics.setHotWalletContext() + } + override fun eraseContext() { Analytics.eraseContext() } @@ -25,6 +36,10 @@ internal class DefaultAnalyticsContextProxy : AnalyticsContextProxy { Analytics.addContext(scanResponse) } + override fun addHotWalletContext() { + Analytics.addHotWalletContext() + } + override fun removeContext() { Analytics.removeContext() } diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/BlockchainApiExceptionEvent.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/BlockchainApiExceptionEvent.kt index 3e687ae5fd..477bf9f930 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/BlockchainApiExceptionEvent.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/events/BlockchainApiExceptionEvent.kt @@ -6,6 +6,7 @@ class BlockchainApiExceptionEvent( selectedHost: String, exceptionHost: String, error: String, + blockchain: String, ) : AnalyticsEvent( category = "BlockchainSdk", event = "Exception", @@ -13,5 +14,6 @@ class BlockchainApiExceptionEvent( AnalyticsParam.BLOCKCHAIN_SELECTED_HOST to selectedHost, AnalyticsParam.BLOCKCHAIN_EXCEPTION_HOST to exceptionHost, AnalyticsParam.ERROR_DESCRIPTION to error, + AnalyticsParam.BLOCKCHAIN to blockchain, ), ) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/BlockchainExceptionHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/BlockchainExceptionHandler.kt index a1931b993e..83e13ae335 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/BlockchainExceptionHandler.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/BlockchainExceptionHandler.kt @@ -1,6 +1,8 @@ package com.tangem.tap.common.analytics.handlers +import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.ExceptionHandlerOutput +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.tap.common.analytics.events.BlockchainApiExceptionEvent import javax.inject.Inject @@ -8,12 +10,13 @@ import javax.inject.Inject class BlockchainExceptionHandler @Inject constructor( private val analyticsErrorHandler: AnalyticsErrorHandler, ) : ExceptionHandlerOutput { - override fun handleApiSwitch(currentHost: String, nextHost: String, message: String) { + override fun handleApiSwitch(currentHost: String, nextHost: String, message: String, blockchain: Blockchain) { analyticsErrorHandler.sendErrorEvent( BlockchainApiExceptionEvent( selectedHost = nextHost, exceptionHost = currentHost, error = message, + blockchain = blockchain.toNetworkId(), ), ) } diff --git a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt new file mode 100644 index 0000000000..8ed80f5e60 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt @@ -0,0 +1,22 @@ +package com.tangem.tap.common.analytics.paramsInterceptor + +import com.tangem.core.analytics.api.ParamsInterceptor +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam + +class HotWalletContextInterceptor( + val parent: ParamsInterceptor? = null, +) : ParamsInterceptor { + + override fun id(): String = HotWalletContextInterceptor.id() + + override fun canBeAppliedTo(event: AnalyticsEvent): Boolean = true + + override fun intercept(params: MutableMap) { + params[AnalyticsParam.PRODUCT_TYPE] = "Mobile Wallet" + } + + companion object { + fun id(): String = HotWalletContextInterceptor::class.java.simpleName + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/LinkedCardContextInterceptor.kt b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/LinkedCardContextInterceptor.kt index 25b67c3ee0..b7309a1366 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/LinkedCardContextInterceptor.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/LinkedCardContextInterceptor.kt @@ -9,7 +9,7 @@ import com.tangem.domain.models.scan.ScanResponse */ class LinkedCardContextInterceptor( scanResponse: ScanResponse, - val parent: LinkedCardContextInterceptor? = null, + val parent: ParamsInterceptor? = null, ) : ParamsInterceptor { private val contextInterceptor = CardContextInterceptor(scanResponse) diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Analytics.kt b/app/src/main/java/com/tangem/tap/common/extensions/Analytics.kt index e09ba24e78..be2a667bb0 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/Analytics.kt +++ b/app/src/main/java/com/tangem/tap/common/extensions/Analytics.kt @@ -4,6 +4,7 @@ import com.tangem.core.analytics.Analytics import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.builder.UserWalletIdBuilder +import com.tangem.tap.common.analytics.paramsInterceptor.HotWalletContextInterceptor import com.tangem.tap.common.analytics.paramsInterceptor.LinkedCardContextInterceptor /** @@ -24,19 +25,46 @@ fun Analytics.setContext(scanResponse: ScanResponse) { fun Analytics.setContext(userWallet: UserWallet) { setUserId(userWallet.walletId.stringValue) - // TODO add product type for hot ([REDACTED_TASK_KEY] [Hot Wallet] Analytics) - if (userWallet is UserWallet.Cold) { - addParamsInterceptor(LinkedCardContextInterceptor(userWallet.scanResponse)) + when (userWallet) { + is UserWallet.Cold -> { + removeParamsInterceptor(HotWalletContextInterceptor.id()) + addParamsInterceptor(LinkedCardContextInterceptor(userWallet.scanResponse)) + } + is UserWallet.Hot -> { + removeParamsInterceptor(LinkedCardContextInterceptor.id()) + addParamsInterceptor(HotWalletContextInterceptor()) + } } } +fun Analytics.setHotWalletContext() { + addParamsInterceptor(HotWalletContextInterceptor()) +} + /** * Erases the context */ fun Analytics.eraseContext() { clearUserId() removeParamsInterceptor(LinkedCardContextInterceptor.id()) + removeParamsInterceptor(HotWalletContextInterceptor.id()) +} + +/** + * Adds a new context and keeps a previous context as the parent of the new one + */ +fun Analytics.addContext(userWallet: UserWallet) { + val currentContext = removeParamsInterceptor(LinkedCardContextInterceptor.id()) + ?: removeParamsInterceptor(HotWalletContextInterceptor.id()) + + val newContext = when (userWallet) { + is UserWallet.Cold -> LinkedCardContextInterceptor(userWallet.scanResponse, parent = currentContext) + is UserWallet.Hot -> HotWalletContextInterceptor(parent = currentContext) + } + + setUserId(userWalletId = userWallet.walletId.stringValue) + addParamsInterceptor(newContext) } /** @@ -48,18 +76,32 @@ fun Analytics.addContext(scanResponse: ScanResponse) { setUserId(userWalletId.stringValue) } - val currentContext = removeParamsInterceptor(LinkedCardContextInterceptor.id()) as? LinkedCardContextInterceptor + val currentContext = removeParamsInterceptor(LinkedCardContextInterceptor.id()) + ?: removeParamsInterceptor(HotWalletContextInterceptor.id()) val newContext = LinkedCardContextInterceptor(scanResponse, parent = currentContext) addParamsInterceptor(newContext) } +fun Analytics.addHotWalletContext() { + val currentContext = removeParamsInterceptor(LinkedCardContextInterceptor.id()) as? LinkedCardContextInterceptor + val newContext = HotWalletContextInterceptor(currentContext) + + addParamsInterceptor(newContext) +} + /** * Removes the current context and restores the previous one if it was present. */ fun Analytics.removeContext() { - val currentContext = removeParamsInterceptor(LinkedCardContextInterceptor.id()) as? LinkedCardContextInterceptor - val previousContext = currentContext?.parent ?: return + val currentContext = removeParamsInterceptor(LinkedCardContextInterceptor.id()) + ?: removeParamsInterceptor(HotWalletContextInterceptor.id()) + + val previousContext = when (currentContext) { + is LinkedCardContextInterceptor -> currentContext.parent + is HotWalletContextInterceptor -> currentContext.parent + else -> null + } ?: return addParamsInterceptor(previousContext) } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/ActivityModule.kt b/app/src/main/java/com/tangem/tap/di/ActivityModule.kt index e963c1f140..d0cead55f7 100644 --- a/app/src/main/java/com/tangem/tap/di/ActivityModule.kt +++ b/app/src/main/java/com/tangem/tap/di/ActivityModule.kt @@ -1,10 +1,10 @@ package com.tangem.tap.di import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.tokens.GetPolkadotCheckHasImmortalUseCase import com.tangem.domain.tokens.GetPolkadotCheckHasResetUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -46,14 +46,14 @@ internal object ActivityModule { @Singleton fun provideDefaultRampManager( appStateHolder: AppStateHolder, - expressServiceLoader: ExpressServiceLoader, + expressServiceFetcher: ExpressServiceFetcher, currenciesRepository: CurrenciesRepository, excludedBlockchains: ExcludedBlockchains, dispatchers: CoroutineDispatcherProvider, ): RampStateManager { return DefaultRampManager( sellService = Provider { requireNotNull(appStateHolder.sellService) }, - expressServiceLoader = expressServiceLoader, + expressServiceFetcher = expressServiceFetcher, currenciesRepository = currenciesRepository, dispatchers = dispatchers, excludedBlockchains = excludedBlockchains, diff --git a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt index 0f50feba84..0c20fd0993 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt @@ -179,20 +179,6 @@ internal object OnrampDomainModule { ) } - @Provides - @Singleton - fun provideGetOnrampV2QuotesUseCase( - settingsRepository: SettingsRepository, - onrampRepository: OnrampRepository, - onrampErrorResolver: OnrampErrorResolver, - ): GetOnrampV2QuotesUseCase { - return GetOnrampV2QuotesUseCase( - settingsRepository = settingsRepository, - repository = onrampRepository, - errorResolver = onrampErrorResolver, - ) - } - @Provides @Singleton fun provideGetOnrampProviderWithQuoteUseCase( 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 960857e2eb..f38de6e571 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 @@ -20,11 +20,7 @@ import com.tangem.domain.staking.single.SingleYieldBalanceSupplier import com.tangem.domain.tokens.* import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations import com.tangem.domain.tokens.operations.CachedCurrenciesStatusesOperations -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.tokens.repository.CurrencyChecksRepository -import com.tangem.domain.tokens.repository.PolkadotAccountHealthCheckRepository -import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository -import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository +import com.tangem.domain.tokens.repository.* import com.tangem.domain.tokens.wallet.WalletBalanceFetcher import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.tap.domain.tokens.DefaultTokensFeatureToggles @@ -60,24 +56,6 @@ internal object TokensDomainModule { ) } - @Provides - @Singleton - fun provideFetchTokenListUseCase( - currenciesRepository: CurrenciesRepository, - multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - multiQuoteStatusFetcher: MultiQuoteStatusFetcher, - multiYieldBalanceFetcher: MultiYieldBalanceFetcher, - stakingIdFactory: StakingIdFactory, - ): FetchTokenListUseCase { - return FetchTokenListUseCase( - currenciesRepository = currenciesRepository, - multiNetworkStatusFetcher = multiNetworkStatusFetcher, - multiQuoteStatusFetcher = multiQuoteStatusFetcher, - multiYieldBalanceFetcher = multiYieldBalanceFetcher, - stakingIdFactory = stakingIdFactory, - ) - } - @Provides @Singleton fun provideFetchPendingTransactionsUseCase( @@ -184,24 +162,6 @@ internal object TokensDomainModule { ) } - @Provides - @Singleton - fun provideFetchCardTokenListUseCase( - currenciesRepository: CurrenciesRepository, - multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - multiQuoteStatusFetcher: MultiQuoteStatusFetcher, - multiYieldBalanceFetcher: MultiYieldBalanceFetcher, - stakingIdFactory: StakingIdFactory, - ): FetchCardTokenListUseCase { - return FetchCardTokenListUseCase( - currenciesRepository = currenciesRepository, - multiNetworkStatusFetcher = multiNetworkStatusFetcher, - multiQuoteStatusFetcher = multiQuoteStatusFetcher, - multiYieldBalanceFetcher = multiYieldBalanceFetcher, - stakingIdFactory = stakingIdFactory, - ) - } - @Provides @Singleton fun provideGetCryptoCurrencyUseCase( diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/BackupWalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/BackupWalletMockContent.kt index 284c59ef1a..d1af421423 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/BackupWalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/BackupWalletMockContent.kt @@ -109,7 +109,7 @@ object BackupWalletMockContent : MockContent { chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( - 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, -67), + 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), ), DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( @@ -185,7 +185,7 @@ object BackupWalletMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), @@ -244,7 +244,7 @@ object BackupWalletMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/DevWalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/DevWalletMockContent.kt index e37fa805a0..4880775fca 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/DevWalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/DevWalletMockContent.kt @@ -109,7 +109,7 @@ object DevWalletMockContent : MockContent { chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( - 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, -67), + 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), ), DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( @@ -185,7 +185,7 @@ object DevWalletMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), @@ -244,7 +244,7 @@ object DevWalletMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Firmware412MockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Firmware412MockContent.kt index ae5b7e8beb..635f3a1ef1 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Firmware412MockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Firmware412MockContent.kt @@ -105,7 +105,7 @@ object Firmware412MockContent : MockContent { chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( - 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, -67), + 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), ), DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( @@ -181,7 +181,7 @@ object Firmware412MockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), @@ -240,7 +240,7 @@ object Firmware412MockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RingMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RingMockContent.kt index de2571ece4..0ed5271984 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RingMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RingMockContent.kt @@ -109,7 +109,7 @@ object RingMockContent : MockContent { chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( - 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, -67), + 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), ), DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( @@ -185,7 +185,7 @@ object RingMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), @@ -244,7 +244,7 @@ object RingMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/ShibaMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/ShibaMockContent.kt index 9d3449a462..5dad640c6e 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/ShibaMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/ShibaMockContent.kt @@ -109,7 +109,7 @@ object ShibaMockContent : MockContent { chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( - 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, -67), + 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), ), DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( @@ -185,7 +185,7 @@ object ShibaMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), @@ -244,7 +244,7 @@ object ShibaMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/ShibaNoBackupMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/ShibaNoBackupMockContent.kt index fc03dab84c..073dcbad85 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/ShibaNoBackupMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/ShibaNoBackupMockContent.kt @@ -109,7 +109,7 @@ object ShibaNoBackupMockContent : MockContent { chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( - 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, -67), + 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), ), DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( @@ -185,7 +185,7 @@ object ShibaNoBackupMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), @@ -244,7 +244,7 @@ object ShibaNoBackupMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/ShibaNoBackupNoWalletsMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/ShibaNoBackupNoWalletsMockContent.kt index a1b1b15b20..398946b5ed 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/ShibaNoBackupNoWalletsMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/ShibaNoBackupNoWalletsMockContent.kt @@ -138,7 +138,7 @@ object ShibaNoBackupNoWalletsMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), @@ -197,7 +197,7 @@ object ShibaNoBackupNoWalletsMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2MockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2MockContent.kt index e9204cdc06..f95b5f923f 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2MockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2MockContent.kt @@ -229,7 +229,7 @@ object Wallet2MockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), @@ -281,7 +281,7 @@ object Wallet2MockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2NoBackupMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2NoBackupMockContent.kt index fd3fe78bbc..05c94275c8 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2NoBackupMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2NoBackupMockContent.kt @@ -229,7 +229,7 @@ object Wallet2NoBackupMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), @@ -281,7 +281,7 @@ object Wallet2NoBackupMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2NoBackupNoWalletsMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2NoBackupNoWalletsMockContent.kt index 682794f29b..ddb6f2123d 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2NoBackupNoWalletsMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2NoBackupNoWalletsMockContent.kt @@ -141,7 +141,7 @@ object Wallet2NoBackupNoWalletsMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), @@ -193,7 +193,7 @@ object Wallet2NoBackupNoWalletsMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithSeedPhraseMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithSeedPhraseMockContent.kt index d0625bd95a..b9728135ba 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithSeedPhraseMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithSeedPhraseMockContent.kt @@ -229,7 +229,7 @@ object Wallet2WithSeedPhraseMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), @@ -281,7 +281,7 @@ object Wallet2WithSeedPhraseMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), 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 23afd7ef06..d6dd533439 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 @@ -110,7 +110,7 @@ object WalletMockContent : MockContent { chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( - 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, -67), + 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), ), DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( @@ -125,10 +125,18 @@ 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/1852'/1815'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + ), DerivationPath("m/44'/144'/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'/501'/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), @@ -194,7 +202,7 @@ object WalletMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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), @@ -235,6 +243,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/148'/0'") to ExtendedPublicKey( // XLM + 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( @@ -244,14 +259,21 @@ object WalletMockContent : MockContent { ExtendedPublicKeysMap( mapOf( DerivationPath("m/1852'/1815'/0'/0/0") to ExtendedPublicKey( // cardano - 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, -67), + 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/1852'/1815'/0'/2/0") to ExtendedPublicKey( // cardano extended - 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, -67), + 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'/501'/0'") to ExtendedPublicKey( // Solana + 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), @@ -290,14 +312,14 @@ object WalletMockContent : MockContent { childNumber = 0, ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth - 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, -67), + 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'/60'/0'/0/0") to ExtendedPublicKey( // xrp - 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, -67), + 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), @@ -312,19 +334,26 @@ object WalletMockContent : MockContent { ExtendedPublicKeysMap( mapOf( DerivationPath("m/1852'/1815'/0'/0/0") to ExtendedPublicKey( // cardano - 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, -67), + 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/1852'/1815'/0'/2/0") to ExtendedPublicKey( // cardano extended - 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, -67), + 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'/501'/0'") to ExtendedPublicKey( // Solana + 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, + ), ), ), ), diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt index 6a0cca84f4..edb7c0365c 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt @@ -6,12 +6,11 @@ import arrow.core.raise.either import arrow.core.raise.ensure import arrow.core.right import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE -import com.tangem.datasource.api.express.models.response.Asset -import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader import com.tangem.domain.core.lce.Lce import com.tangem.domain.exchange.ExpressAvailabilityState import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet @@ -26,10 +25,9 @@ import com.tangem.utils.isNullOrZero import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull -@Suppress("LongParameterList") internal class DefaultRampManager( private val sellService: Provider, - private val expressServiceLoader: ExpressServiceLoader, + private val expressServiceFetcher: ExpressServiceFetcher, private val currenciesRepository: CurrenciesRepository, private val dispatchers: CoroutineDispatcherProvider, excludedBlockchains: ExcludedBlockchains, @@ -107,7 +105,7 @@ internal class DefaultRampManager( } override fun getExpressInitializationStatus(userWalletId: UserWalletId): Flow { - return expressServiceLoader.getInitializationStatus(userWalletId) + return expressServiceFetcher.getInitializationStatus(userWalletId) } override suspend fun getSendUnavailabilityReason( @@ -151,14 +149,14 @@ internal class DefaultRampManager( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): ExpressAvailabilityState { - val asset = expressServiceLoader.getInitializationStatus(userWalletId).firstOrNull() + val asset = expressServiceFetcher.getInitializationStatus(userWalletId).firstOrNull() ?: return ExpressAvailabilityState.Loading return when (asset) { is Lce.Error -> ExpressAvailabilityState.Error is Lce.Loading -> ExpressAvailabilityState.Loading is Lce.Content -> { - val foundAsset = asset.getOrNull()?.find { cryptoCurrency.findAssetPredicate(it) } - foundAsset?.exchangeAvailable?.toSwapAvailabilityState() + val foundAsset = asset.getOrNull()?.find { cryptoCurrency.findAssetPredicate(assetId = it.id) } + foundAsset?.isExchangeAvailable?.toSwapAvailabilityState() ?: ExpressAvailabilityState.AssetNotFound } } @@ -168,15 +166,15 @@ internal class DefaultRampManager( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): ExpressAvailabilityState { - val asset = expressServiceLoader.getInitializationStatus(userWalletId).firstOrNull() + val asset = expressServiceFetcher.getInitializationStatus(userWalletId).firstOrNull() ?: return ExpressAvailabilityState.Loading return when (asset) { is Lce.Error -> ExpressAvailabilityState.Error is Lce.Loading -> ExpressAvailabilityState.Loading is Lce.Content -> { - val foundAsset = asset.getOrNull()?.find { cryptoCurrency.findAssetPredicate(it) } - foundAsset?.onrampAvailable?.toOnrampAvailabilityState() + val foundAsset = asset.getOrNull()?.find { cryptoCurrency.findAssetPredicate(assetId = it.id) } + foundAsset?.isOnrampAvailable?.toOnrampAvailabilityState() ?: ExpressAvailabilityState.AssetNotFound } } @@ -211,8 +209,13 @@ internal class DefaultRampManager( } } - private fun CryptoCurrency.findAssetPredicate(asset: Asset): Boolean { - val contractAddress = (this as? CryptoCurrency.Token)?.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE - return asset.network == network.backendId && asset.contractAddress.equals(contractAddress, ignoreCase = true) + private fun CryptoCurrency.findAssetPredicate(assetId: ExpressAsset.ID): Boolean { + val currencyAssedId = ExpressAsset.ID( + networkId = this.network.backendId, + contractAddress = (this as? CryptoCurrency.Token)?.contractAddress, + ) + + return assetId.networkId == currencyAssedId.networkId && + assetId.contractAddress.equals(currencyAssedId.contractAddress, ignoreCase = true) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt index be7e116dd6..642cdea9ac 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt @@ -160,6 +160,6 @@ internal val Blockchain.moonPaySupportedCurrency: MoonPaySupportedCurrency? Pepecoin, PepecoinTestnet -> null Hyperliquid, HyperliquidTestnet -> null Quai, QuaiTestnet -> null - // Linea, LineaTestnet -> null - // ArbitrumNova -> null + Linea, LineaTestnet -> null + ArbitrumNova -> null } \ 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 e75766a69a..d3f4ee15cd 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 @@ -36,9 +36,10 @@ import com.tangem.features.send.v2.api.SendComponent import com.tangem.features.send.v2.api.SendEntryPointComponent import com.tangem.features.staking.api.StakingComponent import com.tangem.features.swap.SwapComponent -import com.tangem.features.tangempay.components.TangemPayDetailsComponent +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.components.TangemPayOnboardingComponent -import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.* +import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.ContinueOnboarding +import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.Deeplink import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.wallet.WalletEntryComponent import com.tangem.features.walletconnect.components.WalletConnectEntryComponent @@ -100,6 +101,7 @@ internal class ChildFactory @Inject constructor( private val chooseManagedTokensComponentFactory: ChooseManagedTokensComponent.Factory, private val createWalletSelectionComponentFactory: CreateWalletSelectionComponent.Factory, private val createWalletStartComponentFactory: CreateWalletStartComponent.Factory, + private val createHardwareWalletComponentFactory: CreateHardwareWalletComponent.Factory, private val createMobileWalletComponentFactory: CreateMobileWalletComponent.Factory, private val upgradeWalletComponentFactory: UpgradeWalletComponent.Factory, private val addExistingWalletComponentFactory: AddExistingWalletComponent.Factory, @@ -107,8 +109,9 @@ internal class ChildFactory @Inject constructor( private val createWalletBackupComponentFactory: CreateWalletBackupComponent.Factory, private val updateAccessCodeComponentFactory: UpdateAccessCodeComponent.Factory, private val viewPhraseComponentFactory: ViewPhraseComponent.Factory, + private val forgetWalletComponentFactory: ForgetWalletComponent.Factory, private val sendEntryPointComponentFactory: SendEntryPointComponent.Factory, - private val tangemPayDetailsComponentFactory: TangemPayDetailsComponent.Factory, + private val tangemPayDetailsContainerComponentFactory: TangemPayDetailsContainerComponent.Factory, private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, private val kycComponentFactory: KycComponent.Factory, private val yieldSupplyPromoComponentFactory: YieldSupplyPromoComponent.Factory, @@ -138,7 +141,6 @@ internal class ChildFactory @Inject constructor( is AppRoute.ManageTokens -> { val source = when (route.source) { AppRoute.ManageTokens.Source.SETTINGS -> ManageTokensSource.SETTINGS - AppRoute.ManageTokens.Source.ONBOARDING -> ManageTokensSource.ONBOARDING AppRoute.ManageTokens.Source.STORIES -> ManageTokensSource.STORIES } @@ -293,7 +295,7 @@ internal class ChildFactory @Inject constructor( context = context, params = StakingComponent.Params( userWalletId = route.userWalletId, - cryptoCurrencyId = route.cryptoCurrencyId, + cryptoCurrency = route.cryptoCurrency, yieldId = route.yieldId, ), componentFactory = stakingComponentFactory, @@ -496,6 +498,13 @@ internal class ChildFactory @Inject constructor( componentFactory = createWalletSelectionComponentFactory, ) } + is AppRoute.CreateHardwareWallet -> { + createComponentChild( + context = context, + params = Unit, + componentFactory = createHardwareWalletComponentFactory, + ) + } is AppRoute.CreateMobileWallet -> { createComponentChild( context = context, @@ -555,6 +564,15 @@ internal class ChildFactory @Inject constructor( componentFactory = viewPhraseComponentFactory, ) } + is AppRoute.ForgetWallet -> { + createComponentChild( + context = context, + params = ForgetWalletComponent.Params( + userWalletId = route.userWalletId, + ), + componentFactory = forgetWalletComponentFactory, + ) + } is AppRoute.SendEntryPoint -> { createComponentChild( context = context, @@ -604,8 +622,11 @@ internal class ChildFactory @Inject constructor( is AppRoute.TangemPayDetails -> { createComponentChild( context = context, - params = TangemPayDetailsComponent.Params(config = route.config), - componentFactory = tangemPayDetailsComponentFactory, + params = TangemPayDetailsContainerComponent.Params( + userWalletId = route.userWalletId, + config = route.config, + ), + componentFactory = tangemPayDetailsContainerComponentFactory, ) } is AppRoute.TangemPayOnboarding -> { 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 ea0474e6e9..55e85465d3 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 @@ -126,9 +126,12 @@ sealed class AppRoute(val path: String) : Route { val portfolioId: PortfolioId? = null, ) : AppRoute(path = "${source.name.lowercase()}/manage_tokens/${portfolioId?.stringValue}") { + /** + * Source of launching the screen. + * ManageTokens screen launched from Onboarding by another route. See `OnboardingRoute.ManageTokens`. + */ enum class Source { STORIES, - ONBOARDING, SETTINGS, } } @@ -192,9 +195,9 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class Staking( val userWalletId: UserWalletId, - val cryptoCurrencyId: CryptoCurrency.ID, + val cryptoCurrency: CryptoCurrency, val yieldId: String, - ) : AppRoute(path = "/staking/${userWalletId.stringValue}/${cryptoCurrencyId.value}/$yieldId") + ) : AppRoute(path = "/staking/${userWalletId.stringValue}/${cryptoCurrency.id.value}/$yieldId") @Serializable data class PushNotification( @@ -322,6 +325,9 @@ sealed class AppRoute(val path: String) : Route { } } + @Serializable + object CreateHardwareWallet : AppRoute(path = "/create_hardware_wallet") + @Serializable object CreateMobileWallet : AppRoute(path = "/create_mobile_wallet") @@ -353,6 +359,11 @@ sealed class AppRoute(val path: String) : Route { val userWalletId: UserWalletId, ) : AppRoute(path = "/view_seed_phrase/${userWalletId.stringValue}") + @Serializable + data class ForgetWallet( + val userWalletId: UserWalletId, + ) : AppRoute(path = "/forget_wallet/${userWalletId.stringValue}") + @Serializable data class SendEntryPoint( val userWalletId: UserWalletId, @@ -383,8 +394,9 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class TangemPayDetails( + val userWalletId: UserWalletId, val config: TangemPayDetailsConfig, - ) : AppRoute(path = "/tangem_pay_details") + ) : AppRoute(path = "/tangem_pay_details/${userWalletId.stringValue}") @Serializable data class TangemPayOnboarding( 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 e2b2b60cd9..6baef4fa51 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 @@ -3,6 +3,7 @@ package com.tangem.common.ui.account import com.tangem.common.ui.R import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState +import com.tangem.core.ui.components.token.state.TokenItemState.Subtitle2State import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.fiat @@ -56,8 +57,8 @@ class AccountCryptoPortfolioItemStateConverter( ) } - private fun Account.CryptoPortfolio.mapToLoadingState(): TokenItemState.Loading { - return TokenItemState.Loading( + private fun Account.CryptoPortfolio.mapToLoadingState(): TokenItemState.Content { + return TokenItemState.Content( id = account.accountId.value, iconState = AccountIconItemStateConverter.convert(account), titleState = TokenItemState.TitleState.Content( @@ -71,6 +72,10 @@ class AccountCryptoPortfolioItemStateConverter( ), isAvailable = false, ), + fiatAmountState = FiatAmountState.Loading, + subtitle2State = Subtitle2State.Loading, + onItemLongClick = null, + onItemClick = onItemClick?.let { onItemClick -> { onItemClick(account) } }, ) } diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitle.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitle.kt index e2d1c5b4c1..45862deedb 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitle.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountTitle.kt @@ -6,6 +6,7 @@ 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.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp @@ -29,6 +30,7 @@ fun AccountTitle( accountTitleUM: AccountTitleUM, modifier: Modifier = Modifier, textStyle: TextStyle = TangemTheme.typography.subtitle2, + textColor: Color = TangemTheme.colors.text.tertiary, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -40,19 +42,20 @@ fun AccountTitle( Text( text = accountTitleUM.prefixText.resolveReference(), style = textStyle, - color = TangemTheme.colors.text.tertiary, + color = textColor, ) AccountLabel( name = accountTitleUM.name, icon = accountTitleUM.icon, iconSize = AccountIconSize.ExtraSmall, nameStyle = textStyle, + nameColor = textColor, ) } is AccountTitleUM.Text -> Text( text = accountTitleUM.title.resolveReference(), style = textStyle, - color = TangemTheme.colors.text.tertiary, + color = textColor, modifier = Modifier.testTag(SendScreenTestTags.AMOUNT_CONTAINER_TITLE), ) } diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/utils/AnalyticsContextProxy.kt b/core/analytics/src/main/java/com/tangem/core/analytics/utils/AnalyticsContextProxy.kt index cab4da6a7b..5ea57db8ad 100644 --- a/core/analytics/src/main/java/com/tangem/core/analytics/utils/AnalyticsContextProxy.kt +++ b/core/analytics/src/main/java/com/tangem/core/analytics/utils/AnalyticsContextProxy.kt @@ -1,6 +1,7 @@ package com.tangem.core.analytics.utils import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet /** [REDACTED_AUTHOR] @@ -9,9 +10,15 @@ interface AnalyticsContextProxy { fun setContext(scanResponse: ScanResponse) + fun addContext(userWallet: UserWallet) + + fun setHotWalletContext() + fun eraseContext() fun addContext(scanResponse: ScanResponse) + fun addHotWalletContext() + fun removeContext() } \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json b/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json index 5e4b650948..8b0cb7f15f 100644 --- a/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json +++ b/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json @@ -30,9 +30,5 @@ { "name": "zklink", "version": "undefined" - }, - { - "name": "scroll", - "version": "undefined" } ] \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/TangemExpressValues.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/TangemExpressValues.kt deleted file mode 100644 index 984184791c..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/TangemExpressValues.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.datasource.api.express.models - -object TangemExpressValues { - const val EMPTY_CONTRACT_ADDRESS_VALUE = "0" -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt index ac0ce437eb..9ce3c0d9e0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt @@ -20,4 +20,18 @@ data class GetWalletAccountsResponse( @Json(name = "sort") val sort: SortType, @Json(name = "totalAccounts") val totalAccounts: Int, ) +} + +/** Flattens the tokens from all wallet accounts into a single list */ +fun GetWalletAccountsResponse.flattenTokens(): List { + return accounts.flatMap { it.tokens.orEmpty() } +} + +/** Converts the [GetWalletAccountsResponse] into a [UserTokensResponse] */ +fun GetWalletAccountsResponse.toUserTokensResponse(): UserTokensResponse { + return UserTokensResponse( + group = wallet.group, + sort = wallet.sort, + tokens = flattenTokens(), + ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/exchangeservice/ExchangeServiceLoaderModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/exchangeservice/ExchangeServiceLoaderModule.kt deleted file mode 100644 index d9ae44675f..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/di/exchangeservice/ExchangeServiceLoaderModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.datasource.di.exchangeservice - -import com.tangem.datasource.exchangeservice.swap.DefaultExpressServiceLoader -import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader -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 ExchangeServiceLoaderModule { - - @Binds - @Singleton - fun bindExpressServiceLoader(defaultExpressServiceLoader: DefaultExpressServiceLoader): ExpressServiceLoader -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressServiceLoader.kt b/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressServiceLoader.kt deleted file mode 100644 index e591ee227e..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressServiceLoader.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.datasource.exchangeservice.swap - -import com.tangem.datasource.api.express.models.request.LeastTokenInfo -import com.tangem.datasource.api.express.models.response.Asset -import com.tangem.domain.core.lce.Lce -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import kotlinx.coroutines.flow.Flow - -/** - * Express service loader - * -[REDACTED_AUTHOR] - */ -interface ExpressServiceLoader { - - /** Update service using [userWallet] and [userTokens] */ - suspend fun update(userWallet: UserWallet, userTokens: List) - - /** Get initialization status by [userWalletId] */ - fun getInitializationStatus(userWalletId: UserWalletId): Flow>> -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/feature/FeatureBlock.kt b/core/ui/src/main/java/com/tangem/core/ui/components/feature/FeatureBlock.kt new file mode 100644 index 0000000000..b85ad0dd6e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/feature/FeatureBlock.kt @@ -0,0 +1,79 @@ +package com.tangem.core.ui.components.feature + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +fun FeatureBlock(title: String, description: String, iconRes: Int, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + ) { + Icon( + modifier = Modifier + .padding(horizontal = 12.dp), + painter = painterResource(iconRes), + contentDescription = null, + tint = TangemTheme.colors.icon.primary1, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) { + Text( + text = title, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier + .padding(top = 4.dp), + text = description, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewFeatureBlock() { + TangemThemePreview { + Column( + modifier = Modifier + .background(TangemTheme.colors.background.primary) + .padding(16.dp), + ) { + FeatureBlock( + title = stringResourceSafe(R.string.backup_info_save_title), + description = stringResourceSafe(R.string.backup_info_save_description, "12"), + iconRes = R.drawable.ic_lock_24, + ) + Spacer(modifier = Modifier.height(24.dp)) + FeatureBlock( + title = stringResourceSafe(R.string.backup_info_keep_title), + description = stringResourceSafe(R.string.backup_info_keep_description), + iconRes = R.drawable.ic_settings_24, + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/InputRowAsyncImage.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/InputRowAsyncImage.kt index 3b46eeffc5..ad44860661 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/InputRowAsyncImage.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/InputRowAsyncImage.kt @@ -21,7 +21,7 @@ import com.tangem.core.ui.utils.getGreyScaleColorFilter * @param onImageError composable to show if image loading failed */ @Composable -internal fun InputRowAsyncImage( +fun InputRowAsyncImage( imageUrl: String, modifier: Modifier = Modifier, isGrayscale: Boolean = false, 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 be5201fa5c..a704b3fbbe 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 @@ -53,7 +53,7 @@ fun SelectorRowItem( val textStyle = if (isSelected && showSelectedAppearance) { TangemTheme.typography.subtitle2 } else { - TangemTheme.typography.body2 + TangemTheme.typography.body1 } Box( modifier = modifier 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 new file mode 100644 index 0000000000..8add413e59 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt @@ -0,0 +1,33 @@ +package com.tangem.core.ui.extensions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +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. + * + * @property value color provider from theme + */ +@Immutable +data class ColorReference(val value: @Composable () -> Color) + +/** + * Creates a [ColorReference] using a themed color from the app theme with a lambda. + * + * @param value The color provider from theme. + * @return A [ColorReference] representing the themed color. + */ +fun themedColor(value: @Composable () -> Color): ColorReference { + return ColorReference(value) +} + +/** + * Resolves [ColorReference] to [Color] + */ +@Composable +fun ColorReference.resolveReference(): Color { + return value() +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/BaseAmountBlockTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/BaseAmountBlockTestTags.kt index f2f436d707..bd9e81cbcf 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/BaseAmountBlockTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/BaseAmountBlockTestTags.kt @@ -1,6 +1,6 @@ package com.tangem.core.ui.test object BaseAmountBlockTestTags { - const val PRIMARY_AMOUNT = "STAKING_SEND_DETAILS_SCREEN_PRIMARY_AMOUNT" - const val SECONDARY_AMOUNT = "TAKING_SEND_DETAILS_SCREEN_SECONDARY_AMOUNT" + const val PRIMARY_AMOUNT = "SEND_DETAILS_SCREEN_PRIMARY_AMOUNT" + const val SECONDARY_AMOUNT = "SEND_DETAILS_SCREEN_SECONDARY_AMOUNT" } \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_attention_72.xml b/core/ui/src/main/res/drawable/ic_attention_72.xml new file mode 100644 index 0000000000..e8a42b0b46 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_attention_72.xml @@ -0,0 +1,15 @@ + + + + diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index a818b24ccb..3123fadcd8 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { api(projects.domain.common) api(projects.domain.models) api(projects.domain.tokens) + api(projects.domain.wallets) // endregion // region Project - Data diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/SingleAccountProducerFactoryModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/SingleAccountProducerFactoryModule.kt new file mode 100644 index 0000000000..b0b7ac0370 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/SingleAccountProducerFactoryModule.kt @@ -0,0 +1,18 @@ +package com.tangem.data.account.di + +import com.tangem.data.account.producer.DefaultSingleAccountProducer +import com.tangem.domain.account.producer.SingleAccountProducer +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 SingleAccountProducerFactoryModule { + + @Binds + @Singleton + fun bindSingleAccountProducerFactory(impl: DefaultSingleAccountProducer.Factory): SingleAccountProducer.Factory +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/SingleAccountSupplierModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/SingleAccountSupplierModule.kt new file mode 100644 index 0000000000..69219174f3 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/SingleAccountSupplierModule.kt @@ -0,0 +1,23 @@ +package com.tangem.data.account.di + +import com.tangem.domain.account.producer.SingleAccountProducer +import com.tangem.domain.account.supplier.SingleAccountSupplier +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 SingleAccountSupplierModule { + + @Provides + @Singleton + fun provideSingleAccountSupplier(factory: SingleAccountProducer.Factory): SingleAccountSupplier { + return object : SingleAccountSupplier( + factory = factory, + keyCreator = { "single_account_${it.accountId.value}" }, + ) {} + } +} \ No newline at end of file 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 200e133dbd..fdc78b4309 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 @@ -4,7 +4,6 @@ import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.account.utils.assignTokens -import com.tangem.data.account.utils.toUserTokensResponse import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.data.common.account.WalletAccountsSaver import com.tangem.data.common.api.safeApiCall @@ -19,6 +18,7 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.datasource.utils.getSyncOrNull import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -51,18 +51,25 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, ) : WalletAccountsFetcher, WalletAccountsSaver { - override suspend fun fetch(userWalletId: UserWalletId) { + override suspend fun fetch(userWalletId: UserWalletId): GetWalletAccountsResponse { val savedAccountsResponse = getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull() val accountsResponse = fetchWalletAccounts(userWalletId, savedAccountsResponse) - ?: return - if (accountsResponse.accounts.isEmpty()) { - initializeAccounts(userWalletId, accountsResponse) - } else if (accountsResponse.unassignedTokens.isNotEmpty()) { - assignTokens(userWalletId, accountsResponse) + return when { + accountsResponse.accounts.isEmpty() -> { + initializeAccounts(userWalletId, accountsResponse) + } + accountsResponse.unassignedTokens.isNotEmpty() -> { + assignTokens(userWalletId, accountsResponse) + } + else -> accountsResponse } } + override suspend fun getSaved(userWalletId: UserWalletId): GetWalletAccountsResponse? { + return getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull() + } + override suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) { val store = getAccountsResponseStore(userWalletId = userWalletId) @@ -115,7 +122,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( private suspend fun fetchWalletAccounts( userWalletId: UserWalletId, savedAccountsResponse: GetWalletAccountsResponse?, - ): GetWalletAccountsResponse? { + ): GetWalletAccountsResponse { return safeApiCall( call = { val apiResponse = withContext(dispatchers.io) { @@ -145,7 +152,10 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( ) } - private suspend fun initializeAccounts(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) { + private suspend fun initializeAccounts( + userWalletId: UserWalletId, + accountsResponse: GetWalletAccountsResponse, + ): GetWalletAccountsResponse { val response = defaultWalletAccountsResponseFactory.create( userWalletId = userWalletId, userTokensResponse = UserTokensResponse( @@ -156,14 +166,17 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( ) userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse()) - val syncedResponse = push(userWalletId = userWalletId, accounts = response.accounts) + val syncedResponse = push(userWalletId = userWalletId, accounts = response.accounts) ?: response - if (syncedResponse != null) { - store(userWalletId = userWalletId, response = syncedResponse) - } + store(userWalletId = userWalletId, response = syncedResponse) + + return syncedResponse } - private suspend fun assignTokens(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) { + private suspend fun assignTokens( + userWalletId: UserWalletId, + accountsResponse: GetWalletAccountsResponse, + ): GetWalletAccountsResponse { val accountsResponseWithTokens = accountsResponse.assignTokens(userWalletId) store(userWalletId = userWalletId, response = accountsResponseWithTokens) @@ -172,6 +185,8 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( userWalletId = userWalletId, response = accountsResponseWithTokens.toUserTokensResponse(), ) + + return accountsResponseWithTokens } private suspend fun getETag(userWalletId: UserWalletId): String? { diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt index 0d9ea8c585..2470bbfdfa 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -1,7 +1,6 @@ package com.tangem.data.account.fetcher import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory -import com.tangem.data.account.utils.toUserTokensResponse import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.common.response.ApiResponseError @@ -10,6 +9,7 @@ import com.tangem.datasource.api.common.response.isNetworkError import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.models.wallet.UserWalletId import timber.log.Timber @@ -49,11 +49,13 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( savedAccountsResponse: GetWalletAccountsResponse?, pushWalletAccounts: suspend (UserWalletId, List) -> GetWalletAccountsResponse?, storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit, - ): GetWalletAccountsResponse? { + ): GetWalletAccountsResponse { val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED) if (isResponseUpToDate) { Timber.e("ETag is up to date, no need to update accounts for wallet: $userWalletId") - return savedAccountsResponse + return requireNotNull(savedAccountsResponse) { + "Saved accounts response is null for wallet: $userWalletId" + } } var response = savedAccountsResponse ?: createDefaultResponse(userWalletId) diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/AccountListCryptoCurrenciesProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/AccountListCryptoCurrenciesProducer.kt index 7217e10a20..c7b6d3247f 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/AccountListCryptoCurrenciesProducer.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/AccountListCryptoCurrenciesProducer.kt @@ -3,8 +3,8 @@ package com.tangem.data.account.producer import arrow.core.Option import arrow.core.some import com.tangem.data.account.store.AccountsResponseStoreFactory -import com.tangem.data.account.utils.toUserTokensResponse import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.isMultiCurrency @@ -54,6 +54,7 @@ internal class AccountListCryptoCurrenciesProducer @AssistedInject constructor( ).toSet() } .onEmpty { emit(emptySet()) } + .distinctUntilChanged() .flowOn(dispatchers.default) } diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountProducer.kt new file mode 100644 index 0000000000..649d2c4b0a --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountProducer.kt @@ -0,0 +1,55 @@ +package com.tangem.data.account.producer + +import arrow.core.Option +import arrow.core.none +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.producer.SingleAccountProducer +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.models.account.Account +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull + +/** + * Default implementation of [SingleAccountProducer] that produces a flow of [Account.CryptoPortfolio] + * for a single account identified by [SingleAccountProducer.Params.accountId]. + * + * It uses [SingleAccountListSupplier] to get the list of accounts and filters it to find the + * specific account. + * + * @property params Parameters containing the account ID for which the portfolio is produced. + * @property singleAccountListSupplier Supplier to get the list of accounts. + * @property dispatchers Coroutine dispatcher provider for managing threading. + */ +internal class DefaultSingleAccountProducer @AssistedInject constructor( + @Assisted val params: SingleAccountProducer.Params, + private val singleAccountListSupplier: SingleAccountListSupplier, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleAccountProducer { + + override val fallback: Option + get() = none() + + override fun produce(): Flow { + return singleAccountListSupplier( + params = SingleAccountListProducer.Params(userWalletId = params.accountId.userWalletId), + ) + .mapNotNull { accountList -> + accountList.accounts.firstOrNull { + it is Account.CryptoPortfolio && params.accountId == it.accountId + } as? Account.CryptoPortfolio + } + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : SingleAccountProducer.Factory { + override fun create(params: SingleAccountProducer.Params): DefaultSingleAccountProducer + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt index 5cbc0ba8e0..b9962e4deb 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt @@ -9,13 +9,13 @@ import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.store.ArchivedAccountsStore import com.tangem.data.account.store.ArchivedAccountsStoreFactory -import com.tangem.data.account.utils.toUserTokensResponse import com.tangem.data.common.account.WalletAccountsSaver import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.datasource.utils.getSyncOrNull import com.tangem.domain.account.models.AccountList 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 90a1de9362..776578b0b2 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 @@ -9,11 +9,11 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.utils.assignTokens -import com.tangem.data.account.utils.toUserTokensResponse import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.datasource.utils.getSyncOrNull import com.tangem.domain.account.tokens.MainAccountTokensMigration import com.tangem.domain.models.account.DerivationIndex diff --git a/data/account/src/main/kotlin/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactory.kt index 8dad68c1de..ad87b1f3e3 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactory.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactory.kt @@ -1,14 +1,16 @@ package com.tangem.data.account.utils import com.tangem.data.account.converter.CryptoPortfolioConverter -import com.tangem.data.common.currency.CardCryptoCurrencyFactory import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.data.common.network.NetworkFactory import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.domain.account.models.AccountList import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import javax.inject.Inject @@ -19,7 +21,7 @@ import javax.inject.Inject * @property userWalletsListRepository repository to get user wallet information * @property cryptoPortfolioCF converter factory to convert crypto portfolio accounts * @property userTokensResponseFactory factory to create [UserTokensResponse] - * @property cardCryptoCurrencyFactory factory to get default coins for multi-currency wallet + * @property networkFactory factory to create network derivation path * [REDACTED_AUTHOR] */ @@ -27,7 +29,7 @@ internal class DefaultWalletAccountsResponseFactory @Inject constructor( private val userWalletsListRepository: UserWalletsListRepository, private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, private val userTokensResponseFactory: UserTokensResponseFactory, - private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, + private val networkFactory: NetworkFactory, ) { suspend fun create(userWalletId: UserWalletId, userTokensResponse: UserTokensResponse?): GetWalletAccountsResponse { @@ -59,10 +61,12 @@ internal class DefaultWalletAccountsResponseFactory @Inject constructor( private fun UserTokensResponse?.orDefault(userWallet: UserWallet?): UserTokensResponse { if (this != null) return this - return userTokensResponseFactory.createUserTokensResponse( - currencies = userWallet?.let(cardCryptoCurrencyFactory::createDefaultCoinsForMultiCurrencyWallet).orEmpty(), - isGroupedByNetwork = false, - isSortedByBalance = false, + return userTokensResponseFactory.createDefaultResponse( + userWallet = userWallet, + networkFactory = networkFactory, + accountId = userWallet?.let { + AccountId.forCryptoPortfolio(userWalletId = it.walletId, derivationIndex = DerivationIndex.Main) + }, ) } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt b/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt index ee6c308a81..1111b2963f 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt @@ -6,20 +6,6 @@ import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResp import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.domain.models.wallet.UserWalletId -/** Flattens the tokens from all wallet accounts into a single list */ -internal fun GetWalletAccountsResponse.flattenTokens(): List { - return accounts.flatMap { it.tokens.orEmpty() } -} - -/** Converts the [GetWalletAccountsResponse] into a [UserTokensResponse] */ -internal fun GetWalletAccountsResponse.toUserTokensResponse(): UserTokensResponse { - return UserTokensResponse( - group = wallet.group, - sort = wallet.sort, - tokens = flattenTokens(), - ) -} - /** * Assigns tokens from a [UserTokensResponse] to the wallet accounts in the [GetWalletAccountsResponse] * diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultSingleAccountListFetcherTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultSingleAccountListFetcherTest.kt index a7d7628c9f..0211b1753b 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultSingleAccountListFetcherTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultSingleAccountListFetcherTest.kt @@ -29,6 +29,8 @@ class DefaultSingleAccountListFetcherTest { // Arrange val params = SingleAccountListFetcher.Params(userWalletId = userWalletId) + coEvery { walletAccountsFetcher.fetch(userWalletId) } returns mockk() + // Act val actual = fetcher.invoke(params) 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 55be8a64db..e3ffe9e2a0 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 @@ -199,7 +199,7 @@ class DefaultWalletAccountsFetcherTest { @Test fun `fetch should call error handler when getWalletAccounts returns error`() = runTest { // Arrange - val savedAccountsResponse = null + val savedAccountsResponse = createGetWalletAccountsResponse(userWalletId) val apiError = ApiResponse.Error(ApiResponseError.NetworkException()) accountsResponseStoreFlow.value = savedAccountsResponse @@ -212,7 +212,7 @@ class DefaultWalletAccountsFetcherTest { fetchWalletAccountsErrorHandler.handle( error = apiError.cause, userWalletId = userWalletId, - savedAccountsResponse = null, + savedAccountsResponse = savedAccountsResponse, pushWalletAccounts = any(), storeWalletAccounts = any(), ) @@ -231,7 +231,7 @@ class DefaultWalletAccountsFetcherTest { fetchWalletAccountsErrorHandler.handle( error = apiError.cause, userWalletId = userWalletId, - savedAccountsResponse = null, + savedAccountsResponse = savedAccountsResponse, pushWalletAccounts = any(), storeWalletAccounts = any(), ) diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt index 6500556de8..e8b46ce20c 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt @@ -3,13 +3,13 @@ package com.tangem.data.account.fetcher import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.converter.createWalletAccountDTO import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory -import com.tangem.data.account.utils.toUserTokensResponse import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.models.wallet.UserWalletId import io.mockk.clearMocks @@ -53,6 +53,8 @@ class FetchWalletAccountsErrorHandlerTest { @Test fun `does not update accounts when response is up to date`() = runTest { // Arrange + val response = createGetWalletAccountsResponse(userWalletId) + val error = ApiResponseError.HttpException( code = Code.NOT_MODIFIED, message = "Not Modified", @@ -63,7 +65,7 @@ class FetchWalletAccountsErrorHandlerTest { handler.handle( error = error, userWalletId = userWalletId, - savedAccountsResponse = null, + savedAccountsResponse = response, pushWalletAccounts = pushWalletAccounts, storeWalletAccounts = storeWalletAccounts, ) 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 37c3d84862..b59fd6884d 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 @@ -7,10 +7,10 @@ import com.tangem.data.account.converter.createWalletAccountDTO 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.toUserTokensResponse import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId diff --git a/data/account/src/test/java/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactoryTest.kt index e270dacc98..3a7d6800b1 100644 --- a/data/account/src/test/java/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactoryTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactoryTest.kt @@ -3,8 +3,8 @@ package com.tangem.data.account.utils import com.google.common.truth.Truth import com.tangem.data.account.converter.CryptoPortfolioConverter import com.tangem.data.account.converter.createWalletAccountDTO -import com.tangem.data.common.currency.CardCryptoCurrencyFactory import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.data.common.network.NetworkFactory import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.domain.account.models.AccountList @@ -27,13 +27,13 @@ class DefaultWalletAccountsResponseFactoryTest { private val cryptoPortfolioCF = mockk() private val cryptoPortfolioConverter = mockk() private val userTokensResponseFactory = mockk() - private val cardCryptoCurrencyFactory = mockk() + private val networkFactory = mockk() private val factory = DefaultWalletAccountsResponseFactory( userWalletsListRepository = userWalletsListRepository, cryptoPortfolioCF = cryptoPortfolioCF, userTokensResponseFactory = userTokensResponseFactory, - cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + networkFactory = networkFactory, ) private val userWalletId = UserWalletId("011") @@ -50,7 +50,7 @@ class DefaultWalletAccountsResponseFactoryTest { cryptoPortfolioCF, cryptoPortfolioConverter, userTokensResponseFactory, - cardCryptoCurrencyFactory, + networkFactory, ) } @@ -65,10 +65,10 @@ class DefaultWalletAccountsResponseFactoryTest { coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList() every { - userTokensResponseFactory.createUserTokensResponse( - currencies = emptyList(), - isGroupedByNetwork = false, - isSortedByBalance = false, + userTokensResponseFactory.createDefaultResponse( + userWallet = null, + networkFactory = networkFactory, + accountId = null, ) } returns userTokensResponse @@ -89,10 +89,10 @@ class DefaultWalletAccountsResponseFactoryTest { coVerifyOrder { userWalletsListRepository.userWalletsSync() - userTokensResponseFactory.createUserTokensResponse( - currencies = emptyList(), - isGroupedByNetwork = false, - isSortedByBalance = false, + userTokensResponseFactory.createDefaultResponse( + userWallet = null, + networkFactory = networkFactory, + accountId = null, ) } } @@ -104,27 +104,24 @@ class DefaultWalletAccountsResponseFactoryTest { every { walletId } returns userWalletId } - val defaultCoins = listOf(mockk()) + val accounts = AccountList.empty(userWallet.walletId).accounts + .filterIsInstance() + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) - every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns defaultCoins val defaultResponse = UserTokensResponse( group = UserTokensResponse.GroupType.NETWORK, sort = UserTokensResponse.SortType.BALANCE, tokens = listOf(mockk(relaxed = true)), ) - every { - userTokensResponseFactory.createUserTokensResponse( - currencies = defaultCoins, - isGroupedByNetwork = false, - isSortedByBalance = false, + userTokensResponseFactory.createDefaultResponse( + userWallet = userWallet, + networkFactory = networkFactory, + accountId = accounts.first().accountId, ) } returns defaultResponse - val accounts = AccountList.empty(userWallet.walletId).accounts - .filterIsInstance() - val accountsDTO = createWalletAccountDTO(userWalletId) every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO) @@ -147,11 +144,10 @@ class DefaultWalletAccountsResponseFactoryTest { coVerifyOrder { userWalletsListRepository.userWalletsSync() cryptoPortfolioConverter.convertListBack(accounts) - cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) - userTokensResponseFactory.createUserTokensResponse( - currencies = defaultCoins, - isGroupedByNetwork = false, - isSortedByBalance = false, + userTokensResponseFactory.createDefaultResponse( + userWallet = userWallet, + networkFactory = networkFactory, + accountId = accounts.first().accountId, ) } } @@ -162,22 +158,26 @@ class DefaultWalletAccountsResponseFactoryTest { val userWallet = mockk(relaxed = true) { every { walletId } returns userWalletId } + + val accounts = AccountList.empty(userWallet.walletId).accounts + .filterIsInstance() + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) - every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList() + val defaultResponse = UserTokensResponse( group = UserTokensResponse.GroupType.NETWORK, sort = UserTokensResponse.SortType.BALANCE, tokens = emptyList(), ) + every { - userTokensResponseFactory.createUserTokensResponse( - currencies = emptyList(), - isGroupedByNetwork = false, - isSortedByBalance = false, + userTokensResponseFactory.createDefaultResponse( + userWallet = userWallet, + networkFactory = networkFactory, + accountId = accounts.first().accountId, ) } returns defaultResponse - val accounts = AccountList.empty(userWallet.walletId).accounts - .filterIsInstance() + every { cryptoPortfolioConverter.convertListBack(accounts) } returns emptyList() // Act diff --git a/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt b/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt index 8f9089305d..94080c8fa3 100644 --- a/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt @@ -4,6 +4,8 @@ import com.google.common.truth.Truth import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.api.tangemTech.models.account.flattenTokens +import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId diff --git a/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsFetcher.kt b/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsFetcher.kt index ba81c5db5a..4004ed3fcf 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsFetcher.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsFetcher.kt @@ -1,5 +1,6 @@ package com.tangem.data.common.account +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.domain.models.wallet.UserWalletId /** @@ -11,5 +12,8 @@ interface WalletAccountsFetcher { /** Fetch wallet accounts by [userWalletId] */ @Throws - suspend fun fetch(userWalletId: UserWalletId) + suspend fun fetch(userWalletId: UserWalletId): GetWalletAccountsResponse + + /** Get saved wallet accounts by [userWalletId] */ + suspend fun getSaved(userWalletId: UserWalletId): GetWalletAccountsResponse? } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt index f0e538b4f9..0fbe2d013f 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt @@ -1,12 +1,15 @@ package com.tangem.data.common.currency -import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.data.common.account.WalletAccountsFetcher +import com.tangem.data.common.tokens.getDefaultWalletBlockchains +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.domain.card.common.TapWorkarounds.isTestCard +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.currency.CryptoCurrency @@ -23,10 +26,13 @@ import com.tangem.domain.models.wallet.isMultiCurrency * @property userWalletsStore user wallets store * @property userTokensResponseStore user tokens response store */ +@Suppress("LongParameterList") internal class DefaultCardCryptoCurrencyFactory( private val demoConfig: DemoConfig, private val excludedBlockchains: ExcludedBlockchains, private val userWalletsStore: UserWalletsStore, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val walletAccountsFetcher: WalletAccountsFetcher, private val userTokensResponseStore: UserTokensResponseStore, private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, ) : CardCryptoCurrencyFactory { @@ -99,25 +105,7 @@ internal class DefaultCardCryptoCurrencyFactory( override fun createDefaultCoinsForMultiCurrencyWallet(userWallet: UserWallet): List { require(userWallet.isMultiCurrency) { "It isn't multi-currency wallet" } - val blockchains = when (userWallet) { - is UserWallet.Cold -> { - val card = userWallet.scanResponse.card - - var blockchainsInternal = if (demoConfig.isDemoCardId(card.cardId)) { - demoConfig.demoBlockchains - } else { - listOf(Blockchain.Bitcoin, Blockchain.Ethereum) - } - - if (card.isTestCard) { - blockchainsInternal = blockchainsInternal.mapNotNull { it.getTestnetVersion() } - } - - blockchainsInternal - } - - is UserWallet.Hot -> listOf(Blockchain.Bitcoin, Blockchain.Ethereum) - } + val blockchains = getDefaultWalletBlockchains(userWallet, demoConfig) return blockchains.mapNotNull { cryptoCurrencyFactory.createCoin( @@ -152,7 +140,7 @@ internal class DefaultCardCryptoCurrencyFactory( userWallet: UserWallet, networks: Set, ): Map> { - val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId) + val response = getUserTokensResponse(userWalletId = userWallet.walletId) ?: return emptyMap() val existingNetworkWithCurrencies = responseCryptoCurrenciesFactory.createCurrencies( @@ -170,7 +158,7 @@ internal class DefaultCardCryptoCurrencyFactory( userWallet: UserWallet, rawIds: Set, ): Map> { - val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId) + val response = getUserTokensResponse(userWalletId = userWallet.walletId) ?: return emptyMap() val networkIds = rawIds.map { it.toBlockchain().toNetworkId() } @@ -182,6 +170,14 @@ internal class DefaultCardCryptoCurrencyFactory( .groupBy { it.network.id.rawId } } + private suspend fun getUserTokensResponse(userWalletId: UserWalletId): UserTokensResponse? { + return if (accountsFeatureToggles.isFeatureEnabled) { + walletAccountsFetcher.getSaved(userWalletId)?.toUserTokensResponse() + } else { + userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId) + } + } + private fun getSingleWalletCurrencies(userWallet: UserWallet.Cold): SingleWalletCurrencies { val resolver = userWallet.cardTypesResolver val blockchain = resolver.getBlockchain() diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt index c36aa9fdbf..41f66c5b28 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt @@ -1,8 +1,15 @@ package com.tangem.data.common.currency +import com.tangem.blockchainsdk.utils.toCoinId +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.common.tokens.getDefaultWalletBlockchains import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.derivations.derivationStyleProvider import javax.inject.Inject // TODO: [REDACTED_JIRA] @@ -43,4 +50,38 @@ class UserTokensResponseFactory @Inject constructor() { ) } } + + fun createDefaultResponse( + userWallet: UserWallet?, + networkFactory: NetworkFactory, + accountId: AccountId? = null, + ): UserTokensResponse { + val tokens = userWallet?.let { + getDefaultWalletBlockchains(userWallet = it, demoConfig = DemoConfig()) + .map { blockchain -> + val derivationPath = networkFactory.createDerivationPath( + blockchain = blockchain, + extraDerivationPath = null, + cardDerivationStyleProvider = userWallet.derivationStyleProvider, + ).value + + UserTokensResponse.Token( + id = blockchain.toCoinId(), + accountId = accountId?.value, + networkId = blockchain.toNetworkId(), + derivationPath = derivationPath, + name = blockchain.getCoinName(), + symbol = blockchain.currency, + decimals = blockchain.decimals(), + contractAddress = null, + ) + } + } + + return UserTokensResponse( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + tokens = tokens.orEmpty(), + ) + } } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt index 79af422911..ed6a6d9545 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt @@ -1,6 +1,7 @@ package com.tangem.data.common.di import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.data.common.cache.etag.DefaultETagsStore import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.* @@ -30,6 +31,8 @@ internal object DataCommonModule { fun provideCardCryptoCurrencyFactory( excludedBlockchains: ExcludedBlockchains, userWalletsStore: UserWalletsStore, + accountsFeatureToggles: AccountsFeatureToggles, + walletAccountsFetcher: WalletAccountsFetcher, userTokensResponseStore: UserTokensResponseStore, responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, ): CardCryptoCurrencyFactory { @@ -37,6 +40,8 @@ internal object DataCommonModule { demoConfig = DemoConfig(), excludedBlockchains = excludedBlockchains, userWalletsStore = userWalletsStore, + accountsFeatureToggles = accountsFeatureToggles, + walletAccountsFetcher = walletAccountsFetcher, userTokensResponseStore = userTokensResponseStore, responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, ) diff --git a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt index 1ec0f21c4f..6605a39757 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt @@ -7,10 +7,10 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.domain.card.common.extensions.canHandleToken -import com.tangem.domain.wallets.derivations.derivationStyleProvider import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.derivations.DerivationStyleProvider +import com.tangem.domain.wallets.derivations.derivationStyleProvider import timber.log.Timber import javax.inject.Inject @@ -130,7 +130,7 @@ class NetworkFactory @Inject constructor( return true } - private fun createDerivationPath( + fun createDerivationPath( blockchain: Blockchain, extraDerivationPath: String?, cardDerivationStyleProvider: DerivationStyleProvider?, @@ -326,8 +326,8 @@ class NetworkFactory @Inject constructor( Blockchain.Pepecoin, Blockchain.PepecoinTestnet, Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet, Blockchain.Quai, Blockchain.QuaiTestnet, - // Blockchain.Linea, Blockchain.LineaTestnet, - // Blockchain.ArbitrumNova, + Blockchain.Linea, Blockchain.LineaTestnet, + Blockchain.ArbitrumNova, -> Network.TransactionExtrasType.NONE // endregion } diff --git a/data/common/src/main/kotlin/com/tangem/data/common/tokens/DefaultWalletBlockchains.kt b/data/common/src/main/kotlin/com/tangem/data/common/tokens/DefaultWalletBlockchains.kt new file mode 100644 index 0000000000..9815907b64 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/tokens/DefaultWalletBlockchains.kt @@ -0,0 +1,33 @@ +package com.tangem.data.common.tokens + +import com.tangem.blockchain.common.Blockchain +import com.tangem.domain.card.common.TapWorkarounds.isTestCard +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.wallet.UserWallet + +/** + * Returns the default blockchains for the multi-currency wallet. + * + * @param userWallet The user's wallet, which can be either a cold or hot wallet. + * @param demoConfig Configuration for demo cards, which may specify different default blockchains. + */ +fun getDefaultWalletBlockchains(userWallet: UserWallet, demoConfig: DemoConfig): Collection { + return when (userWallet) { + is UserWallet.Cold -> { + val card = userWallet.scanResponse.card + + var blockchainsInternal = if (demoConfig.isDemoCardId(card.cardId)) { + demoConfig.demoBlockchains + } else { + listOf(Blockchain.Bitcoin, Blockchain.Ethereum) + } + + if (card.isTestCard) { + blockchainsInternal = blockchainsInternal.mapNotNull { it.getTestnetVersion() } + } + + blockchainsInternal + } + is UserWallet.Hot -> listOf(Blockchain.Bitcoin, Blockchain.Ethereum) + } +} \ No newline at end of file diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt index ceb7c2fc1c..9c65d12bd9 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt @@ -9,10 +9,12 @@ import com.tangem.common.test.domain.card.MockScanResponseFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.common.test.domain.wallet.MockUserWalletFactory import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.data.common.network.NetworkFactory import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.configs.GenericCardConfig import com.tangem.domain.demo.models.DemoConfig @@ -37,6 +39,8 @@ internal class DefaultCardCryptoCurrencyFactoryTest { private val userWalletsStore: UserWalletsStore = mockk() private val userTokensResponseStore: UserTokensResponseStore = mockk() private val excludedBlockchains = ExcludedBlockchains() + private val accountsFeatureToggles = mockk() + private val walletAccountsFetcher = mockk() private val factory = DefaultCardCryptoCurrencyFactory( demoConfig = DemoConfig(), @@ -46,6 +50,8 @@ internal class DefaultCardCryptoCurrencyFactoryTest { responseCryptoCurrenciesFactory = ResponseCryptoCurrenciesFactory( networkFactory = NetworkFactory(excludedBlockchains = excludedBlockchains), ), + accountsFeatureToggles = accountsFeatureToggles, + walletAccountsFetcher = walletAccountsFetcher, ) private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() @@ -57,7 +63,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest { @BeforeEach fun init() { - clearMocks(userWalletsStore, userTokensResponseStore, iconUri) + clearMocks(userWalletsStore, userTokensResponseStore, accountsFeatureToggles, walletAccountsFetcher, iconUri) mockkStatic(Uri::class) every { Uri.parse(any()) } returns iconUri @@ -75,6 +81,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest { val userTokensResponse = model.userTokensResponse val network = ethereum.network + every { accountsFeatureToggles.isFeatureEnabled } returns false coEvery { userWalletsStore.getSyncStrict(key = userWallet.walletId) } returns userWallet coEvery { userTokensResponseStore.getSyncOrNull(userWallet.walletId) } returns userTokensResponse @@ -236,6 +243,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest { val networks = setOf(ethereum.network, bitcoin.network) val userTokensResponse = model.userTokensResponse + every { accountsFeatureToggles.isFeatureEnabled } returns false coEvery { userTokensResponseStore.getSyncOrNull(userWallet.walletId) } returns userTokensResponse // Act diff --git a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/DefaultExpressServiceLoader.kt b/data/express/src/main/java/com/tangem/data/express/DefaultExpressServiceFetcher.kt similarity index 65% rename from core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/DefaultExpressServiceLoader.kt rename to data/express/src/main/java/com/tangem/data/express/DefaultExpressServiceFetcher.kt index 11192e7af9..65e0556973 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/DefaultExpressServiceLoader.kt +++ b/data/express/src/main/java/com/tangem/data/express/DefaultExpressServiceFetcher.kt @@ -1,17 +1,23 @@ -package com.tangem.datasource.exchangeservice.swap +package com.tangem.data.express +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.data.express.converter.ExpressAssetConverter import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.express.models.request.AssetsRequestBody import com.tangem.datasource.api.express.models.request.LeastTokenInfo -import com.tangem.datasource.api.express.models.response.Asset import com.tangem.datasource.exchangeservice.swap.ExpressUtils.getRefCode import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.ExpressAssetsStore +import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.core.lce.Lce +import com.tangem.domain.core.utils.catchOn import com.tangem.domain.core.utils.lceContent import com.tangem.domain.core.utils.lceError import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -19,56 +25,72 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject -typealias InitializationStatusFlow = MutableStateFlow>> +typealias InitializationStatusFlow = MutableStateFlow>> /** - * Default implementation of [ExpressServiceLoader] + * Default implementation of [ExpressServiceFetcher] * * @property tangemExpressApi express api * @property expressAssetsStore local storage * [REDACTED_AUTHOR] */ -internal class DefaultExpressServiceLoader @Inject constructor( +internal class DefaultExpressServiceFetcher @Inject constructor( private val tangemExpressApi: TangemExpressApi, private val expressAssetsStore: ExpressAssetsStore, private val appPreferencesStore: AppPreferencesStore, + private val userWalletsStore: UserWalletsStore, private val dispatchers: CoroutineDispatcherProvider, -) : ExpressServiceLoader { +) : ExpressServiceFetcher { private val initializationStatuses = MutableStateFlow>(value = emptyMap()) - override suspend fun update(userWallet: UserWallet, userTokens: List) { - withContext(dispatchers.io) { + override suspend fun fetch(userWalletId: UserWalletId, assetIds: Set): Either = + either { + val userWallet = arrow.core.raise.catch( + block = { userWalletsStore.getSyncStrict(userWalletId) }, + catch = ::raise, + ) + + fetch(userWallet = userWallet, assetIds = assetIds).bind() + } + + override suspend fun fetch(userWallet: UserWallet, assetIds: Set): Either { + return Either.catchOn(dispatchers.io) { val initializationStatus = getInitializationStatusInternal(userWallet.walletId) try { - if (userTokens.isNotEmpty()) { + if (assetIds.isNotEmpty()) { + val tokenList = assetIds.map { + LeastTokenInfo(contractAddress = it.contractAddress, network = it.networkId) + } + val response = tangemExpressApi.getAssets( userWalletId = userWallet.walletId.stringValue, refCode = getRefCode(userWallet, appPreferencesStore), - body = AssetsRequestBody(tokensList = userTokens), + body = AssetsRequestBody(tokensList = tokenList), ).getOrThrow() expressAssetsStore.store(userWallet.walletId, response) - initializationStatus.update { response.lceContent() } + val expressAssets = ExpressAssetConverter.convertList(response) + initializationStatus.update { expressAssets.lceContent() } } } catch (e: Throwable) { if (expressAssetsStore.getSyncOrNull(userWallet.walletId) == null) { initializationStatus.update { e.lceError() } } Timber.e(e, "Unable to fetch assets for: ${userWallet.walletId.stringValue}") + throw e } } } - override fun getInitializationStatus(userWalletId: UserWalletId): Flow>> { + override fun getInitializationStatus(userWalletId: UserWalletId): Flow>> { return flow { getInitializationStatusInternal(userWalletId).collect { emit(it) } } } @@ -77,7 +99,7 @@ internal class DefaultExpressServiceLoader @Inject constructor( val initializationStatus = initializationStatuses.value[userWalletId] if (initializationStatus != null) return initializationStatus - val cached = expressAssetsStore.getSyncOrNull(userWalletId) + val cached = expressAssetsStore.getSyncOrNull(userWalletId)?.let(ExpressAssetConverter::convertList) val default: InitializationStatusFlow = MutableStateFlow(value = cached?.lceContent() ?: lceLoading()) initializationStatuses.update { statuses -> diff --git a/data/express/src/main/java/com/tangem/data/express/converter/ExpressAssetConverter.kt b/data/express/src/main/java/com/tangem/data/express/converter/ExpressAssetConverter.kt new file mode 100644 index 0000000000..9c5ccace5f --- /dev/null +++ b/data/express/src/main/java/com/tangem/data/express/converter/ExpressAssetConverter.kt @@ -0,0 +1,24 @@ +package com.tangem.data.express.converter + +import com.tangem.datasource.api.express.models.response.Asset +import com.tangem.domain.express.models.ExpressAsset +import com.tangem.utils.converter.Converter + +/** + * Converts an [Asset] from the data layer to an [ExpressAsset] in the domain layer. + * +[REDACTED_AUTHOR] + */ +internal object ExpressAssetConverter : Converter { + + override fun convert(value: Asset): ExpressAsset { + return ExpressAsset( + id = ExpressAsset.ID( + networkId = value.network, + contractAddress = value.contractAddress, + ), + isExchangeAvailable = value.exchangeAvailable, + isOnrampAvailable = value.onrampAvailable, + ) + } +} \ No newline at end of file diff --git a/data/express/src/main/java/com/tangem/data/express/di/ExpressDataModule.kt b/data/express/src/main/java/com/tangem/data/express/di/ExpressDataModule.kt index 299b631162..c696e08aec 100644 --- a/data/express/src/main/java/com/tangem/data/express/di/ExpressDataModule.kt +++ b/data/express/src/main/java/com/tangem/data/express/di/ExpressDataModule.kt @@ -2,6 +2,7 @@ package com.tangem.data.express.di import com.squareup.moshi.Moshi import com.tangem.data.express.DefaultExpressRepository +import com.tangem.data.express.DefaultExpressServiceFetcher import com.tangem.data.express.converter.ExpressErrorConverter import com.tangem.data.express.error.DefaultExpressErrorResolver import com.tangem.datasource.api.express.TangemExpressApi @@ -10,6 +11,7 @@ import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.express.ExpressErrorResolver import com.tangem.domain.express.ExpressRepository +import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -43,4 +45,10 @@ internal object ExpressDataModule { dispatchers = dispatchers, ) } + + @Provides + @Singleton + fun provideExpressServiceFetcher(impl: DefaultExpressServiceFetcher): ExpressServiceFetcher { + return impl + } } \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt index cc2fe1bca6..3d74578599 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt @@ -238,7 +238,7 @@ internal class DefaultCustomTokensRepository( "User tokens not found for user wallet [$userWalletId] while removing currency" } - val token = userTokensResponseFactory.createResponseToken(cryptoCurrency) + val token = userTokensResponseFactory.createResponseToken(currency = cryptoCurrency, accountId = null) userTokensSaver.storeAndPush( userWalletId = userWalletId, response = storedCurrencies.copy(tokens = storedCurrencies.tokens.filterNot { it == token }), @@ -249,6 +249,27 @@ internal class DefaultCustomTokensRepository( } } + override suspend fun convertToCryptoCurrency( + userWalletId: UserWalletId, + currency: ManagedCryptoCurrency.Custom, + ): CryptoCurrency { + return when (currency) { + is ManagedCryptoCurrency.Custom.Coin -> createCoin( + userWalletId = userWalletId, + networkId = currency.network.id, + derivationPath = currency.network.derivationPath, + ) + is ManagedCryptoCurrency.Custom.Token -> cryptoCurrencyFactory.createToken( + network = currency.network, + rawId = currency.currencyId.rawCurrencyId, + name = currency.name, + symbol = currency.symbol, + decimals = currency.decimals, + contractAddress = currency.contractAddress, + ) + } + } + override suspend fun getSupportedNetworks(userWalletId: UserWalletId): List = withContext(dispatchers.io) { val userWallet = requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) { "User wallet [$userWalletId] not found while getting supported networks" diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt index c5c8b51cdd..0d30385164 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt @@ -6,6 +6,7 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.data.common.api.safeApiCall import com.tangem.data.common.currency.CardCryptoCurrencyFactory import com.tangem.data.common.currency.UserTokensResponseFactory @@ -21,15 +22,13 @@ import com.tangem.datasource.local.config.testnet.TestnetTokensStorage import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.card.common.TapWorkarounds.isTestCard -import com.tangem.domain.card.common.extensions.canHandleBlockchain -import com.tangem.domain.card.common.extensions.canHandleToken -import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains -import com.tangem.domain.card.common.extensions.supportedBlockchains -import com.tangem.domain.card.common.extensions.supportedTokens +import com.tangem.domain.card.common.extensions.* import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.managetokens.model.* import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork import com.tangem.domain.managetokens.repository.ManageTokensRepository +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -40,7 +39,7 @@ import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher.Request import com.tangem.pagination.toBatchFlow import com.tangem.utils.coroutines.CoroutineDispatcherProvider -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") internal class DefaultManageTokensRepository( private val tangemTechApi: TangemTechApi, private val userWalletsStore: UserWalletsStore, @@ -51,6 +50,7 @@ internal class DefaultManageTokensRepository( private val excludedBlockchains: ExcludedBlockchains, private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, private val dispatchers: CoroutineDispatcherProvider, + private val walletAccountsFetcher: WalletAccountsFetcher, networkFactory: NetworkFactory, ) : ManageTokensRepository { @@ -94,7 +94,6 @@ internal class DefaultManageTokensRepository( }, ) - @Suppress("ComplexCondition") private suspend fun fetchCurrencies( userWallet: UserWallet?, request: Request, @@ -128,20 +127,22 @@ internal class DefaultManageTokensRepository( ) val tokensResponse = request.params.userWalletId?.let { userWalletId -> - if (loadUserTokensFromRemote && userWallet != null) { - safeApiCall({ tangemTechApi.getUserTokens(userWalletId.stringValue).bind() }) { - // save tokens response only if loadUserTokensFromRemote is true and it means onboarding call - createAndSaveDefaultUserTokensResponse(userWallet = userWallet) + when (val params = request.params) { + is ManageTokensListConfig.Account -> { + fetchUserTokens(userWallet, userWalletId, params, loadUserTokensFromRemote) + } + is ManageTokensListConfig.Wallet -> { + fetchUserTokensLegacy(userWallet, userWalletId, loadUserTokensFromRemote) } - } else { - getSavedUserTokensResponseSync(userWalletId) } } - val items = if (isFirstBatchFetching && + + val isCreateWithCustom = isFirstBatchFetching && tokensResponse != null && userWallet != null && query == null - ) { + + val items = if (isCreateWithCustom) { managedCryptoCurrencyFactory.createWithCustomTokens( coinsResponse = updatedCoinsResponse, tokensResponse = tokensResponse, @@ -162,6 +163,56 @@ internal class DefaultManageTokensRepository( ) } + private suspend fun fetchUserTokens( + userWallet: UserWallet?, + userWalletId: UserWalletId, + params: ManageTokensListConfig.Account, + loadUserTokensFromRemote: Boolean, + ): UserTokensResponse? { + val accountId = when { + params.accountId == null -> { + return null + } + loadUserTokensFromRemote -> { + AccountId.forCryptoPortfolio(userWalletId = userWalletId, derivationIndex = DerivationIndex.Main) + } + else -> requireNotNull(params.accountId) + } + + val response = if (loadUserTokensFromRemote && userWallet != null) { + runCatching { walletAccountsFetcher.fetch(userWalletId = userWallet.walletId) }.getOrNull() + } else { + walletAccountsFetcher.getSaved(userWalletId) + } + + val account = response?.accounts?.firstOrNull { it.id == accountId.value } + ?: return null + + return UserTokensResponse( + group = response.wallet.group, + sort = response.wallet.sort, + tokens = account.tokens.orEmpty(), + ) + } + + private suspend fun fetchUserTokensLegacy( + userWallet: UserWallet?, + userWalletId: UserWalletId, + loadUserTokensFromRemote: Boolean, + ): UserTokensResponse? { + return if (loadUserTokensFromRemote && userWallet != null) { + safeApiCall( + call = { tangemTechApi.getUserTokens(userWalletId.stringValue).bind() }, + onError = { + // save tokens response only if loadUserTokensFromRemote is true and it means onboarding call + createAndSaveDefaultUserTokensResponse(userWallet = userWallet) + }, + ) + } else { + getSavedUserTokensResponseSync(userWalletId) + } + } + private suspend fun createAndSaveDefaultUserTokensResponse(userWallet: UserWallet): UserTokensResponse { val userTokensResponse = createDefaultUserTokensResponse(userWallet) userTokenSaver.store(userWallet.walletId, userTokensResponse, useEnricher = false) diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt index 412155dcda..28341d6124 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt @@ -1,6 +1,7 @@ package com.tangem.data.managetokens.di import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.data.common.currency.CardCryptoCurrencyFactory import com.tangem.data.common.currency.UserTokensSaver import com.tangem.data.common.network.NetworkFactory @@ -38,6 +39,7 @@ internal object ManageTokensDataModule { excludedBlockchains: ExcludedBlockchains, cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, networkFactory: NetworkFactory, + walletAccountsFetcher: WalletAccountsFetcher, ): ManageTokensRepository { return DefaultManageTokensRepository( tangemTechApi = tangemTechApi, @@ -50,6 +52,7 @@ internal object ManageTokensDataModule { cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, networkFactory = networkFactory, dispatchers = dispatchers, + walletAccountsFetcher = walletAccountsFetcher, ) } diff --git a/data/onramp/build.gradle.kts b/data/onramp/build.gradle.kts index 47909aa6bf..ada7a8f3b0 100644 --- a/data/onramp/build.gradle.kts +++ b/data/onramp/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(projects.domain.walletManager) implementation(projects.domain.appTheme.models) implementation(projects.domain.models) + implementation(projects.domain.express.models) // region DI diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt index 603a4a806f..be4b3d4460 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt @@ -12,7 +12,6 @@ import com.tangem.data.onramp.converters.error.OnrampErrorConverter import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.express.TangemExpressApi -import com.tangem.datasource.api.express.models.TangemExpressValues import com.tangem.datasource.api.express.models.response.ExchangeProvider import com.tangem.datasource.api.express.models.response.ExchangeProviderType import com.tangem.datasource.api.express.models.response.ExpressErrorResponse @@ -38,6 +37,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObject import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull import com.tangem.datasource.local.preferences.utils.storeObject +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -560,7 +560,7 @@ internal class DefaultOnrampRepository( } private fun CryptoCurrency.getContractAddress(): String = when (this) { - is CryptoCurrency.Coin -> TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE + is CryptoCurrency.Coin -> ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE is CryptoCurrency.Token -> this.contractAddress } diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt index 66ee3d8bb8..a535b536db 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt @@ -160,7 +160,7 @@ public val Blockchain.mercuryoNetwork: String? Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> null Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> null Blockchain.Quai, Blockchain.QuaiTestnet -> null - // Blockchain.Linea, Blockchain.LineaTestnet -> null - // Blockchain.ArbitrumNova -> null + Blockchain.Linea, Blockchain.LineaTestnet -> null + Blockchain.ArbitrumNova -> null } } \ No newline at end of file diff --git a/data/tokens/build.gradle.kts b/data/tokens/build.gradle.kts index eb3fe93e66..d54ae54bf7 100644 --- a/data/tokens/build.gradle.kts +++ b/data/tokens/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation(projects.domain.card) implementation(projects.domain.core) implementation(projects.domain.demo) + implementation(projects.domain.express) implementation(projects.domain.legacy) implementation(projects.domain.models) implementation(projects.domain.staking) diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcher.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcher.kt index 5c8f9d9f23..bf34ca207d 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcher.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcher.kt @@ -1,9 +1,13 @@ package com.tangem.data.tokens import arrow.core.Either +import arrow.core.right import com.tangem.data.common.account.WalletAccountsFetcher +import com.tangem.datasource.api.tangemTech.models.account.flattenTokens import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.core.utils.catchOn +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher @@ -15,6 +19,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider * * @property userWalletsStore [UserWallet]'s store * @property walletAccountsFetcher instance of [WalletAccountsFetcher] to fetch accounts for a multi wallet + * @property expressServiceFetcher fetcher of express service * @property dispatchers dispatchers * [REDACTED_AUTHOR] @@ -22,14 +27,26 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider internal class AccountListCryptoCurrenciesFetcher( private val userWalletsStore: UserWalletsStore, private val walletAccountsFetcher: WalletAccountsFetcher, + private val expressServiceFetcher: ExpressServiceFetcher, private val dispatchers: CoroutineDispatcherProvider, ) : MultiWalletCryptoCurrenciesFetcher { - override suspend fun invoke(params: Params) = Either.catchOn(dispatchers.default) { - val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId) + override suspend fun invoke(params: Params): Either { + return Either.catchOn(dispatchers.default) { + val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId) - if (!userWallet.isMultiCurrency) error("${this::class.simpleName} supports only multi-currency wallet") + if (!userWallet.isMultiCurrency) error("${this::class.simpleName} supports only multi-currency wallet") - walletAccountsFetcher.fetch(userWalletId = params.userWalletId) + val response = walletAccountsFetcher.fetch(userWalletId = params.userWalletId) + + expressServiceFetcher.fetch( + userWallet = userWallet, + assetIds = response.flattenTokens().mapTo(hashSetOf()) { + ExpressAsset.ID(networkId = it.networkId, contractAddress = it.contractAddress) + }, + ) + + Unit.right() + } } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesFetcher.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesFetcher.kt index 896fd35e4f..8fc010aa4d 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesFetcher.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesFetcher.kt @@ -7,15 +7,14 @@ import com.tangem.data.common.currency.UserTokensResponseFactory import com.tangem.data.common.currency.UserTokensSaver import com.tangem.data.tokens.utils.CustomTokensMerger import com.tangem.datasource.api.common.response.ApiResponseError -import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE -import com.tangem.datasource.api.express.models.request.LeastTokenInfo import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.core.utils.catchOn import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher @@ -32,7 +31,7 @@ import timber.log.Timber * @property userTokensResponseStore store of [UserTokensResponse] * @property userTokensSaver user tokens saver * @property cardCryptoCurrencyFactory factory for creating crypto currencies for specified card - * @property expressServiceLoader express service loader + * @property expressServiceFetcher express service loader * @property dispatchers dispatchers * [REDACTED_AUTHOR] @@ -46,7 +45,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcher( private val userTokensResponseStore: UserTokensResponseStore, private val userTokensSaver: UserTokensSaver, private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, - private val expressServiceLoader: ExpressServiceLoader, + private val expressServiceFetcher: ExpressServiceFetcher, private val dispatchers: CoroutineDispatcherProvider, ) : MultiWalletCryptoCurrenciesFetcher { @@ -109,14 +108,14 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcher( } private suspend fun fetchExpressAssetsByNetworkIds(userWallet: UserWallet, userTokens: UserTokensResponse) { - val tokens = userTokens.tokens.map { token -> - LeastTokenInfo( - contractAddress = token.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE, - network = token.networkId, + val tokens = userTokens.tokens.mapTo(hashSetOf()) { token -> + ExpressAsset.ID( + networkId = token.networkId, + contractAddress = token.contractAddress, ) } - expressServiceLoader.update(userWallet = userWallet, userTokens = tokens) + expressServiceFetcher.fetch(userWallet = userWallet, assetIds = tokens) } private fun createDefaultUserTokensResponse(userWallet: UserWallet): UserTokensResponse { @@ -124,6 +123,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcher( currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet), isGroupedByNetwork = false, isSortedByBalance = false, + accountId = null, ) } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesFetcherModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesFetcherModule.kt index c360054a04..ae517b8ffc 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesFetcherModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesFetcherModule.kt @@ -7,11 +7,11 @@ import com.tangem.data.tokens.AccountListCryptoCurrenciesFetcher import com.tangem.data.tokens.DefaultMultiWalletCryptoCurrenciesFetcher import com.tangem.data.tokens.utils.CustomTokensMerger import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -33,7 +33,7 @@ internal class MultiWalletCryptoCurrenciesFetcherModule { userTokensResponseStore: UserTokensResponseStore, userTokensSaver: UserTokensSaver, cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, - expressServiceLoader: ExpressServiceLoader, + expressServiceFetcher: ExpressServiceFetcher, walletAccountsFetcher: WalletAccountsFetcher, dispatchers: CoroutineDispatcherProvider, ): MultiWalletCryptoCurrenciesFetcher { @@ -41,6 +41,7 @@ internal class MultiWalletCryptoCurrenciesFetcherModule { AccountListCryptoCurrenciesFetcher( userWalletsStore = userWalletsStore, walletAccountsFetcher = walletAccountsFetcher, + expressServiceFetcher = expressServiceFetcher, dispatchers = dispatchers, ) } else { @@ -56,7 +57,7 @@ internal class MultiWalletCryptoCurrenciesFetcherModule { userTokensResponseStore = userTokensResponseStore, userTokensSaver = userTokensSaver, cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, - expressServiceLoader = expressServiceLoader, + expressServiceFetcher = expressServiceFetcher, dispatchers = dispatchers, ) } 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 23f10b2f4d..e41afb0cdc 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 @@ -5,22 +5,14 @@ import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.common.currency.CardCryptoCurrencyFactory import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.common.currency.UserTokensSaver -import com.tangem.data.tokens.repository.DefaultCurrenciesRepository -import com.tangem.data.tokens.repository.DefaultCurrencyChecksRepository -import com.tangem.data.tokens.repository.DefaultPolkadotAccountHealthCheckRepository -import com.tangem.data.tokens.repository.DefaultTokenReceiveWarningsViewedRepository -import com.tangem.data.tokens.repository.DefaultYieldSupplyWarningsViewedRepository +import com.tangem.data.tokens.repository.* import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.TokenReceiveWarningActionStore import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.tokens.repository.CurrencyChecksRepository -import com.tangem.domain.tokens.repository.PolkadotAccountHealthCheckRepository -import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository -import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.tokens.repository.* import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -42,7 +34,7 @@ internal object TokensDataModule { walletManagersFacade: WalletManagersFacade, cacheRegistry: CacheRegistry, dispatchers: CoroutineDispatcherProvider, - expressServiceLoader: ExpressServiceLoader, + expressServiceFetcher: ExpressServiceFetcher, excludedBlockchains: ExcludedBlockchains, cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, tokensSaver: UserTokensSaver, @@ -54,7 +46,7 @@ internal object TokensDataModule { walletManagersFacade = walletManagersFacade, cacheRegistry = cacheRegistry, userTokensResponseStore = userTokensResponseStore, - expressServiceLoader = expressServiceLoader, + expressServiceFetcher = expressServiceFetcher, dispatchers = dispatchers, excludedBlockchains = excludedBlockchains, cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index 80b1a4577d..4c0102f124 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -10,17 +10,16 @@ import com.tangem.data.common.currency.* import com.tangem.data.tokens.utils.CustomTokensMerger import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow -import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE -import com.tangem.datasource.api.express.models.request.LeastTokenInfo import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.card.CardTypesResolver import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.core.error.DataError import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -40,7 +39,7 @@ internal class DefaultCurrenciesRepository( private val userWalletsStore: UserWalletsStore, private val walletManagersFacade: WalletManagersFacade, private val cacheRegistry: CacheRegistry, - private val expressServiceLoader: ExpressServiceLoader, + private val expressServiceFetcher: ExpressServiceFetcher, private val dispatchers: CoroutineDispatcherProvider, private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, private val userTokensSaver: UserTokensSaver, @@ -74,26 +73,6 @@ internal class DefaultCurrenciesRepository( userTokensSaver.storeAndPush(userWalletId, response) } - override suspend fun saveCurrenciesLocal(userWalletId: UserWalletId, currencies: List) { - withContext(dispatchers.io) { - val savedResponse = requireNotNull( - value = getSavedUserTokensResponseSync(key = userWalletId), - lazyMessage = { "Saved tokens empty. Can not perform add currencies action." }, - ) - - val updatedResponse = savedResponse.copy( - tokens = currencies.map(userTokensResponseFactory::createResponseToken), - ) - - userTokensSaver.store(userWalletId = userWalletId, response = updatedResponse) - - fetchExpressAssetsByNetworkIds( - userWallet = userWalletsStore.getSyncStrict(key = userWalletId), - userTokens = updatedResponse, - ) - } - } - override suspend fun addCurrenciesCache( userWalletId: UserWalletId, currencies: List, @@ -667,16 +646,17 @@ internal class DefaultCurrenciesRepository( return demoConfig.isDemoCardId(userWallet.cardId) && response == null } + // TODO [REDACTED_JIRA] private suspend fun fetchExpressAssetsByNetworkIds(userWallet: UserWallet, userTokens: UserTokensResponse) { - val tokens = userTokens.tokens.map { token -> - LeastTokenInfo( - contractAddress = token.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE, - network = token.networkId, + val tokens = userTokens.tokens.mapTo(hashSetOf()) { token -> + ExpressAsset.ID( + networkId = token.networkId, + contractAddress = token.contractAddress, ) } coroutineScope { - launch { expressServiceLoader.update(userWallet, tokens) } + launch { expressServiceFetcher.fetch(userWallet, tokens) } } } @@ -685,11 +665,11 @@ internal class DefaultCurrenciesRepository( cryptoCurrencies: List, refresh: Boolean = false, ) { - val tokens = cryptoCurrencies.map { currency -> + val tokens = cryptoCurrencies.mapTo(hashSetOf()) { currency -> val tokenCurrency = currency as? CryptoCurrency.Token - LeastTokenInfo( - contractAddress = tokenCurrency?.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE, - network = currency.network.backendId, + ExpressAsset.ID( + networkId = currency.network.backendId, + contractAddress = tokenCurrency?.contractAddress, ) } cacheRegistry.invokeOnExpire( @@ -697,7 +677,7 @@ internal class DefaultCurrenciesRepository( skipCache = refresh, block = { coroutineScope { - launch { expressServiceLoader.update(userWallet, tokens) } + launch { expressServiceFetcher.fetch(userWallet, tokens) } } }, ) @@ -731,6 +711,7 @@ internal class DefaultCurrenciesRepository( ), isGroupedByNetwork = false, isSortedByBalance = false, + accountId = null, ) private fun ensureIsCorrectUserWallet(userWalletId: UserWalletId, isMultiCurrencyWalletExpected: Boolean) { diff --git a/data/tokens/src/test/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcherTest.kt b/data/tokens/src/test/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcherTest.kt index 79bcd8e6f8..b82fb38b4d 100644 --- a/data/tokens/src/test/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcherTest.kt +++ b/data/tokens/src/test/kotlin/com/tangem/data/tokens/AccountListCryptoCurrenciesFetcherTest.kt @@ -1,10 +1,13 @@ package com.tangem.data.tokens import arrow.core.left +import arrow.core.right import com.tangem.common.test.utils.assertEither import com.tangem.common.test.utils.assertEitherRight import com.tangem.data.common.account.WalletAccountsFetcher +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency @@ -21,11 +24,13 @@ internal class AccountListCryptoCurrenciesFetcherTest { private val userWalletsStore: UserWalletsStore = mockk(relaxUnitFun = true) private val walletAccountsFetcher: WalletAccountsFetcher = mockk(relaxUnitFun = true) + private val expressServiceFetcher: ExpressServiceFetcher = mockk() private val dispatchers = TestingCoroutineDispatcherProvider() private val fetcher = AccountListCryptoCurrenciesFetcher( userWalletsStore = userWalletsStore, walletAccountsFetcher = walletAccountsFetcher, + expressServiceFetcher = expressServiceFetcher, dispatchers = dispatchers, ) @@ -59,8 +64,11 @@ internal class AccountListCryptoCurrenciesFetcherTest { // Arrange val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId) val mockUserWallet = mockk { every { isMultiCurrency } returns true } + val response = mockk(relaxed = true) every { userWalletsStore.getSyncStrict(key = params.userWalletId) } returns mockUserWallet + coEvery { walletAccountsFetcher.fetch(userWalletId = params.userWalletId) } returns response + coEvery { expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = emptySet()) } returns Unit.right() // Act val actual = fetcher(params) @@ -71,6 +79,7 @@ internal class AccountListCryptoCurrenciesFetcherTest { coVerify(ordering = Ordering.SEQUENCE) { userWalletsStore.getSyncStrict(key = params.userWalletId) walletAccountsFetcher.fetch(userWalletId = params.userWalletId) + expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = emptySet()) } } diff --git a/data/tokens/src/test/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesFetcherTest.kt b/data/tokens/src/test/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesFetcherTest.kt index 9b685b565c..47187cae61 100644 --- a/data/tokens/src/test/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesFetcherTest.kt +++ b/data/tokens/src/test/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesFetcherTest.kt @@ -11,14 +11,13 @@ import com.tangem.data.common.currency.UserTokensSaver import com.tangem.data.tokens.utils.CustomTokensMerger import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError -import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE -import com.tangem.datasource.api.express.models.request.LeastTokenInfo import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency @@ -45,7 +44,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true) private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() - private val expressServiceLoader: ExpressServiceLoader = mockk(relaxUnitFun = true) + private val expressServiceFetcher: ExpressServiceFetcher = mockk(relaxUnitFun = true) private val fetcher = DefaultMultiWalletCryptoCurrenciesFetcher( demoConfig = DemoConfig(), @@ -55,7 +54,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { userTokensResponseStore = userTokensResponseStore, userTokensSaver = userTokensSaver, cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, - expressServiceLoader = expressServiceLoader, + expressServiceFetcher = expressServiceFetcher, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -67,7 +66,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { userTokensResponseStore, userTokensSaver, cardCryptoCurrencyFactory, - expressServiceLoader, + expressServiceFetcher, ) } @@ -131,6 +130,13 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse) } returns userTokensResponse + coEvery { + expressServiceFetcher.fetch( + userWallet = mockUserWallet, + assetIds = userTokensResponse.toAssetId(), + ) + } returns Unit.right() + // Act val actual = fetcher(params) @@ -144,7 +150,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(mockUserWallet) customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse) userTokensSaver.store(userWalletId = params.userWalletId, response = userTokensResponse) - expressServiceLoader.update(userWallet = mockUserWallet, userTokens = userTokensResponse.toLeastTokens()) + expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = userTokensResponse.toAssetId()) } } @@ -170,6 +176,13 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data) } returns apiResponse.data + coEvery { + expressServiceFetcher.fetch( + userWallet = mockUserWallet, + assetIds = defaultResponse.toAssetId(), + ) + } returns Unit.right() + // Act val actual = fetcher(params) @@ -183,7 +196,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue) customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data) userTokensSaver.store(userWalletId = params.userWalletId, response = apiResponse.data) - expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens()) + expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = defaultResponse.toAssetId()) } coVerify(inverse = true) { @@ -212,6 +225,13 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data) } returns apiResponse.data + coEvery { + expressServiceFetcher.fetch( + userWallet = mockUserWallet, + assetIds = apiResponse.data.toAssetId(), + ) + } returns Unit.right() + // Act val actual = fetcher(params) @@ -224,7 +244,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue) customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data) userTokensSaver.store(userWalletId = params.userWalletId, response = apiResponse.data) - expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens()) + expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = defaultResponse.toAssetId()) } coVerify(inverse = true) { @@ -273,6 +293,13 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse) } returns userTokensResponse + coEvery { + expressServiceFetcher.fetch( + userWallet = mockUserWallet, + assetIds = userTokensResponse.toAssetId(), + ) + } returns Unit.right() + // Act val actual = fetcher(params) @@ -286,7 +313,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId) customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse) userTokensSaver.store(userWalletId = params.userWalletId, response = userTokensResponse) - expressServiceLoader.update(userWallet = mockUserWallet, userTokens = userTokensResponse.toLeastTokens()) + expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = userTokensResponse.toAssetId()) } coVerify(inverse = true) { @@ -317,6 +344,13 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse) } returns defaultResponse + coEvery { + expressServiceFetcher.fetch( + userWallet = mockUserWallet, + assetIds = defaultResponse.toAssetId(), + ) + } returns Unit.right() + // Act val actual = fetcher(params) @@ -330,7 +364,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId) customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse) userTokensSaver.store(userWalletId = params.userWalletId, response = defaultResponse) - expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens()) + expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = defaultResponse.toAssetId()) } coVerify(inverse = true) { @@ -383,6 +417,13 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse) } returns userTokensResponse + coEvery { + expressServiceFetcher.fetch( + userWallet = mockUserWallet, + assetIds = userTokensResponse.toAssetId(), + ) + } returns Unit.right() + // Act val actual = fetcher(params) @@ -398,7 +439,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { userTokensSaver.push(userWalletId = params.userWalletId, response = userTokensResponse) customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse) userTokensSaver.store(userWalletId = params.userWalletId, response = userTokensResponse) - expressServiceLoader.update(userWallet = mockUserWallet, userTokens = userTokensResponse.toLeastTokens()) + expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = userTokensResponse.toAssetId()) } } @@ -429,6 +470,13 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse) } returns defaultResponse + coEvery { + expressServiceFetcher.fetch( + userWallet = mockUserWallet, + assetIds = defaultResponse.toAssetId(), + ) + } returns Unit.right() + // Act val actual = fetcher(params) @@ -443,7 +491,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { userTokensSaver.push(userWalletId = params.userWalletId, response = defaultResponse) customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse) userTokensSaver.store(userWalletId = params.userWalletId, response = defaultResponse) - expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens()) + expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = defaultResponse.toAssetId()) } coVerify(inverse = true) { @@ -471,11 +519,11 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest { ), ) - fun UserTokensResponse.toLeastTokens(): List { - return tokens.map { token -> - LeastTokenInfo( - contractAddress = token.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE, - network = token.networkId, + fun UserTokensResponse.toAssetId(): Set { + return tokens.mapTo(hashSetOf()) { token -> + ExpressAsset.ID( + networkId = token.networkId, + contractAddress = token.contractAddress, ) } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt index 7e197fea52..b7c1bac9d4 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt @@ -1,8 +1,10 @@ package com.tangem.data.pay.repository +import com.squareup.moshi.Moshi import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.visa.utils.TangemPayTxHistoryItemConverter import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext @@ -26,8 +28,11 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor( private val cacheRegistry: CacheRegistry, private val txHistoryItemsStore: TangemPayTxHistoryItemsStore, private val dispatchers: CoroutineDispatcherProvider, + @NetworkMoshi private val moshi: Moshi, ) : TangemPayTxHistoryRepository { + private val txHistoryItemConverter by lazy { TangemPayTxHistoryItemConverter(moshi) } + override fun getTxHistoryBatchFlow( batchSize: Int, context: TangemPayTxHistoryListBatchingContext, @@ -84,7 +89,7 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor( val result = requestPerformer.request { authHeader -> visaApi.getTangemPayTxHistory(authHeader = authHeader, limit = pageSize, cursor = cursor) }.result - val items = TangemPayTxHistoryItemConverter.convertList(result.transactions).filterNotNull() + val items = txHistoryItemConverter.convertList(result.transactions).filterNotNull() txHistoryItemsStore.store(key = customerWalletAddress, cursor = cursor ?: INITIAL_CURSOR, value = items) }.onLeft { error(it.toString()) } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemConverter.kt index e8bead6d27..8c916070f1 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemConverter.kt @@ -1,14 +1,19 @@ package com.tangem.data.visa.utils +import com.squareup.moshi.Moshi import com.tangem.datasource.api.pay.models.response.TangemPayTxHistoryResponse import com.tangem.domain.visa.model.TangemPayTxHistoryItem import com.tangem.utils.converter.Converter import timber.log.Timber import java.util.Currency -internal object TangemPayTxHistoryItemConverter : +internal class TangemPayTxHistoryItemConverter(moshi: Moshi) : Converter { + private val spendAdapter = moshi.adapter(TangemPayTxHistoryResponse.Spend::class.java) + private val paymentAdapter = moshi.adapter(TangemPayTxHistoryResponse.Payment::class.java) + private val feeAdapter = moshi.adapter(TangemPayTxHistoryResponse.Fee::class.java) + override fun convert(value: TangemPayTxHistoryResponse.Transaction): TangemPayTxHistoryItem? { return value.spend?.let { convertSpend(id = value.id, spend = it) } ?: value.payment?.let { convertPayment(id = value.id, payment = it) } @@ -22,6 +27,7 @@ internal object TangemPayTxHistoryItemConverter : private fun convertSpend(id: String, spend: TangemPayTxHistoryResponse.Spend): TangemPayTxHistoryItem.Spend { return TangemPayTxHistoryItem.Spend( id = id, + jsonRepresentation = spendAdapter.toJson(spend), // If postedAt is null, it means transaction wasn't posted and was likely declined. Use authorizedAt date = spend.postedAt ?: spend.authorizedAt, amount = spend.amount, @@ -41,15 +47,18 @@ internal object TangemPayTxHistoryItemConverter : ): TangemPayTxHistoryItem.Payment { return TangemPayTxHistoryItem.Payment( id = id, + jsonRepresentation = paymentAdapter.toJson(payment), date = payment.postedAt, currency = Currency.getInstance(payment.currency), amount = payment.amount, + transactionHash = payment.transactionHash, ) } private fun convertFee(id: String, fee: TangemPayTxHistoryResponse.Fee): TangemPayTxHistoryItem.Fee { return TangemPayTxHistoryItem.Fee( id = id, + jsonRepresentation = feeAdapter.toJson(fee), date = fee.postedAt, currency = Currency.getInstance(fee.currency), amount = fee.amount, diff --git a/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountProducer.kt b/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountProducer.kt new file mode 100644 index 0000000000..541ce35936 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountProducer.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.account.producer + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId + +/** + * Produces a flow of [Account.CryptoPortfolio] for a single account identified by [Params.accountId]. + * The flow emits updates whenever the account's portfolio changes. + */ +interface SingleAccountProducer : FlowProducer { + + data class Params(val accountId: AccountId) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountSupplier.kt b/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountSupplier.kt new file mode 100644 index 0000000000..527a32f557 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountSupplier.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.account.supplier + +import com.tangem.domain.account.producer.SingleAccountProducer +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.models.account.Account + +/** + * Supplies instances of [SingleAccountProducer] that produce flows of [Account.CryptoPortfolio] + * for individual accounts. Each producer is uniquely identified by its [SingleAccountProducer.Params]. + * + * @property factory A factory to create instances of [SingleAccountProducer]. + * @property keyCreator A function that generates a unique key for caching based on [SingleAccountProducer.Params]. + */ +abstract class SingleAccountSupplier( + override val factory: SingleAccountProducer.Factory, + override val keyCreator: (SingleAccountProducer.Params) -> String, +) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/account/status/build.gradle.kts b/domain/account/status/build.gradle.kts index 8e2f490e75..9859fe487d 100644 --- a/domain/account/status/build.gradle.kts +++ b/domain/account/status/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { api(projects.domain.account) api(projects.domain.core) api(projects.domain.common) + api(projects.domain.express) api(projects.domain.quotes) api(projects.domain.models) api(projects.domain.networks) 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 79d373768b..d9376f4dd2 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 @@ -4,9 +4,11 @@ import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCase import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase -import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase +import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.common.wallets.UserWalletsListRepository +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 @@ -14,6 +16,7 @@ import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher import com.tangem.domain.staking.utils.StakingCleaner +import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.wallets.derivations.DerivationsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -21,6 +24,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 @Module @@ -41,6 +46,20 @@ internal object AccountStatusUseCaseModule { ) } + @Provides + @Singleton + fun provideGetCryptoCurrencyActionsUseCaseV2( + accountsCRUDRepository: AccountsCRUDRepository, + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, + ): GetCryptoCurrencyActionsUseCaseV2 { + return GetCryptoCurrencyActionsUseCaseV2( + accountsCRUDRepository = accountsCRUDRepository, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + getCryptoCurrencyActionsUseCase = getCryptoCurrencyActionsUseCase, + ) + } + @Provides @Singleton fun provideGetAccountCurrencyStatusUseCase( @@ -62,9 +81,10 @@ internal object AccountStatusUseCaseModule { stakingIdFactory: StakingIdFactory, networksCleaner: NetworksCleaner, stakingCleaner: StakingCleaner, + expressServiceFetcher: ExpressServiceFetcher, dispatchers: CoroutineDispatcherProvider, - ): SaveCryptoCurrenciesUseCase { - return SaveCryptoCurrenciesUseCase( + ): ManageCryptoCurrenciesUseCase { + return ManageCryptoCurrenciesUseCase( singleAccountListSupplier = singleAccountListSupplier, accountsCRUDRepository = accountsCRUDRepository, currenciesRepository = currenciesRepository, @@ -75,6 +95,8 @@ internal object AccountStatusUseCaseModule { stakingIdFactory = stakingIdFactory, networksCleaner = networksCleaner, stakingCleaner = stakingCleaner, + expressServiceFetcher = expressServiceFetcher, + parallelUpdatingScope = CoroutineScope(SupervisorJob() + dispatchers.default), dispatchers = dispatchers, ) } 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 e8c9a00296..8a5f3346ec 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 @@ -53,25 +53,27 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo params = SingleAccountListProducer.Params(params.userWalletId), ) + @Suppress("UnusedFlow") return accountListFlow.flatMapLatest { accountList -> - val accountStatusFlows = accountList.accounts - .filterIsInstance() - .map { account -> - if (account.cryptoCurrencies.isEmpty()) { - createEmptyAccountStatusFlow(account) - } else { - val userWallet = userWalletsListRepository.userWalletsSync().first { - it.walletId == params.userWalletId - } + val accountStatusFlows = accountList.accounts.mapNotNull { account -> + if (account !is Account.CryptoPortfolio) return@mapNotNull null - getAccountStatusFlow( - userWallet = userWallet, - account = account, - groupType = accountList.groupType, - sortType = accountList.sortType, - ) + if (account.cryptoCurrencies.isEmpty()) { + createEmptyAccountStatusFlow(account) + } else { + val userWallet = userWalletsListRepository.userWalletsSync().first { + it.walletId == params.userWalletId } + + getAccountStatusFlow( + userWallet = userWallet, + account = account, + groupType = accountList.groupType, + sortType = accountList.sortType, + ) } + .distinctUntilChanged() + } combine(accountStatusFlows) { accountStatuses -> val balances = accountStatuses.map { it.tokenList.totalFiatBalance } @@ -84,6 +86,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo ) } } + .distinctUntilChanged() .flowOn(dispatchers.default) } @@ -108,6 +111,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo ): Flow { val statusesFlows = account.cryptoCurrencies.map { currency -> cryptoCurrencyStatusesFlowFactory.create(userWallet = userWallet, currency = currency) + .distinctUntilChanged() } return combine(statusesFlows) { statuses -> @@ -123,6 +127,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo priceChangeLce = PriceChangeCalculator.calculate(statuses = statusList), ) } + .distinctUntilChanged() } @AssistedFactory diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/SaveCryptoCurrenciesUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt similarity index 85% rename from domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/SaveCryptoCurrenciesUseCase.kt rename to domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt index 144421a34d..186ea0cb19 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/SaveCryptoCurrenciesUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt @@ -7,6 +7,8 @@ import com.tangem.domain.account.producer.SingleAccountListProducer import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.core.utils.eitherOn +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency @@ -41,7 +43,7 @@ import timber.log.Timber [REDACTED_AUTHOR] */ @Suppress("LongParameterList") -class SaveCryptoCurrenciesUseCase( +class ManageCryptoCurrenciesUseCase( private val singleAccountListSupplier: SingleAccountListSupplier, private val accountsCRUDRepository: AccountsCRUDRepository, private val currenciesRepository: CurrenciesRepository, @@ -52,6 +54,8 @@ class SaveCryptoCurrenciesUseCase( private val stakingIdFactory: StakingIdFactory, private val networksCleaner: NetworksCleaner, private val stakingCleaner: StakingCleaner, + private val expressServiceFetcher: ExpressServiceFetcher, + private val parallelUpdatingScope: CoroutineScope, private val dispatchers: CoroutineDispatcherProvider, ) { @@ -85,10 +89,11 @@ class SaveCryptoCurrenciesUseCase( derivePublicKeys(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) - val jobs = refreshBalances(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) + + parallelUpdatingScope.launch { + refreshBalances(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) + refreshExpress(userWalletId = userWalletId, currencies = modifiedCurrencyList.total) clearMetadata(userWalletId = userWalletId, currencies = modifiedCurrencyList.removed) - - jobs.joinAll() + } } } @@ -114,7 +119,14 @@ class SaveCryptoCurrenciesUseCase( val tokenToAdd = findToken(userWalletId, contractAddress, networkId) - refreshBalances(userWalletId = userWalletId, currencies = listOf(tokenToAdd)).joinAll() + val modifiedCurrencyList = account.cryptoCurrencies.modify(add = listOf(tokenToAdd), remove = emptyList()) + + saveAccount(account = account.copy(cryptoCurrencies = modifiedCurrencyList.total.toSet())) + + parallelUpdatingScope.launch { + refreshBalances(userWalletId = userWalletId, currencies = listOf(tokenToAdd)) + refreshExpress(userWalletId = userWalletId, currencies = modifiedCurrencyList.total) + } tokenToAdd } @@ -234,15 +246,13 @@ class SaveCryptoCurrenciesUseCase( ) } - private suspend fun refreshBalances(userWalletId: UserWalletId, currencies: List): List { - if (currencies.isEmpty()) return emptyList() + private suspend fun refreshBalances(userWalletId: UserWalletId, currencies: List) { + if (currencies.isEmpty()) return - return coroutineScope { - listOf( - launch { refreshNetworks(userWalletId = userWalletId, currencies = currencies) }, - launch { refreshYieldBalances(userWalletId = userWalletId, currencies = currencies) }, - launch { refreshQuotes(currencies = currencies) }, - ) + coroutineScope { + launch { refreshNetworks(userWalletId = userWalletId, currencies = currencies) } + launch { refreshYieldBalances(userWalletId = userWalletId, currencies = currencies) } + launch { refreshQuotes(currencies = currencies) } } } @@ -276,8 +286,25 @@ class SaveCryptoCurrenciesUseCase( ) } - private suspend fun clearMetadata(userWalletId: UserWalletId, currencies: List): List { - if (currencies.isEmpty()) return emptyList() + private suspend fun refreshExpress(userWalletId: UserWalletId, currencies: List) { + if (currencies.isEmpty()) return + + coroutineScope { + launch { + val assetIds = currencies.mapTo(hashSetOf()) { + ExpressAsset.ID( + networkId = it.network.backendId, + contractAddress = (it as? CryptoCurrency.Token)?.contractAddress, + ) + } + + expressServiceFetcher.fetch(userWalletId = userWalletId, assetIds = assetIds) + } + } + } + + private suspend fun clearMetadata(userWalletId: UserWalletId, currencies: List) { + if (currencies.isEmpty()) return return coroutineScope { listOf( diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactory.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactory.kt index bd7df0e9f4..b4597e4746 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactory.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactory.kt @@ -62,7 +62,6 @@ internal class CryptoCurrencyStatusesFlowFactory @Inject constructor( CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading), ) } - .conflate() .distinctUntilChanged() } @@ -88,6 +87,7 @@ internal class CryptoCurrencyStatusesFlowFactory @Inject constructor( val quoteStatusFlow = currency.id.rawCurrencyId?.let(::getQuoteStatusFlow) return combine(networkStatusFlow, yieldBalanceFlow, quoteStatusFlow) + .distinctUntilChanged() } private fun combine( @@ -120,7 +120,6 @@ internal class CryptoCurrencyStatusesFlowFactory @Inject constructor( return singleNetworkStatusSupplier( params = SingleNetworkStatusProducer.Params(userWalletId = userWalletId, network = network), ) - .conflate() .distinctUntilChanged() } @@ -128,7 +127,6 @@ internal class CryptoCurrencyStatusesFlowFactory @Inject constructor( return singleQuoteStatusSupplier( params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawCurrencyId), ) - .conflate() .distinctUntilChanged() } @@ -147,7 +145,6 @@ internal class CryptoCurrencyStatusesFlowFactory @Inject constructor( singleYieldBalanceSupplier( params = SingleYieldBalanceProducer.Params(userWalletId = userWalletId, stakingId = stakingId), ) - .conflate() .distinctUntilChanged() } else { flowOf(null) diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt index 26f4e21fc4..aa67bf21fe 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt @@ -212,9 +212,9 @@ data object Wallet2CardConfig : CardConfig { Blockchain.HyperliquidTestnet -> EllipticCurve.Secp256k1 Blockchain.Quai -> EllipticCurve.Secp256k1 Blockchain.QuaiTestnet -> EllipticCurve.Secp256k1 - // Blockchain.Linea -> EllipticCurve.Secp256k1 - // Blockchain.LineaTestnet -> EllipticCurve.Secp256k1 - // Blockchain.ArbitrumNova -> EllipticCurve.Secp256k1 + Blockchain.Linea -> EllipticCurve.Secp256k1 + Blockchain.LineaTestnet -> EllipticCurve.Secp256k1 + Blockchain.ArbitrumNova -> EllipticCurve.Secp256k1 } } } \ No newline at end of file diff --git a/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt b/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt index 10dcac6737..d211d4c8c9 100644 --- a/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt +++ b/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt @@ -168,9 +168,9 @@ class Wallet2CardConfigTest { Blockchain.HyperliquidTestnet to EllipticCurve.Secp256k1, Blockchain.Quai to EllipticCurve.Secp256k1, Blockchain.QuaiTestnet to EllipticCurve.Secp256k1, - // Blockchain.Linea to EllipticCurve.Secp256k1, - // Blockchain.LineaTestnet to EllipticCurve.Secp256k1, - // Blockchain.ArbitrumNova to EllipticCurve.Secp256k1, + Blockchain.Linea to EllipticCurve.Secp256k1, + Blockchain.LineaTestnet to EllipticCurve.Secp256k1, + Blockchain.ArbitrumNova to EllipticCurve.Secp256k1, ) @Test diff --git a/domain/express/build.gradle.kts b/domain/express/build.gradle.kts index fc0bfacaa9..3b501122fc 100644 --- a/domain/express/build.gradle.kts +++ b/domain/express/build.gradle.kts @@ -1,24 +1,10 @@ plugins { - alias(deps.plugins.android.library) - alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.jvm) alias(deps.plugins.kotlin.serialization) id("configuration") } -android { - namespace = "com.tangem.domain.express.models" -} - dependencies { - /** Core */ - implementation(projects.core.utils) - implementation(projects.core.datasource) - - /** Domain */ - implementation(projects.domain.express.models) + api(projects.domain.express.models) api(projects.domain.models) - implementation(projects.domain.tokens.models) - - /** Other */ - implementation(deps.moshi.adapters) } \ No newline at end of file diff --git a/domain/express/models/build.gradle.kts b/domain/express/models/build.gradle.kts index a7e2520b52..701087a4b1 100644 --- a/domain/express/models/build.gradle.kts +++ b/domain/express/models/build.gradle.kts @@ -1,12 +1,10 @@ plugins { alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.kotlin.serialization) id("configuration") } dependencies { - /** Domain */ - implementation(projects.domain.tokens.models) - - /* Other */ implementation(deps.moshi.adapters) + implementation(deps.kotlin.serialization) } \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressAsset.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressAsset.kt new file mode 100644 index 0000000000..0d8b046954 --- /dev/null +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressAsset.kt @@ -0,0 +1,48 @@ +package com.tangem.domain.express.models + +import kotlinx.serialization.Serializable + +/** + * Express asset model. + * + * @property id The unique identifier of the Express asset. + * @property isExchangeAvailable Indicates if exchange is available for this asset. + * @property isOnrampAvailable Indicates if onramp is available for this asset (nullable). + * +[REDACTED_AUTHOR] + */ +@Serializable +data class ExpressAsset( + val id: ID, + val isExchangeAvailable: Boolean, + val isOnrampAvailable: Boolean?, +) { + + /** + * Unique identifier for an Express asset, consisting of network ID and contract address. + * + * @property networkId The network ID of the asset. + * @property contractAddress The contract address of the asset. + */ + @Serializable + data class ID(val networkId: String, val contractAddress: String) { + + companion object { + + /** + * Creates an [ID] instance, defaulting the contract address to "0" if null. + * + * @param networkId The network ID of the asset. + * @param contractAddress The contract address of the asset, or null to default to "0". + * @return An [ID] instance with the specified network ID and contract address. + */ + operator fun invoke(networkId: String, contractAddress: String?): ID { + return ID(networkId = networkId, contractAddress = contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE) + } + } + } + + companion object { + const val EMPTY_CONTRACT_ADDRESS_VALUE = "0" + } +} \ No newline at end of file diff --git a/domain/express/src/main/java/com/tangem/domain/express/ExpressServiceFetcher.kt b/domain/express/src/main/java/com/tangem/domain/express/ExpressServiceFetcher.kt new file mode 100644 index 0000000000..1d7d7b1dbc --- /dev/null +++ b/domain/express/src/main/java/com/tangem/domain/express/ExpressServiceFetcher.kt @@ -0,0 +1,38 @@ +package com.tangem.domain.express + +import arrow.core.Either +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.express.models.ExpressAsset +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow + +/** + * Service interface to fetch Express assets data and monitor initialization status. + */ +interface ExpressServiceFetcher { + + /** + * Fetches Express assets data for the given user wallet ID and asset IDs. + * + * @param userWalletId The ID of the user wallet. + * @param assetIds The list of Express asset IDs to fetch. + */ + suspend fun fetch(userWalletId: UserWalletId, assetIds: Set): Either + + /** + * Fetches Express assets data for the given user wallet and asset IDs. + * + * @param userWallet The user wallet for which to fetch assets. + * @param assetIds The list of Express asset IDs to fetch. + */ + suspend fun fetch(userWallet: UserWallet, assetIds: Set): Either + + /** + * Returns a flow that emits the initialization status of Express assets for the given user wallet ID. + * + * @param userWalletId The ID of the user wallet. + * @return A flow emitting Lce states containing either a list of Express assets or an error. + */ + fun getInitializationStatus(userWalletId: UserWalletId): Flow>> +} \ 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 5649d73a23..df2d3bd01e 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 @@ -1,5 +1,6 @@ package com.tangem.domain.feedback.models +import com.tangem.domain.visa.model.TangemPayTxHistoryItem import com.tangem.domain.visa.model.VisaTxDetails /** @@ -64,5 +65,10 @@ sealed interface FeedbackEmailType { val visaTxDetails: VisaTxDetails, override val walletMetaInfo: WalletMetaInfo, ) : Visa() + + data class DisputeV2( + val item: TangemPayTxHistoryItem, + override val walletMetaInfo: WalletMetaInfo, + ) : 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 e5f65f4c77..6431a2da48 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 @@ -2,6 +2,7 @@ package com.tangem.domain.feedback import com.tangem.domain.feedback.models.* import com.tangem.domain.feedback.utils.breakLine +import com.tangem.domain.visa.model.TangemPayTxHistoryItem import com.tangem.domain.visa.model.VisaTxDetails import com.tangem.domain.feedback.models.BlockchainInfo.Addresses as BlockchainAddresses @@ -9,6 +10,10 @@ internal class FeedbackDataBuilder { private val builder = StringBuilder() + fun addTangemPayTxInfo(item: TangemPayTxHistoryItem) { + builder.append(item.jsonRepresentation) + } + fun addVisaTxInfo(txDetails: VisaTxDetails) { builder.appendKeyValue("Type", txDetails.type) builder.appendKeyValue("Status", txDetails.status) 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 a8d5177b41..0aff9662cf 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 @@ -64,6 +64,7 @@ class SendFeedbackEmailUseCase( is FeedbackEmailType.PreActivatedWallet, is FeedbackEmailType.CardAttestationFailed, is FeedbackEmailType.Visa.Dispute, + is FeedbackEmailType.Visa.DisputeV2, -> this is FeedbackEmailType.DirectUserRequest, is FeedbackEmailType.RateCanBeBetter, 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 5a94a88857..cf9a7a8208 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 @@ -1,9 +1,10 @@ package com.tangem.domain.feedback.utils import com.tangem.domain.feedback.FeedbackDataBuilder -import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.feedback.repository.FeedbackRepository +import com.tangem.domain.visa.model.TangemPayTxHistoryItem import com.tangem.domain.visa.model.VisaTxDetails /** @@ -33,11 +34,21 @@ internal class EmailMessageBodyResolver( is FeedbackEmailType.Visa.Activation -> addUserRequestBody(type.walletMetaInfo) is FeedbackEmailType.Visa.DirectUserRequest -> addUserRequestBody(type.walletMetaInfo) is FeedbackEmailType.Visa.Dispute -> addVisaRequestBody(type.walletMetaInfo, type.visaTxDetails) + is FeedbackEmailType.Visa.DisputeV2 -> addTangemPayRequestBody(type.walletMetaInfo, type.item) } return build() } + private suspend fun FeedbackDataBuilder.addTangemPayRequestBody( + walletMetaInfo: WalletMetaInfo, + item: TangemPayTxHistoryItem, + ) { + addUserRequestBody(walletMetaInfo) + addDelimiter() + addTangemPayTxInfo(item) + } + private suspend fun FeedbackDataBuilder.addVisaRequestBody( walletMetaInfo: WalletMetaInfo, visaTxDetails: VisaTxDetails, 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 583f43cb02..7a76916fd1 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 @@ -23,6 +23,7 @@ internal class EmailMessageTitleResolver(private val resources: Resources) { is FeedbackEmailType.Visa.Activation, is FeedbackEmailType.Visa.DirectUserRequest, is FeedbackEmailType.Visa.Dispute, + is FeedbackEmailType.Visa.DisputeV2, -> 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 490c6611dd..e930678eae 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 @@ -39,7 +39,9 @@ internal class EmailSubjectResolver(private val resources: Resources) { FeedbackEmailType.CardAttestationFailed -> "Card attestation failed" is FeedbackEmailType.Visa.Activation -> "[Visa] [Activation] {auto-filled subject}" is FeedbackEmailType.Visa.DirectUserRequest -> "[Visa] {auto-filled subject}" - is FeedbackEmailType.Visa.Dispute -> "[Visa] [DISPUTE] {auto-filled subject}" + is FeedbackEmailType.Visa.Dispute, + is FeedbackEmailType.Visa.DisputeV2, + -> "[Visa] [DISPUTE] {auto-filled subject}" } } } \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/RemoveCustomManagedCryptoCurrencyUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/RemoveCustomManagedCryptoCurrencyUseCase.kt index a359f27192..585ef118ec 100644 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/RemoveCustomManagedCryptoCurrencyUseCase.kt +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/RemoveCustomManagedCryptoCurrencyUseCase.kt @@ -5,6 +5,7 @@ import com.tangem.domain.managetokens.model.ManagedCryptoCurrency import com.tangem.domain.managetokens.repository.CustomTokensRepository import com.tangem.domain.models.wallet.UserWalletId +@Deprecated("Use SaveCryptoCurrenciesUseCase") class RemoveCustomManagedCryptoCurrencyUseCase(private val repository: CustomTokensRepository) { suspend operator fun invoke( diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt index 6c1b21b89b..fa98ae01b7 100644 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt @@ -15,6 +15,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.derivations.DerivationsRepository +@Deprecated("Use SaveCryptoCurrenciesUseCase") @Suppress("LongParameterList") class SaveManagedTokensUseCase( private val customTokensRepository: CustomTokensRepository, diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensListConfig.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensListConfig.kt index 9c4cda460b..8ac66e350c 100644 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensListConfig.kt +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensListConfig.kt @@ -1,8 +1,26 @@ package com.tangem.domain.managetokens.model +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWalletId -data class ManageTokensListConfig( - val userWalletId: UserWalletId?, - val searchText: String?, -) \ No newline at end of file +sealed interface ManageTokensListConfig { + + val userWalletId: UserWalletId? + val searchText: String? + + // old way + data class Wallet( + override val userWalletId: UserWalletId?, + override val searchText: String?, + ) : ManageTokensListConfig + + // new way + data class Account( + val accountId: AccountId?, + override val searchText: String?, + ) : ManageTokensListConfig { + + override val userWalletId: UserWalletId? + get() = accountId?.userWalletId + } +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt index c21e6179bb..12558a0a45 100644 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt @@ -43,8 +43,14 @@ interface CustomTokensRepository { formValues: AddCustomTokenForm.Validated.All, ): CryptoCurrency.Token + @Deprecated("Use SaveCryptoCurrenciesUseCase") suspend fun removeCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom) + suspend fun convertToCryptoCurrency( + userWalletId: UserWalletId, + currency: ManagedCryptoCurrency.Custom, + ): CryptoCurrency + suspend fun getSupportedNetworks(userWalletId: UserWalletId): List fun createDerivationPath(rawPath: String): Network.DerivationPath diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt index 3417a876fd..4e5c8aa6d6 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt @@ -1,7 +1,6 @@ package com.tangem.domain.markets import arrow.core.Either -import com.tangem.domain.wallets.derivations.DerivationsRepository import com.tangem.domain.markets.repositories.MarketsTokenRepository import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -11,6 +10,7 @@ import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.wallets.derivations.DerivationsRepository /** * Use case for saving tokens from Markets @@ -21,6 +21,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository * [REDACTED_AUTHOR] */ +@Deprecated("Use SaveCryptoCurrenciesUseCase") @Suppress("LongParameterList") class SaveMarketTokensUseCase( private val derivationsRepository: DerivationsRepository, diff --git a/domain/models/build.gradle.kts b/domain/models/build.gradle.kts index f2d3d5a0cf..5cb3097c77 100644 --- a/domain/models/build.gradle.kts +++ b/domain/models/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementation(deps.moshi.kotlin) implementation(deps.moshi.adapters) implementation(deps.kotlin.datetime) + implementation(deps.jodatime) implementation(deps.kotlin.serialization) ksp(deps.moshi.kotlin.codegen) implementation(deps.arrow.core) diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/serialization/CurrencySerializer.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/serialization/CurrencySerializer.kt new file mode 100644 index 0000000000..b17a17658f --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/serialization/CurrencySerializer.kt @@ -0,0 +1,24 @@ +package com.tangem.domain.models.serialization + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.util.Currency + +typealias SerializedCurrency = @Serializable(with = CurrencySerializer::class) Currency + +object CurrencySerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("java.util.Currency", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Currency) { + encoder.encodeString(value.currencyCode) + } + + override fun deserialize(decoder: Decoder): Currency { + return Currency.getInstance(decoder.decodeString()) + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/serialization/JodaDateTimeSerializer.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/serialization/JodaDateTimeSerializer.kt new file mode 100644 index 0000000000..d72729ef78 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/serialization/JodaDateTimeSerializer.kt @@ -0,0 +1,24 @@ +package com.tangem.domain.models.serialization + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.joda.time.DateTime + +typealias SerializedDateTime = @Serializable(with = JodaDateTimeSerializer::class) DateTime + +object JodaDateTimeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("DateTime", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: DateTime) { + encoder.encodeLong(value.millis) + } + + override fun deserialize(decoder: Decoder): DateTime { + return DateTime(decoder.decodeLong()) + } +} \ No newline at end of file diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt index 56700b01f4..ff2085d060 100644 --- a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampOffer.kt @@ -15,7 +15,7 @@ data class OnrampOffer( ) enum class OnrampOfferAdvantages { - Default, BestRate, Fastest, + Default, BestRate, Fastest, GreatRate, } enum class OnrampOfferCategory { diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt index 17c2e41ae0..e1049344b2 100644 --- a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethod.kt @@ -18,31 +18,53 @@ enum class PaymentMethodType(val id: String?) { OTHER(id = null), ; + /** + * Get priority regardless of real speed. By business logic. + */ @Suppress("MagicNumber") - fun getPriority(isGooglePayEnabled: Boolean): Int = if (isGooglePayEnabled) { + fun getPriorityForMethod(isGooglePayEnabled: Boolean): Int = if (isGooglePayEnabled) { when (this) { GOOGLE_PAY -> 0 CARD -> 1 - SEPA -> 2 - REVOLUT_PAY -> 3 + REVOLUT_PAY -> 2 + SEPA -> 3 OTHER -> 4 } } else { when (this) { - CARD -> 0 - GOOGLE_PAY -> 1 + CARD -> 2 + REVOLUT_PAY -> 1 SEPA -> 2 - REVOLUT_PAY -> 3 + OTHER -> 3 + GOOGLE_PAY -> 4 + } + } + + @Suppress("MagicNumber") + fun getPriorityBySpeed(isGooglePayEnabled: Boolean): Int = if (isGooglePayEnabled) { + when (this) { + GOOGLE_PAY -> 0 + REVOLUT_PAY -> 1 + CARD -> 2 + SEPA -> 3 OTHER -> 4 } + } else { + when (this) { + REVOLUT_PAY -> 0 + CARD -> 1 + SEPA -> 2 + OTHER -> 3 + GOOGLE_PAY -> 4 + } } /** * BE AWARE. HARDCODED. Returns the speed of transaction for payment method type. */ fun getProcessingSpeed(): PaymentSpeed = when (this) { - REVOLUT_PAY, GOOGLE_PAY, + REVOLUT_PAY, -> PaymentSpeed.Instant CARD -> PaymentSpeed.FewMin SEPA -> PaymentSpeed.FewDays diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethodGroup.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethodGroup.kt index 9a957d44ff..68d0fd7c24 100644 --- a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethodGroup.kt +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampPaymentMethodGroup.kt @@ -8,12 +8,10 @@ data class OnrampPaymentMethodGroup( val bestRateOffer: OnrampOffer?, val providerCount: Int, val isBestPaymentMethod: Boolean, -) { + val methodStatus: PaymentMethodStatus, +) - val bestRateAmount: BigDecimal? = bestRateOffer?.let { offer -> - when (val quote = offer.quote) { - is OnrampQuote.Data -> quote.toAmount.value - else -> BigDecimal.ZERO - } - } +sealed interface PaymentMethodStatus { + data object Available : PaymentMethodStatus + data class Unavailable(val availableFrom: BigDecimal) : PaymentMethodStatus } \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt index a7e1d2754f..148202162c 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampAllOffersUseCase.kt @@ -5,10 +5,7 @@ import arrow.core.right import com.tangem.domain.core.utils.EitherFlow import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.onramp.model.OnrampOffer -import com.tangem.domain.onramp.model.OnrampOfferAdvantages -import com.tangem.domain.onramp.model.OnrampPaymentMethodGroup -import com.tangem.domain.onramp.model.OnrampQuote +import com.tangem.domain.onramp.model.* import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.domain.onramp.repositories.OnrampErrorResolver import com.tangem.domain.onramp.repositories.OnrampRepository @@ -35,17 +32,73 @@ class GetOnrampAllOffersUseCase( } private suspend fun processAllOffers(quotes: List): List { - val validQuotes = quotes.filterIsInstance() - if (validQuotes.isEmpty()) return emptyList() + val relevantQuotes = quotes.filter { it !is OnrampQuote.Error } + if (relevantQuotes.isEmpty()) return emptyList() + val isGooglePayAvailable = settingsRepository.isGooglePayAvailability() - val overallBestRateQuote = validQuotes.maxWithOrNull(compareOffersByRateSpeedAndPriority(isGooglePayAvailable)) + val validQuotes = relevantQuotes.filterIsInstance() + val overallBestRateQuote = findOverallBestRateQuote(validQuotes, isGooglePayAvailable) val bestRate = overallBestRateQuote?.toAmount?.value - val offersByPaymentMethod = validQuotes.groupBy { it.paymentMethod } + val offersByPaymentMethod = relevantQuotes.groupBy { it.paymentMethod } - return offersByPaymentMethod.map { (paymentMethod, methodQuotes) -> - val methodOffers = methodQuotes.map { quote -> + return offersByPaymentMethod + .map { (paymentMethod, methodQuotes) -> + createPaymentMethodGroup( + paymentMethod = paymentMethod, + methodQuotes = methodQuotes, + overallBestRateQuote = overallBestRateQuote, + bestRate = bestRate, + isGooglePayAvailable = isGooglePayAvailable, + ) + } + .sortedBy { it.paymentMethod.type.getPriorityForMethod(isGooglePayAvailable) } + } + + private fun findOverallBestRateQuote( + validQuotes: List, + isGooglePayAvailable: Boolean, + ): OnrampQuote.Data? { + return validQuotes.maxWithOrNull( + compareOffersByRateSpeedAndPriority( + isGooglePayAvailable = isGooglePayAvailable, + isSepaPrioritized = false, + ), + ) + } + + private fun createPaymentMethodGroup( + paymentMethod: OnrampPaymentMethod, + methodQuotes: List, + overallBestRateQuote: OnrampQuote.Data?, + bestRate: BigDecimal?, + isGooglePayAvailable: Boolean, + ): OnrampPaymentMethodGroup { + val methodOffers = methodQuotes.mapNotNull { quote -> + createOffer(quote, overallBestRateQuote, bestRate) + } + + val groupBestRateQuote = findGroupBestRateQuote(methodQuotes, isGooglePayAvailable) + val groupBestRateOffer = findBestRateOffer(methodOffers, groupBestRateQuote) + + return OnrampPaymentMethodGroup( + paymentMethod = paymentMethod, + offers = sortOffers(methodOffers), + providerCount = countUniqueProviders(methodOffers), + bestRateOffer = groupBestRateOffer, + isBestPaymentMethod = overallBestRateQuote?.paymentMethod == paymentMethod, + methodStatus = determineMethodStatus(methodQuotes), + ) + } + + private fun createOffer( + quote: OnrampQuote, + overallBestRateQuote: OnrampQuote.Data?, + bestRate: BigDecimal?, + ): OnrampOffer? { + return when (quote) { + is OnrampQuote.Data -> { val advantages = if (quote == overallBestRateQuote) { OnrampOfferAdvantages.BestRate } else { @@ -54,28 +107,70 @@ class GetOnrampAllOffersUseCase( val rateDif = calculateRateDif(quote.toAmount.value, bestRate) OnrampOffer(quote = quote, rateDif = rateDif, advantages = advantages) } - - val groupBestRateOfferData = - methodQuotes.maxWithOrNull(compareOffersByRateSpeedAndPriority(isGooglePayAvailable)) - val groupBestRateOffer = methodOffers.find { - when (val quote = it.quote) { - is OnrampQuote.Data -> quote == groupBestRateOfferData - else -> false - } + is OnrampQuote.AmountError -> { + OnrampOffer(quote = quote, rateDif = null, advantages = OnrampOfferAdvantages.Default) } + is OnrampQuote.Error -> null + } + } - OnrampPaymentMethodGroup( - paymentMethod = paymentMethod, - offers = methodOffers.sortedByDescending { offer -> - when (val quote = offer.quote) { - is OnrampQuote.Data -> quote.toAmount.value - else -> BigDecimal.ZERO - } - }, - providerCount = methodOffers.map { it.quote.provider.id }.distinct().size, - bestRateOffer = groupBestRateOffer, - isBestPaymentMethod = overallBestRateQuote?.paymentMethod == paymentMethod, - ) - }.sortedBy { it.paymentMethod.type.getPriority(isGooglePayAvailable) } + private fun findGroupBestRateQuote( + validMethodQuotes: List, + isGooglePayAvailable: Boolean, + ): OnrampQuote? { + val dataQuotes = validMethodQuotes.filterIsInstance() + val amountErrorQuotes = validMethodQuotes.filterIsInstance() + + return when { + dataQuotes.isNotEmpty() -> { + dataQuotes.maxWithOrNull( + compareOffersByRateSpeedAndPriority( + isGooglePayAvailable = isGooglePayAvailable, + isSepaPrioritized = false, + ), + ) + } + amountErrorQuotes.isNotEmpty() -> { + amountErrorQuotes.minByOrNull { it.error.requiredAmount } + } + else -> validMethodQuotes.firstOrNull() + } + } + + private fun findBestRateOffer(methodOffers: List, groupBestRateQuote: OnrampQuote?): OnrampOffer? { + if (groupBestRateQuote == null) return null + + return methodOffers.find { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> quote == groupBestRateQuote + is OnrampQuote.AmountError -> quote == groupBestRateQuote + is OnrampQuote.Error -> false + } + } + } + + private fun sortOffers(methodOffers: List): List { + return methodOffers.sortedByDescending { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> quote.toAmount.value + else -> BigDecimal.ZERO + } + } + } + + private fun countUniqueProviders(methodOffers: List): Int { + return methodOffers.map { it.quote.provider.id }.distinct().size + } + + private fun determineMethodStatus(methodQuotes: List): PaymentMethodStatus { + val hasAtLeastOneValidQuote = methodQuotes.any { it is OnrampQuote.Data } + return if (hasAtLeastOneValidQuote) { + PaymentMethodStatus.Available + } else { + val minSum = methodQuotes + .filterIsInstance() + .minOfOrNull { it.error.requiredAmount } ?: BigDecimal.ZERO + PaymentMethodStatus.Unavailable(minSum) + } } } \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt index 81ee5e82fd..f6ea2fd0c3 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt @@ -47,7 +47,12 @@ class GetOnrampOffersUseCase( if (validQuotes.isEmpty()) return emptyList() val isGooglePayAvailable = settingsRepository.isGooglePayAvailability() - val bestRateQuote = validQuotes.maxWithOrNull(compareOffersByRateSpeedAndPriority(isGooglePayAvailable)) + val bestRateQuote = validQuotes.maxWithOrNull( + compareOffersByRateSpeedAndPriority( + isGooglePayAvailable = isGooglePayAvailable, + isSepaPrioritized = true, + ), + ) val bestRate = bestRateQuote?.toAmount?.value val offers = validQuotes.map { quote -> @@ -71,8 +76,8 @@ class GetOnrampOffersUseCase( val lastTransaction = transactions.maxByOrNull { it.timestamp } ?: return null return offers.find { offer -> - offer.quote.provider.id == lastTransaction.providerType && - offer.quote.paymentMethod.id == lastTransaction.paymentMethod + offer.quote.provider.info.name == lastTransaction.providerName && + offer.quote.paymentMethod.name == lastTransaction.paymentMethod && isRecentUsed(lastTransaction.status) } } @@ -83,14 +88,14 @@ class GetOnrampOffersUseCase( private fun findFastestOffer(offers: List, isGooglePayAvailable: Boolean): OnrampOffer? { val instantOffers = offers.filter { it.quote.paymentMethod.type.isInstant() } return if (instantOffers.isNotEmpty()) { - instantOffers.maxWithOrNull(offerComparator(isGooglePayAvailable)) + instantOffers.maxWithOrNull(fastestOfferComparator(isGooglePayAvailable)) } else { val offersBySpeed = offers.groupBy { offer -> offer.quote.paymentMethod.type.getProcessingSpeed().speed } val fastestSpeed = offersBySpeed.keys.minOrNull() ?: return null val fastestOffers = offersBySpeed[fastestSpeed] ?: return null - fastestOffers.maxWithOrNull(offerComparator(isGooglePayAvailable)) + fastestOffers.maxWithOrNull(fastestOfferComparator(isGooglePayAvailable)) } } @@ -99,7 +104,10 @@ class GetOnrampOffersUseCase( is OnrampQuote.Data -> { when (val quote2 = offer2.quote) { is OnrampQuote.Data -> { - compareOffersByRateSpeedAndPriority(isGooglePayAvailable).compare(quote1, quote2) + compareOffersByRateSpeedAndPriority( + isGooglePayAvailable = isGooglePayAvailable, + isSepaPrioritized = true, + ).compare(quote1, quote2) } else -> 1 } @@ -108,6 +116,30 @@ class GetOnrampOffersUseCase( } } + private fun fastestOfferComparator(isGooglePayAvailable: Boolean): Comparator = + Comparator { offer1, offer2 -> + when (val quote1 = offer1.quote) { + is OnrampQuote.Data -> { + when (val quote2 = offer2.quote) { + is OnrampQuote.Data -> { + // For fastest offer, first compare by priority of speed + val priorityComparison = quote2.paymentMethod.type.getPriorityBySpeed(isGooglePayAvailable) + .compareTo(quote1.paymentMethod.type.getPriorityBySpeed(isGooglePayAvailable)) + + // If priorities are equal, compare by rate + if (priorityComparison != 0) { + priorityComparison + } else { + quote1.toAmount.value.compareTo(quote2.toAmount.value) + } + } + else -> 1 + } + } + else -> -1 + } + } + private fun buildOffersBlocks( recentOffer: OnrampOffer?, bestRateOffer: OnrampOffer?, @@ -143,7 +175,7 @@ class GetOnrampOffersUseCase( ) } - if (recommendedOffers.isNotEmpty() && hasOnlyOneMethodAndProvider(allOffers).not()) { + if (recommendedOffers.isNotEmpty()) { add( OnrampOffersBlock( category = OnrampOfferCategory.Recommended, @@ -161,10 +193,10 @@ class GetOnrampOffersUseCase( fastestOffer: OnrampOffer?, ): OnrampOfferAdvantages { if (isSameOffer(recentOffer, bestRateOffer) && isSameOffer(recentOffer, fastestOffer)) { - return OnrampOfferAdvantages.BestRate + return OnrampOfferAdvantages.GreatRate } if (isSameOffer(recentOffer, bestRateOffer)) { - return OnrampOfferAdvantages.BestRate + return OnrampOfferAdvantages.GreatRate } if (isSameOffer(recentOffer, fastestOffer)) { return OnrampOfferAdvantages.Fastest @@ -182,7 +214,7 @@ class GetOnrampOffersUseCase( bestRateOffer?.let { offer -> add( offer.copy( - advantages = OnrampOfferAdvantages.BestRate, + advantages = OnrampOfferAdvantages.GreatRate, rateDif = null, ), ) @@ -191,7 +223,7 @@ class GetOnrampOffersUseCase( if (bestRateOffer != null && !isSameOffer(bestRateOffer, recentOffer)) { add( bestRateOffer.copy( - advantages = OnrampOfferAdvantages.BestRate, + advantages = OnrampOfferAdvantages.GreatRate, rateDif = null, ), ) @@ -211,15 +243,28 @@ class GetOnrampOffersUseCase( } } - private fun hasOnlyOneMethodAndProvider(offers: List): Boolean { - val uniquePaymentMethods = offers.map { it.quote.paymentMethod.id }.distinct() - val uniqueProviders = offers.map { it.quote.provider.id }.distinct() - return uniquePaymentMethods.size == 1 && uniqueProviders.size == 1 - } - private fun isSameOffer(offer1: OnrampOffer?, offer2: OnrampOffer?): Boolean { if (offer1 == null || offer2 == null) return false return offer1.quote.provider.id == offer2.quote.provider.id && offer1.quote.paymentMethod.id == offer2.quote.paymentMethod.id } + + private fun isRecentUsed(onrampStatus: OnrampStatus.Status): Boolean { + return when (onrampStatus) { + OnrampStatus.Status.Created, + OnrampStatus.Status.Expired, + OnrampStatus.Status.Paused, + OnrampStatus.Status.WaitingForPayment, + OnrampStatus.Status.PaymentProcessing, + OnrampStatus.Status.Verifying, + OnrampStatus.Status.Paid, + OnrampStatus.Status.Sending, + OnrampStatus.Status.RefundInProgress, + -> false + OnrampStatus.Status.Failed, + OnrampStatus.Status.Finished, + OnrampStatus.Status.Refunded, + -> true + } + } } \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampPaymentMethodsUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampPaymentMethodsUseCase.kt index f4528c9e4b..6a8c990b1f 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampPaymentMethodsUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampPaymentMethodsUseCase.kt @@ -19,7 +19,7 @@ class GetOnrampPaymentMethodsUseCase( repository.getAvailablePaymentMethods() .toList() - .sortedBy { it.type.getPriority(isGooglePayAvailable) } + .sortedBy { it.type.getPriorityBySpeed(isGooglePayAvailable) } .toSet() }.mapLeft(errorResolver::resolve) } diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampQuotesUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampQuotesUseCase.kt index 24b27d4860..657c529152 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampQuotesUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampQuotesUseCase.kt @@ -26,7 +26,7 @@ class GetOnrampQuotesUseCase( quotes.groupBy { it.paymentMethod.type } .asSequence() - .sortedBy { it.key.getPriority(isGooglePayAvailable) } + .sortedBy { it.key.getPriorityBySpeed(isGooglePayAvailable) } .sortByRate() .toList() .flatten() @@ -53,7 +53,6 @@ class GetOnrampQuotesUseCase( when (val error = it.error) { is OnrampError.AmountError.TooSmallError -> it.fromAmount.value - error.requiredAmount is OnrampError.AmountError.TooBigError -> error.requiredAmount - it.fromAmount.value - else -> null } } } diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampV2QuotesUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampV2QuotesUseCase.kt deleted file mode 100644 index 5039240734..0000000000 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampV2QuotesUseCase.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.tangem.domain.onramp - -import arrow.core.Either -import arrow.core.left -import arrow.core.right -import com.tangem.domain.onramp.model.OnrampQuote -import com.tangem.domain.onramp.model.error.OnrampError -import com.tangem.domain.onramp.repositories.OnrampErrorResolver -import com.tangem.domain.onramp.repositories.OnrampRepository -import com.tangem.domain.settings.repositories.SettingsRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.map -import java.math.BigDecimal -import java.util.Comparator - -class GetOnrampV2QuotesUseCase( - private val settingsRepository: SettingsRepository, - private val repository: OnrampRepository, - private val errorResolver: OnrampErrorResolver, -) { - - operator fun invoke(): Flow>> { - return repository.getQuotes() - .map, Either>> { quotes -> - val isGooglePayAvailable = settingsRepository.isGooglePayAvailability() - quotes.sortedWith( - Comparator.comparing { quote -> - getQuoteSortPriority(quote) - } - .thenComparingInt { quote -> - quote.paymentMethod.type.getPriority(isGooglePayAvailable) - }, - ).right() - } - .catch { - emit(errorResolver.resolve(it).left()) - } - } - - private class SortableBigDecimalWrapper( - val value: BigDecimal?, - val negateForSort: Boolean = false, - ) : Comparable { - override fun compareTo(other: SortableBigDecimalWrapper): Int { - return when { - value == null && other.value == null -> 0 - value == null -> 1 - other.value == null -> -1 - else -> { - val thisValue = if (negateForSort) value.negate() else value - val otherValue = if (other.negateForSort) other.value.negate() else other.value - thisValue.compareTo(otherValue) - } - } - } - } - - private fun getQuoteSortPriority(quote: OnrampQuote): SortableBigDecimalWrapper { - return when (quote) { - is OnrampQuote.Data -> SortableBigDecimalWrapper(quote.toAmount.value, negateForSort = true) - is OnrampQuote.Error -> SortableBigDecimalWrapper(null) - is OnrampQuote.AmountError -> { - when (val error = quote.error) { - is OnrampError.AmountError.TooSmallError -> - SortableBigDecimalWrapper((quote.fromAmount.value - error.requiredAmount).abs()) - is OnrampError.AmountError.TooBigError -> - SortableBigDecimalWrapper((error.requiredAmount - quote.fromAmount.value).abs()) - } - } - } - } -} \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt index 7329a88a84..8d8bb89a05 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/utils/OnrampOfferUtils.kt @@ -1,34 +1,42 @@ package com.tangem.domain.onramp.utils import com.tangem.domain.onramp.model.OnrampQuote +import com.tangem.domain.onramp.model.PaymentMethodType import java.math.BigDecimal internal fun calculateRateDif(currentTokenRate: BigDecimal, bestRate: BigDecimal?): BigDecimal? { if (bestRate == null) return null - return BigDecimal.ONE - currentTokenRate / bestRate + if (currentTokenRate > bestRate) return null + + val rateDif = BigDecimal.ONE - currentTokenRate / bestRate + return if (rateDif >= BigDecimal("0.01")) rateDif else null } -internal fun compareOffersByRateSpeedAndPriority(isGooglePayAvailable: Boolean): Comparator { +/** + * @param isSepaPrioritized Sepa provider should be prioritized over all offers no matter what. + */ +internal fun compareOffersByRateSpeedAndPriority( + isGooglePayAvailable: Boolean, + isSepaPrioritized: Boolean = false, +): Comparator { return Comparator { quote1, quote2 -> - val rateComparison = quote1 - .toAmount - .value - .compareTo(quote2.toAmount.value) + if (isSepaPrioritized) { + val isQuote1Sepa = quote1.paymentMethod.type == PaymentMethodType.SEPA + val isQuote2Sepa = quote2.paymentMethod.type == PaymentMethodType.SEPA + + if (isQuote1Sepa && !isQuote2Sepa) return@Comparator 1 + if (!isQuote1Sepa && isQuote2Sepa) return@Comparator -1 + } + + val rateComparison = quote1.toAmount.value.compareTo(quote2.toAmount.value) if (rateComparison != 0) return@Comparator rateComparison val speedComparison = - quote2 - .paymentMethod - .type - .getProcessingSpeed() - .speed + quote2.paymentMethod.type.getProcessingSpeed().speed .compareTo(quote1.paymentMethod.type.getProcessingSpeed().speed) if (speedComparison != 0) return@Comparator speedComparison - quote1 - .paymentMethod - .type - .getPriority(isGooglePayAvailable) - .compareTo(quote2.paymentMethod.type.getPriority(isGooglePayAvailable)) + quote2.paymentMethod.type.getPriorityBySpeed(isGooglePayAvailable) + .compareTo(quote1.paymentMethod.type.getPriorityBySpeed(isGooglePayAvailable)) } } \ No newline at end of file diff --git a/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampAllOffersUseCaseTest.kt b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampAllOffersUseCaseTest.kt index 89c4b8e6f9..6158e843b4 100644 --- a/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampAllOffersUseCaseTest.kt +++ b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampAllOffersUseCaseTest.kt @@ -3,10 +3,14 @@ package com.tangem.domain.onramp import com.google.common.truth.Truth import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.model.OnrampAmount import com.tangem.domain.onramp.model.OnrampOfferAdvantages import com.tangem.domain.onramp.model.OnrampPaymentMethod import com.tangem.domain.onramp.model.OnrampProvider import com.tangem.domain.onramp.model.OnrampQuote +import com.tangem.domain.onramp.model.PaymentMethodStatus +import com.tangem.domain.onramp.model.PaymentMethodType +import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.domain.onramp.repositories.OnrampErrorResolver import com.tangem.domain.onramp.repositories.OnrampRepository import com.tangem.domain.settings.repositories.SettingsRepository @@ -40,7 +44,7 @@ class GetOnrampAllOffersUseCaseTest { } @Test - fun `invoke should return empty list when no valid quotes`() = runTest { + fun `should return empty list when no quotes`() = runTest { val emptyQuotes = listOf() coEvery { onrampRepository.getQuotes() } returns flowOf(emptyQuotes) @@ -57,16 +61,40 @@ class GetOnrampAllOffersUseCaseTest { } @Test - fun `invoke should return grouped offers with best rate marked`() = runTest { - val paymentMethod1 = createMockPaymentMethod("card", "Card") - val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer") + fun `should return empty list when only Error quotes`() = runTest { + val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD) + val provider = createMockProvider("provider1", "Provider 1") + + val quotes = listOf( + createMockErrorQuote(paymentMethod, provider), + createMockErrorQuote(paymentMethod, provider), + ) + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { settingsRepository.isGooglePayAvailability() } returns false + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> Truth.assertThat(offers).isEmpty() }, + ) + } + } + + @Test + fun `should group offers by payment method with best rate marked`() = runTest { + val paymentMethod1 = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD) + val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer", PaymentMethodType.CARD) val provider1 = createMockProvider("provider1", "Provider 1") val provider2 = createMockProvider("provider2", "Provider 2") val quotes = listOf( - createMockQuote(paymentMethod1, provider1, BigDecimal("100.0")), - createMockQuote(paymentMethod1, provider2, BigDecimal("95.0")), - createMockQuote(paymentMethod2, provider1, BigDecimal("98.0")), + createMockDataQuote(paymentMethod1, provider1, BigDecimal("100.0")), + createMockDataQuote(paymentMethod1, provider2, BigDecimal("95.0")), + createMockDataQuote(paymentMethod2, provider1, BigDecimal("98.0")), ) coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) @@ -86,6 +114,7 @@ class GetOnrampAllOffersUseCaseTest { Truth.assertThat(cardGroup?.offers).hasSize(2) Truth.assertThat(cardGroup?.providerCount).isEqualTo(2) Truth.assertThat(cardGroup?.isBestPaymentMethod).isTrue() + Truth.assertThat(cardGroup?.methodStatus).isEqualTo(PaymentMethodStatus.Available) val bestRateOffer = cardGroup?.offers?.find { it.advantages == OnrampOfferAdvantages.BestRate } Truth.assertThat(bestRateOffer).isNotNull() @@ -95,6 +124,7 @@ class GetOnrampAllOffersUseCaseTest { Truth.assertThat(bankGroup?.offers).hasSize(1) Truth.assertThat(bankGroup?.providerCount).isEqualTo(1) Truth.assertThat(bankGroup?.isBestPaymentMethod).isFalse() + Truth.assertThat(bankGroup?.methodStatus).isEqualTo(PaymentMethodStatus.Available) }, ) } @@ -104,14 +134,14 @@ class GetOnrampAllOffersUseCaseTest { } @Test - fun `invoke should sort offers by toAmount descending`() = runTest { - val paymentMethod = createMockPaymentMethod("card", "Card") + fun `should sort offers by toAmount descending`() = runTest { + val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD) val provider = createMockProvider("provider1", "Provider 1") val quotes = listOf( - createMockQuote(paymentMethod, provider, BigDecimal("90.0")), - createMockQuote(paymentMethod, provider, BigDecimal("100.0")), - createMockQuote(paymentMethod, provider, BigDecimal("95.0")), + createMockDataQuote(paymentMethod, provider, BigDecimal("90.0")), + createMockDataQuote(paymentMethod, provider, BigDecimal("100.0")), + createMockDataQuote(paymentMethod, provider, BigDecimal("95.0")), ) coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) @@ -144,13 +174,248 @@ class GetOnrampAllOffersUseCaseTest { } } - private fun createMockPaymentMethod(id: String, name: String): OnrampPaymentMethod { + @Test + fun `should include AmountError offers in group`() = runTest { + val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD) + val provider1 = createMockProvider("provider1", "Provider 1") + val provider2 = createMockProvider("provider2", "Provider 2") + + val quotes = listOf( + createMockDataQuote(paymentMethod, provider1, BigDecimal("100.0")), + createMockAmountErrorQuote(paymentMethod, provider2), + ) + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { settingsRepository.isGooglePayAvailability() } returns false + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(1) + + val cardGroup = offers.first() + Truth.assertThat(cardGroup.offers).hasSize(2) + Truth.assertThat(cardGroup.methodStatus).isEqualTo(PaymentMethodStatus.Available) + + val dataOffer = cardGroup.offers.find { it.quote is OnrampQuote.Data } + Truth.assertThat(dataOffer).isNotNull() + Truth.assertThat(dataOffer?.rateDif).isNull() // Best rate offer has null rateDif + + val amountErrorOffer = cardGroup.offers.find { it.quote is OnrampQuote.AmountError } + Truth.assertThat(amountErrorOffer).isNotNull() + Truth.assertThat(amountErrorOffer?.rateDif).isNull() + Truth.assertThat(amountErrorOffer?.advantages).isEqualTo(OnrampOfferAdvantages.Default) + }, + ) + } + } + + @Test + fun `should mark payment method as Unavailable when only AmountError offers`() = runTest { + val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD) + val provider1 = createMockProvider("provider1", "Provider 1") + val provider2 = createMockProvider("provider2", "Provider 2") + + val quotes = listOf( + createMockAmountErrorQuote(paymentMethod, provider1, requiredAmount = BigDecimal("100.0")), + createMockAmountErrorQuote(paymentMethod, provider2, requiredAmount = BigDecimal("50.0")), + ) + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { settingsRepository.isGooglePayAvailability() } returns false + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(1) + + val cardGroup = offers.first() + Truth.assertThat( + cardGroup.methodStatus, + ).isEqualTo(PaymentMethodStatus.Unavailable(BigDecimal("50.0"))) + Truth.assertThat(cardGroup.offers).hasSize(2) + + Truth.assertThat(cardGroup.bestRateOffer).isNotNull() + val bestQuote = cardGroup.bestRateOffer?.quote as? OnrampQuote.AmountError + Truth.assertThat(bestQuote?.error?.requiredAmount).isEqualTo(BigDecimal("50.0")) + + Truth.assertThat(cardGroup.isBestPaymentMethod).isFalse() + + cardGroup.offers.forEach { offer -> + Truth.assertThat(offer.quote).isInstanceOf(OnrampQuote.AmountError::class.java) + Truth.assertThat(offer.rateDif).isNull() + } + }, + ) + } + } + + @Test + fun `should not include Error quotes in groups`() = runTest { + val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD) + val provider1 = createMockProvider("provider1", "Provider 1") + val provider2 = createMockProvider("provider2", "Provider 2") + + val quotes = listOf( + createMockDataQuote(paymentMethod, provider1, BigDecimal("100.0")), + createMockErrorQuote(paymentMethod, provider2), + ) + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { settingsRepository.isGooglePayAvailability() } returns false + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(1) + + val cardGroup = offers.first() + Truth.assertThat(cardGroup.offers).hasSize(1) + Truth.assertThat(cardGroup.offers.first().quote).isInstanceOf(OnrampQuote.Data::class.java) + Truth.assertThat(cardGroup.methodStatus).isEqualTo(PaymentMethodStatus.Available) + }, + ) + } + } + + @Test + fun `should prioritize Data over AmountError for best rate`() = runTest { + val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD) + val provider1 = createMockProvider("provider1", "Provider 1") + val provider2 = createMockProvider("provider2", "Provider 2") + val provider3 = createMockProvider("provider3", "Provider 3") + + val quotes = listOf( + createMockDataQuote(paymentMethod, provider1, BigDecimal("90.0")), + createMockAmountErrorQuote(paymentMethod, provider2, requiredAmount = BigDecimal("10.0")), + createMockDataQuote(paymentMethod, provider3, BigDecimal("100.0")), + ) + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { settingsRepository.isGooglePayAvailability() } returns false + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(1) + + val cardGroup = offers.first() + Truth.assertThat(cardGroup.offers).hasSize(3) + Truth.assertThat(cardGroup.methodStatus).isEqualTo(PaymentMethodStatus.Available) + + Truth.assertThat(cardGroup.bestRateOffer).isNotNull() + val bestQuote = cardGroup.bestRateOffer?.quote as? OnrampQuote.Data + Truth.assertThat(bestQuote?.toAmount?.value).isEqualTo(BigDecimal("100.0")) + }, + ) + } + } + + @Test + fun `should sort AmountError offers to bottom`() = runTest { + val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD) + val provider1 = createMockProvider("provider1", "Provider 1") + val provider2 = createMockProvider("provider2", "Provider 2") + val provider3 = createMockProvider("provider3", "Provider 3") + + val quotes = listOf( + createMockAmountErrorQuote(paymentMethod, provider1, requiredAmount = BigDecimal("50.0")), + createMockDataQuote(paymentMethod, provider2, BigDecimal("95.0")), + createMockDataQuote(paymentMethod, provider3, BigDecimal("100.0")), + ) + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { settingsRepository.isGooglePayAvailability() } returns false + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(1) + + val cardGroup = offers.first() + Truth.assertThat(cardGroup.offers).hasSize(3) + + Truth.assertThat(cardGroup.offers[0].quote).isInstanceOf(OnrampQuote.Data::class.java) + Truth.assertThat(cardGroup.offers[1].quote).isInstanceOf(OnrampQuote.Data::class.java) + Truth.assertThat(cardGroup.offers[2].quote).isInstanceOf(OnrampQuote.AmountError::class.java) + + val firstAmount = (cardGroup.offers[0].quote as OnrampQuote.Data).toAmount.value + val secondAmount = (cardGroup.offers[1].quote as OnrampQuote.Data).toAmount.value + Truth.assertThat(firstAmount).isEqualTo(BigDecimal("100.0")) + Truth.assertThat(secondAmount).isEqualTo(BigDecimal("95.0")) + }, + ) + } + } + + @Test + fun `should handle mixed Data AmountError and Error quotes correctly`() = runTest { + val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD) + val provider1 = createMockProvider("provider1", "Provider 1") + val provider2 = createMockProvider("provider2", "Provider 2") + val provider3 = createMockProvider("provider3", "Provider 3") + val provider4 = createMockProvider("provider4", "Provider 4") + + val quotes = listOf( + createMockDataQuote(paymentMethod, provider1, BigDecimal("100.0")), + createMockAmountErrorQuote(paymentMethod, provider2, requiredAmount = BigDecimal("50.0")), + createMockErrorQuote(paymentMethod, provider3), + createMockDataQuote(paymentMethod, provider4, BigDecimal("95.0")), + ) + + coEvery { onrampRepository.getQuotes() } returns flowOf(quotes) + coEvery { settingsRepository.isGooglePayAvailability() } returns false + + val result = useCase(userWalletId, cryptoCurrencyId) + + result.collect { either -> + Truth.assertThat(either.isRight()).isTrue() + either.fold( + ifLeft = { error -> Truth.assertThat(error).isNull() }, + ifRight = { offers -> + Truth.assertThat(offers).hasSize(1) + + val cardGroup = offers.first() + Truth.assertThat(cardGroup.offers).hasSize(3) + Truth.assertThat(cardGroup.methodStatus).isEqualTo(PaymentMethodStatus.Available) + + val dataOffers = cardGroup.offers.filter { it.quote is OnrampQuote.Data } + val amountErrorOffers = cardGroup.offers.filter { it.quote is OnrampQuote.AmountError } + val errorOffers = cardGroup.offers.filter { it.quote is OnrampQuote.Error } + + Truth.assertThat(dataOffers).hasSize(2) + Truth.assertThat(amountErrorOffers).hasSize(1) + Truth.assertThat(errorOffers).isEmpty() + }, + ) + } + } + + private fun createMockPaymentMethod(id: String, name: String, type: PaymentMethodType): OnrampPaymentMethod { return mockk { every { this@mockk.id } returns id every { this@mockk.name } returns name - every { this@mockk.type } returns mockk { - every { getPriority(any()) } returns 1 - } + every { this@mockk.type } returns type } } @@ -161,7 +426,7 @@ class GetOnrampAllOffersUseCaseTest { } } - private fun createMockQuote( + private fun createMockDataQuote( paymentMethod: OnrampPaymentMethod, provider: OnrampProvider, toAmount: BigDecimal, @@ -169,9 +434,44 @@ class GetOnrampAllOffersUseCaseTest { return mockk { every { this@mockk.paymentMethod } returns paymentMethod every { this@mockk.provider } returns provider - every { this@mockk.toAmount } returns mockk { - every { value } returns toAmount + every { this@mockk.toAmount } returns createMockAmount(toAmount) + every { this@mockk.fromAmount } returns createMockAmount(BigDecimal("10.0")) + every { this@mockk.countryCode } returns "US" + every { this@mockk.minFromAmount } returns null + every { this@mockk.maxFromAmount } returns null + } + } + + private fun createMockAmountErrorQuote( + paymentMethod: OnrampPaymentMethod, + provider: OnrampProvider, + requiredAmount: BigDecimal = BigDecimal("50.0"), + ): OnrampQuote.AmountError { + return mockk { + every { this@mockk.paymentMethod } returns paymentMethod + every { this@mockk.provider } returns provider + every { this@mockk.fromAmount } returns createMockAmount(BigDecimal("10.0")) + every { this@mockk.countryCode } returns "US" + every { this@mockk.error } returns mockk { + every { this@mockk.requiredAmount } returns requiredAmount } } } + + private fun createMockErrorQuote(paymentMethod: OnrampPaymentMethod, provider: OnrampProvider): OnrampQuote.Error { + return mockk { + every { this@mockk.paymentMethod } returns paymentMethod + every { this@mockk.provider } returns provider + every { this@mockk.fromAmount } returns createMockAmount(BigDecimal("10.0")) + every { this@mockk.countryCode } returns "US" + every { this@mockk.error } returns mockk() + } + } + + private fun createMockAmount(value: BigDecimal): OnrampAmount { + return mockk { + every { this@mockk.value } returns value + every { this@mockk.symbol } returns "USD" + } + } } \ No newline at end of file diff --git a/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt index 87f9fbb1c6..743ca16aae 100644 --- a/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt +++ b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt @@ -67,8 +67,8 @@ class GetOnrampOffersUseCaseTest { @Test fun `invoke should return offers blocks with recent and recommended categories`() = runTest { - val paymentMethod1 = createMockPaymentMethod("card", "Card", isInstant = true) - val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer", isInstant = false) + val paymentMethod1 = createMockPaymentMethod("card", "Card", PaymentMethodType.GOOGLE_PAY) + val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer", PaymentMethodType.CARD) val provider1 = createMockProvider("provider1", "Provider 1") val provider2 = createMockProvider("provider2", "Provider 2") @@ -78,7 +78,7 @@ class GetOnrampOffersUseCaseTest { ) val transactions = listOf( - createMockTransaction("provider1", "card", 1000L), + createMockTransaction("Provider 1", "Card", 1000L), ) coEvery { settingsRepository.isGooglePayAvailability() } returns false @@ -105,7 +105,7 @@ class GetOnrampOffersUseCaseTest { Truth.assertThat(recommendedBlock).isNotNull() Truth.assertThat(recommendedBlock?.offers).hasSize(1) Truth.assertThat(recommendedBlock?.offers?.first()?.advantages) - .isEqualTo(OnrampOfferAdvantages.BestRate) + .isEqualTo(OnrampOfferAdvantages.GreatRate) }, ) } @@ -113,8 +113,8 @@ class GetOnrampOffersUseCaseTest { @Test fun `invoke should find best rate offer correctly`() = runTest { - val paymentMethod1 = createMockPaymentMethod("card", "Card", isInstant = false) - val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer", isInstant = false) + val paymentMethod1 = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD) + val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer", PaymentMethodType.CARD) val provider1 = createMockProvider("provider1", "Provider 1") val provider2 = createMockProvider("provider2", "Provider 2") @@ -145,10 +145,10 @@ class GetOnrampOffersUseCaseTest { Truth.assertThat(recommendedBlock).isNotNull() Truth.assertThat(recommendedBlock?.offers).hasSize(1) - val bestRateOffer = recommendedBlock?.offers?.first() - Truth.assertThat(bestRateOffer?.advantages).isEqualTo(OnrampOfferAdvantages.BestRate) + val grateRateOffer = recommendedBlock?.offers?.first() + Truth.assertThat(grateRateOffer?.advantages).isEqualTo(OnrampOfferAdvantages.GreatRate) - when (val quote = bestRateOffer?.quote) { + when (val quote = grateRateOffer?.quote) { is OnrampQuote.Data -> Truth.assertThat(quote.toAmount.value).isEqualTo(BigDecimal("100.0")) else -> Truth.assertThat(false).isTrue() } @@ -159,8 +159,10 @@ class GetOnrampOffersUseCaseTest { @Test fun `invoke should find fastest offer correctly`() = runTest { - val instantPaymentMethod = createMockPaymentMethod("card", "Card", isInstant = true) - val slowPaymentMethod = createMockPaymentMethod("bank", "Bank Transfer", isInstant = false) + val instantPaymentMethod = + createMockPaymentMethod("card", "Card", PaymentMethodType.GOOGLE_PAY) + val slowPaymentMethod = + createMockPaymentMethod("bank", "Bank Transfer", PaymentMethodType.CARD) val provider1 = createMockProvider("provider1", "Provider 1") val provider2 = createMockProvider("provider2", "Provider 2") @@ -190,17 +192,17 @@ class GetOnrampOffersUseCaseTest { Truth.assertThat(recommendedBlock).isNotNull() Truth.assertThat(recommendedBlock?.offers).hasSize(2) - val bestRateOffer = recommendedBlock + val grateRateOffer = recommendedBlock ?.offers - ?.find { it.advantages == OnrampOfferAdvantages.BestRate } + ?.find { it.advantages == OnrampOfferAdvantages.GreatRate } val fastestOffer = recommendedBlock ?.offers ?.find { it.advantages == OnrampOfferAdvantages.Fastest } - Truth.assertThat(bestRateOffer).isNotNull() + Truth.assertThat(grateRateOffer).isNotNull() Truth.assertThat(fastestOffer).isNotNull() - when (val quote = bestRateOffer?.quote) { + when (val quote = grateRateOffer?.quote) { is OnrampQuote.Data -> Truth.assertThat(quote.toAmount.value).isEqualTo(BigDecimal("100.0")) else -> Truth.assertThat(false).isTrue() } @@ -215,8 +217,8 @@ class GetOnrampOffersUseCaseTest { } @Test - fun `invoke should not show recommended block when only one method and provider`() = runTest { - val paymentMethod = createMockPaymentMethod("card", "Card", isInstant = false) + fun `invoke should show recommended block when only one method and provider`() = runTest { + val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD) val provider = createMockProvider("provider1", "Provider 1") val quotes = listOf( @@ -238,22 +240,22 @@ class GetOnrampOffersUseCaseTest { either.fold( ifLeft = { error -> Truth.assertThat(error).isNull() }, ifRight = { offers -> - Truth.assertThat(offers).isEmpty() + Truth.assertThat(offers).isNotEmpty() + Truth.assertThat(offers).hasSize(1) }, ) } } - private fun createMockPaymentMethod(id: String, name: String, isInstant: Boolean): OnrampPaymentMethod { + private fun createMockPaymentMethod( + id: String, + name: String, + type: PaymentMethodType = PaymentMethodType.CARD, + ): OnrampPaymentMethod { return mockk { every { this@mockk.id } returns id every { this@mockk.name } returns name - every { this@mockk.type } returns mockk { - every { isInstant() } returns isInstant - every { getProcessingSpeed() } returns mockk { - every { speed } returns if (isInstant) 1 else 3 - } - } + every { this@mockk.type } returns type } } @@ -278,11 +280,12 @@ class GetOnrampOffersUseCaseTest { } } - private fun createMockTransaction(providerType: String, paymentMethod: String, timestamp: Long): OnrampTransaction { + private fun createMockTransaction(providerName: String, paymentMethod: String, timestamp: Long): OnrampTransaction { return mockk { - every { this@mockk.providerType } returns providerType + every { this@mockk.providerName } returns providerName every { this@mockk.paymentMethod } returns paymentMethod every { this@mockk.timestamp } returns timestamp + every { this@mockk.status } returns OnrampStatus.Status.Finished } } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt index 7223a6faae..d7d39f2d86 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.coroutineScope * This use case interacts with the underlying repositories to both add currencies and refresh * network statuses, particularly after the addition of new tokens. */ -// TODO: Add tests +@Deprecated("Use SaveCryptoCurrenciesUseCase") @Suppress("LongParameterList") class AddCryptoCurrenciesUseCase( private val currenciesRepository: CurrenciesRepository, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt deleted file mode 100644 index 2c491ffdee..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.tangem.domain.tokens - -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.network.Network -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher -import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher -import com.tangem.domain.staking.StakingIdFactory -import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher -import com.tangem.domain.tokens.error.TokenListError -import com.tangem.domain.tokens.repository.CurrenciesRepository -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope - -class FetchCardTokenListUseCase( - private val currenciesRepository: CurrenciesRepository, - private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, - private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, - private val stakingIdFactory: StakingIdFactory, -) { - - suspend operator fun invoke(userWalletId: UserWalletId, refresh: Boolean = false): Either { - return either { - val currencies = fetchCurrencies(userWalletId = userWalletId, refresh = refresh) - - coroutineScope { - val fetchStatuses = async { - fetchNetworksStatuses( - userWalletId = userWalletId, - networks = currencies.mapTo(destination = hashSetOf(), transform = CryptoCurrency::network), - ) - } - val fetchQuotes = async { - fetchQuotes( - currenciesIds = currencies.mapNotNullTo(destination = hashSetOf()) { it.id.rawCurrencyId }, - ) - } - val yieldBalances = async { - fetchYieldBalances( - userWalletId = userWalletId, - currencies = currencies, - ) - } - awaitAll(fetchStatuses, fetchQuotes, yieldBalances) - } - } - } - - private suspend fun Raise.fetchCurrencies( - userWalletId: UserWalletId, - refresh: Boolean = false, - ): List { - return catch( - block = { - currenciesRepository.getSingleCurrencyWalletWithCardCurrencies( - userWalletId = userWalletId, - refresh = refresh, - ) - }, - catch = { raise(TokenListError.DataError(it)) }, - ) - } - - private suspend fun Raise.fetchNetworksStatuses( - userWalletId: UserWalletId, - networks: Set, - ) { - multiNetworkStatusFetcher( - MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = networks), - ) - .mapLeft(TokenListError::DataError) - .bind() - } - - private suspend fun fetchQuotes(currenciesIds: Set) { - multiQuoteStatusFetcher( - params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null), - ) - } - - private suspend fun fetchYieldBalances(userWalletId: UserWalletId, currencies: List) { - val stakingIds = currencies.mapNotNullTo(hashSetOf()) { - stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() - } - - multiYieldBalanceFetcher( - params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = stakingIds), - ) - } -} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt deleted file mode 100644 index d4df6acfcd..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.tangem.domain.tokens - -import arrow.core.Either -import arrow.core.raise.Raise -import arrow.core.raise.catch -import arrow.core.raise.either -import arrow.core.raise.ensureNotNull -import arrow.core.toNonEmptyListOrNull -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.multi.MultiNetworkStatusFetcher -import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher -import com.tangem.domain.staking.StakingIdFactory -import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher -import com.tangem.domain.tokens.error.TokenListError -import com.tangem.domain.tokens.repository.CurrenciesRepository -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope - -/** - * Use case responsible for fetching token list information, including currency data, - * network statuses, and quotes for tokens associated with a user's wallet. - * - * @param currenciesRepository The repository for retrieving currency-related data. - */ -class FetchTokenListUseCase( - private val currenciesRepository: CurrenciesRepository, - private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, - private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, - private val stakingIdFactory: StakingIdFactory, -) { - - /** - * Fetches the token list information for a user's wallet, including currency data, - * network statuses, and quotes for associated tokens. - * - * @param userWalletId The ID of the user's wallet. - * @return An [Either] representing success (Right) or an error (Left) in fetching the token list. - */ - suspend operator fun invoke(userWalletId: UserWalletId): Either = either { - val currencies = fetchCurrencies(userWalletId) - - invoke(userWalletId = userWalletId, currencies = currencies) - } - - suspend operator fun invoke( - userWalletId: UserWalletId, - currencies: List, - ): Either = either { - coroutineScope { - val fetchStatuses = async { - fetchNetworksStatuses( - userWalletId = userWalletId, - networks = currencies.mapTo(hashSetOf()) { it.network }, - ) - } - val fetchQuotes = async { - fetchQuotes( - currenciesIds = currencies.mapTo(hashSetOf()) { it.id }, - ) - } - - val yieldBalances = async { - fetchYieldBalances(userWalletId = userWalletId, currencies = currencies) - } - - awaitAll(fetchStatuses, fetchQuotes, yieldBalances) - } - } - - private suspend fun Raise.fetchCurrencies(userWalletId: UserWalletId): List { - val currencies = catch( - block = { currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId, true) }, - ) { - raise(TokenListError.DataError(it)) - } - - return ensureNotNull(currencies.toNonEmptyListOrNull()) { - TokenListError.EmptyTokens - } - } - - private suspend fun Raise.fetchNetworksStatuses( - userWalletId: UserWalletId, - networks: Set, - ) { - multiNetworkStatusFetcher( - params = MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = networks), - ) - .mapLeft(TokenListError::DataError) - .bind() - } - - private suspend fun Raise.fetchQuotes(currenciesIds: Set) { - multiQuoteStatusFetcher( - params = MultiQuoteStatusFetcher.Params( - currenciesIds = currenciesIds.mapNotNull { it.rawCurrencyId }.toSet(), - appCurrencyId = null, - ), - ) - .mapLeft(TokenListError::DataError) - .bind() - } - - private suspend fun fetchYieldBalances(userWalletId: UserWalletId, currencies: List) { - val stakingIds = currencies.mapNotNullTo(hashSetOf()) { - stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() - } - - multiYieldBalanceFetcher( - params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = stakingIds), - ) - } -} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt index 826fb686d5..e9fc93562c 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt @@ -34,14 +34,6 @@ interface CurrenciesRepository { isSortedByBalance: Boolean, ) - /** - * Saves the given list of cryptocurrencies for a specific multi-currency user wallet. - * - * @param userWalletId The unique identifier of the user wallet. - * @param currencies The list of cryptocurrencies to be saved. - */ - suspend fun saveCurrenciesLocal(userWalletId: UserWalletId, currencies: List) - /** * Add currencies to a specific user wallet. * @@ -50,7 +42,7 @@ interface CurrenciesRepository { * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet * ID provided. */ - @Deprecated("Tech debt") + @Deprecated("Use SaveCryptoCurrenciesUseCase") suspend fun addCurrenciesCache(userWalletId: UserWalletId, currencies: List): List /** @@ -61,6 +53,7 @@ interface CurrenciesRepository { * @throws DataError.UserWalletError.WrongUserWallet If multi-currency user wallet * ID provided. */ + @Deprecated("Use SaveCryptoCurrenciesUseCase") suspend fun removeCurrency(userWalletId: UserWalletId, currency: CryptoCurrency) /** @@ -71,6 +64,7 @@ interface CurrenciesRepository { * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet * ID provided. */ + @Deprecated("Use SaveCryptoCurrenciesUseCase") suspend fun removeCurrencies(userWalletId: UserWalletId, currencies: List) /** diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt index c4459c749e..6b7afb825a 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt @@ -46,8 +46,6 @@ internal class MockCurrenciesRepository( isTokensSortedByBalanceAfterSortingApply = isSortedByBalance } - override suspend fun saveCurrenciesLocal(userWalletId: UserWalletId, currencies: List) = Unit - override suspend fun addCurrenciesCache( userWalletId: UserWalletId, currencies: List, diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayTxHistoryItem.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayTxHistoryItem.kt index 6793255df3..d5a102ee64 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayTxHistoryItem.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayTxHistoryItem.kt @@ -1,20 +1,25 @@ package com.tangem.domain.visa.model -import org.joda.time.DateTime -import java.math.BigDecimal -import java.util.Currency +import com.tangem.domain.models.serialization.SerializedBigDecimal +import com.tangem.domain.models.serialization.SerializedCurrency +import com.tangem.domain.models.serialization.SerializedDateTime +import kotlinx.serialization.Serializable +@Serializable sealed class TangemPayTxHistoryItem { abstract val id: String - abstract val date: DateTime - abstract val amount: BigDecimal - abstract val currency: Currency + abstract val date: SerializedDateTime + abstract val amount: SerializedBigDecimal + abstract val currency: SerializedCurrency + abstract val jsonRepresentation: String + @Serializable data class Spend( override val id: String, - override val date: DateTime, - override val amount: BigDecimal, - override val currency: Currency, + override val jsonRepresentation: String, + override val date: SerializedDateTime, + override val amount: SerializedBigDecimal, + override val currency: SerializedCurrency, val enrichedMerchantName: String?, val merchantName: String, val enrichedMerchantCategory: String?, @@ -23,18 +28,23 @@ sealed class TangemPayTxHistoryItem { val enrichedMerchantIconUrl: String?, ) : TangemPayTxHistoryItem() + @Serializable data class Payment( override val id: String, - override val date: DateTime, - override val amount: BigDecimal, - override val currency: Currency, + override val jsonRepresentation: String, + override val date: SerializedDateTime, + override val amount: SerializedBigDecimal, + override val currency: SerializedCurrency, + val transactionHash: String?, ) : TangemPayTxHistoryItem() + @Serializable data class Fee( override val id: String, - override val date: DateTime, - override val amount: BigDecimal, - override val currency: Currency, + override val jsonRepresentation: String, + override val date: SerializedDateTime, + override val amount: SerializedBigDecimal, + override val currency: SerializedCurrency, ) : TangemPayTxHistoryItem() enum class Status { diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/di/PortfolioSelectorModule.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/di/PortfolioSelectorModule.kt new file mode 100644 index 0000000000..d28a1c305e --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/di/PortfolioSelectorModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.account.selector.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.account.selector.PortfolioSelectorModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface PortfolioSelectorModule { + + @Binds + @IntoMap + @ClassKey(PortfolioSelectorModel::class) + fun portfolioSelectorModel(model: PortfolioSelectorModel): Model +} \ No newline at end of file diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt index 20b64f77ca..9a81297bdd 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt @@ -94,7 +94,7 @@ internal class CreateWalletSelectionModel @Inject constructor( } private fun onHardwareWalletClick() { - // TODO [REDACTED_TASK_KEY] + router.push(AppRoute.CreateHardwareWallet) } private fun onBuyClick() { diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/CreateHardwareWalletComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/CreateHardwareWalletComponent.kt new file mode 100644 index 0000000000..ec266c8c2f --- /dev/null +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/CreateHardwareWalletComponent.kt @@ -0,0 +1,9 @@ +package com.tangem.features.hotwallet + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface CreateHardwareWalletComponent : ComposableContentComponent { + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/ForgetWalletComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/ForgetWalletComponent.kt new file mode 100644 index 0000000000..a363de47e0 --- /dev/null +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/ForgetWalletComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.hotwallet + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId + +interface ForgetWalletComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt index 925be8bdc1..22d2db296e 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/AddExistingWalletModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.hotwallet.addexistingwallet.entry import com.arkivanov.decompose.router.stack.* import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.utils.AnalyticsContextProxy import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -32,6 +33,7 @@ internal class AddExistingWalletModel @Inject constructor( private val router: Router, private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, + private val analyticsContextProxy: AnalyticsContextProxy, ) : Model() { val hotWalletStepperComponentModelCallback = HotWalletStepperComponentModelCallback() @@ -45,6 +47,15 @@ internal class AddExistingWalletModel @Inject constructor( val startRoute = AddExistingWalletRoute.Import val currentRoute: MutableStateFlow = MutableStateFlow(startRoute) + init { + analyticsContextProxy.addHotWalletContext() + } + + override fun onDestroy() { + super.onDestroy() + analyticsContextProxy.removeContext() + } + fun onChildBack() { when (currentRoute.value) { is AddExistingWalletRoute.Import -> router.pop() diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/CreateHardwareWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/CreateHardwareWalletModel.kt new file mode 100644 index 0000000000..992eefd69f --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/CreateHardwareWalletModel.kt @@ -0,0 +1,183 @@ +package com.tangem.features.hotwallet.createhardwarewallet + +import com.tangem.common.core.TangemError +import com.tangem.common.core.TangemSdkError +import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic.SignedIn +import com.tangem.core.analytics.models.Basic.SignedIn.SignInType +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.card.ScanCardProcessor +import com.tangem.domain.card.analytics.ParamCardCurrencyConverter +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.error.SaveWalletError +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder +import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.features.hotwallet.createhardwarewallet.entity.CreateHardwareWalletUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +private const val HIDE_PROGRESS_DELAY = 400L + +@Suppress("LongParameterList") +@ModelScoped +internal class CreateHardwareWalletModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, + private val urlOpener: UrlOpener, + private val settingsRepository: SettingsRepository, + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val uiMessageSender: UiMessageSender, + private val scanCardProcessor: ScanCardProcessor, + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, + private val saveWalletUseCase: SaveWalletUseCase, + private val userWalletsListRepository: UserWalletsListRepository, + private val analyticsEventHandler: AnalyticsEventHandler, +) : Model() { + + val uiState: StateFlow + field = MutableStateFlow( + CreateHardwareWalletUM( + onBackClick = { router.pop() }, + onBuyTangemWalletClick = ::onBuyTangemWalletClick, + onScanDeviceClick = ::onScanDeviceClick, + ), + ) + + override fun onDestroy() { + super.onDestroy() + } + + private fun onBuyTangemWalletClick() { + modelScope.launch { + generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) } + } + } + + private fun onScanDeviceClick() { + scanCard() + } + + private fun scanCard() { + modelScope.launch { + setLoading(true) + + val shouldSaveAccessCodes = settingsRepository.shouldSaveAccessCodes() + cardSdkConfigRepository.setAccessCodeRequestPolicy( + isBiometricsRequestPolicy = shouldSaveAccessCodes, + ) + + val analyticsSource = AnalyticsParam.ScreensSources.Intro + + scanCardProcessor.scan( + analyticsSource = analyticsSource, + onProgressStateChange = { showProgress -> + if (!showProgress) { + delay(HIDE_PROGRESS_DELAY) + setLoading(false) + } else { + setLoading(true) + } + }, + onFailure = { error -> + handleScanError(error) + delay(HIDE_PROGRESS_DELAY) + setLoading(false) + }, + onSuccess = { scanResponse -> + proceedWithScanResponse(scanResponse) + }, + ) + } + } + + private suspend fun proceedWithScanResponse(scanResponse: ScanResponse) { + val userWallet = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build() + + if (userWallet == null) { + Timber.e("User wallet not created") + setLoading(false) + return + } + + saveWalletUseCase(userWallet = userWallet).fold( + ifLeft = { + 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) + } + } + } + }, + ifRight = { + setLoading(false) + sendSignedInCardAnalyticsEvent(scanResponse = scanResponse, isImported = userWallet.isImported) + router.replaceAll(AppRoute.Wallet) + }, + ) + } + + private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse, isImported: Boolean) { + val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver) + if (currency != null) { + analyticsEventHandler.send( + SignedIn( + currency = currency, + batch = scanResponse.card.batchId, + signInType = SignInType.Card, + walletsCount = userWalletsListRepository.userWalletsSync().size.toString(), + isImported = isImported, + hasBackup = scanResponse.card.backupStatus?.isActive, + ), + ) + } + } + + private fun setLoading(isLoading: Boolean) { + uiState.update { it.copy(isScanInProgress = isLoading) } + } + + private fun handleScanError(error: TangemError) { + when (error) { + is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable() + is TangemSdkError -> Timber.e(error, "Scan error occurred") + else -> Timber.e(error, "Error happened") + } + } + + private fun handleNfcFeatureUnavailable() { + uiMessageSender.send( + message = DialogMessage( + message = resourceReference(R.string.nfc_error_unavailable), + title = resourceReference(id = R.string.common_error), + ), + ) + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/DefaultCreateHardwareWalletComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/DefaultCreateHardwareWalletComponent.kt new file mode 100644 index 0000000000..0f83065531 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/DefaultCreateHardwareWalletComponent.kt @@ -0,0 +1,36 @@ +package com.tangem.features.hotwallet.createhardwarewallet + +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.features.hotwallet.CreateHardwareWalletComponent +import com.tangem.features.hotwallet.createhardwarewallet.ui.CreateHardwareWalletContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Suppress("UnusedPrivateMember") +internal class DefaultCreateHardwareWalletComponent @AssistedInject constructor( + @Assisted private val context: AppComponentContext, + @Assisted private val params: Unit, +) : CreateHardwareWalletComponent, AppComponentContext by context { + + private val model: CreateHardwareWalletModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + CreateHardwareWalletContent( + state = state, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory : CreateHardwareWalletComponent.Factory { + override fun create(context: AppComponentContext, params: Unit): DefaultCreateHardwareWalletComponent + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/di/CreateHardwareWalletModule.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/di/CreateHardwareWalletModule.kt new file mode 100644 index 0000000000..6440dc7bb6 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/di/CreateHardwareWalletModule.kt @@ -0,0 +1,27 @@ +package com.tangem.features.hotwallet.createhardwarewallet.di + +import com.tangem.core.decompose.model.Model +import com.tangem.features.hotwallet.CreateHardwareWalletComponent +import com.tangem.features.hotwallet.createhardwarewallet.CreateHardwareWalletModel +import com.tangem.features.hotwallet.createhardwarewallet.DefaultCreateHardwareWalletComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(SingletonComponent::class) +internal interface CreateHardwareWalletModule { + + @Binds + fun bindCreateHardwareWalletComponentFactory( + impl: DefaultCreateHardwareWalletComponent.Factory, + ): CreateHardwareWalletComponent.Factory + + @Binds + @IntoMap + @ClassKey(CreateHardwareWalletModel::class) + fun bindCreateHardwareWalletModel(model: CreateHardwareWalletModel): Model +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/entity/CreateHardwareWalletUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/entity/CreateHardwareWalletUM.kt new file mode 100644 index 0000000000..814ead9ec4 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/entity/CreateHardwareWalletUM.kt @@ -0,0 +1,8 @@ +package com.tangem.features.hotwallet.createhardwarewallet.entity + +internal data class CreateHardwareWalletUM( + val onBackClick: () -> Unit, + val onBuyTangemWalletClick: () -> Unit, + val onScanDeviceClick: () -> Unit, + val isScanInProgress: Boolean = false, +) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/ui/CreateHardwareWalletContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/ui/CreateHardwareWalletContent.kt new file mode 100644 index 0000000000..8f2ce41cfd --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/ui/CreateHardwareWalletContent.kt @@ -0,0 +1,130 @@ +package com.tangem.features.hotwallet.createhardwarewallet.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +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.PrimaryButtonIconEnd +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.feature.FeatureBlock +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.hotwallet.createhardwarewallet.entity.CreateHardwareWalletUM + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CreateHardwareWalletContent(state: CreateHardwareWalletUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background(TangemTheme.colors.background.primary) + .fillMaxSize() + .systemBarsPadding(), + ) { + TangemTopAppBar( + modifier = Modifier + .statusBarsPadding(), + startButton = TopAppBarButtonUM.Back(state.onBackClick), + title = TextReference.EMPTY, + ) + Column( + modifier = Modifier + .weight(1f) + .padding( + start = 16.dp, + top = 24.dp, + end = 16.dp, + ), + ) { + Icon( + modifier = Modifier + .fillMaxWidth(), + painter = painterResource(R.drawable.ic_tangem_64), + contentDescription = null, + tint = Color.Unspecified, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + top = 20.dp, + end = 16.dp, + ), + text = stringResourceSafe(R.string.wallet_create_common_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 32.dp), + title = stringResourceSafe(R.string.hw_upgrade_key_migration_title), + description = stringResourceSafe(R.string.hw_upgrade_key_migration_description), + iconRes = R.drawable.ic_mobile_security_24, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 24.dp), + title = stringResourceSafe(R.string.hw_upgrade_funds_access_title), + description = stringResourceSafe(R.string.hw_upgrade_funds_access_description), + iconRes = R.drawable.ic_knight_shield_24, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 24.dp), + title = stringResourceSafe(R.string.hw_upgrade_general_security_title), + description = stringResourceSafe(R.string.hw_upgrade_general_security_description), + iconRes = R.drawable.ic_protect_24, + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SecondaryButton( + modifier = Modifier + .fillMaxWidth(), + text = stringResourceSafe(R.string.details_buy_wallet), + onClick = state.onBuyTangemWalletClick, + ) + PrimaryButtonIconEnd( + modifier = Modifier + .fillMaxWidth(), + text = stringResourceSafe(R.string.home_button_scan), + onClick = state.onScanDeviceClick, + iconResId = R.drawable.ic_tangem_24, + showProgress = state.isScanInProgress, + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewCreateHardwareWalletContent() { + TangemThemePreview { + CreateHardwareWalletContent( + state = CreateHardwareWalletUM( + onBackClick = {}, + onBuyTangemWalletClick = {}, + onScanDeviceClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt index 5f8dae3197..8607a0715b 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.hotwallet.createmobilewallet import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.utils.AnalyticsContextProxy import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.navigation.Router @@ -25,6 +26,7 @@ internal class CreateMobileWalletModel @Inject constructor( private val saveUserWalletUseCase: SaveWalletUseCase, private val router: Router, private val tangemHotSdk: TangemHotSdk, + private val analyticsContextProxy: AnalyticsContextProxy, ) : Model() { internal val uiState: StateFlow @@ -37,6 +39,15 @@ internal class CreateMobileWalletModel @Inject constructor( ), ) + init { + analyticsContextProxy.addHotWalletContext() + } + + override fun onDestroy() { + super.onDestroy() + analyticsContextProxy.removeContext() + } + private fun onImportClick() { router.push(AppRoute.AddExistingWallet) } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/ui/CreateMobileWalletContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/ui/CreateMobileWalletContent.kt index a53d0f587d..1cdcdcf836 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/ui/CreateMobileWalletContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/ui/CreateMobileWalletContent.kt @@ -15,6 +15,7 @@ import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.feature.FeatureBlock import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -103,39 +104,6 @@ internal fun CreateMobileWalletContent(state: CreateMobileWalletUM, modifier: Mo } } -@Composable -private fun FeatureBlock(title: String, description: String, iconRes: Int, modifier: Modifier = Modifier) { - Row( - modifier = modifier, - ) { - Icon( - modifier = Modifier - .padding(horizontal = 12.dp), - painter = painterResource(iconRes), - contentDescription = null, - tint = TangemTheme.colors.icon.primary1, - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - ) { - Text( - text = title, - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, - ) - Text( - modifier = Modifier - .padding(top = 4.dp), - text = description, - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - } - } -} - @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/CreateWalletBackupModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/CreateWalletBackupModel.kt index 9b0a2821e7..41d158f6f9 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/CreateWalletBackupModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/CreateWalletBackupModel.kt @@ -3,6 +3,7 @@ package com.tangem.features.hotwallet.createwalletbackup import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.push +import com.tangem.core.analytics.utils.AnalyticsContextProxy import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -23,6 +24,7 @@ internal class CreateWalletBackupModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, + private val analyticsContextProxy: AnalyticsContextProxy, ) : Model() { val params = paramsContainer.require() @@ -36,6 +38,15 @@ internal class CreateWalletBackupModel @Inject constructor( val startRoute = CreateWalletBackupRoute.RecoveryPhraseStart val currentRoute: MutableStateFlow = MutableStateFlow(startRoute) + init { + analyticsContextProxy.addHotWalletContext() + } + + override fun onDestroy() { + super.onDestroy() + analyticsContextProxy.removeContext() + } + fun onBack() { when (currentRoute.value) { is CreateWalletBackupRoute.RecoveryPhraseStart -> router.pop() diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/DefaultForgetWalletComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/DefaultForgetWalletComponent.kt new file mode 100644 index 0000000000..919cfbddf7 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/DefaultForgetWalletComponent.kt @@ -0,0 +1,39 @@ +package com.tangem.features.hotwallet.forgetwallet + +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.features.hotwallet.ForgetWalletComponent +import com.tangem.features.hotwallet.forgetwallet.ui.ForgetWalletContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Suppress("UnusedPrivateMember") +internal class DefaultForgetWalletComponent @AssistedInject constructor( + @Assisted private val context: AppComponentContext, + @Assisted private val params: ForgetWalletComponent.Params, +) : ForgetWalletComponent, AppComponentContext by context { + + private val model: ForgetWalletModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + ForgetWalletContent( + state = state, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory : ForgetWalletComponent.Factory { + override fun create( + context: AppComponentContext, + params: ForgetWalletComponent.Params, + ): DefaultForgetWalletComponent + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt new file mode 100644 index 0000000000..7c146e24d3 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt @@ -0,0 +1,114 @@ +package com.tangem.features.hotwallet.forgetwallet + +import arrow.core.getOrElse +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.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.wallets.usecase.DeleteWalletUseCase +import com.tangem.features.hotwallet.ForgetWalletComponent +import com.tangem.features.hotwallet.forgetwallet.entity.ForgetWalletUM +import com.tangem.features.hotwallet.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@ModelScoped +internal class ForgetWalletModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val deleteWalletUseCase: DeleteWalletUseCase, + private val uiMessageSender: UiMessageSender, +) : Model() { + + private val params = paramsContainer.require() + + internal val uiState: StateFlow + field = MutableStateFlow( + ForgetWalletUM( + onBackClick = { router.pop() }, + firstCheckboxChecked = false, + secondCheckboxChecked = false, + onFirstCheckboxClick = ::onFirstCheckboxClick, + onSecondCheckboxClick = ::onSecondCheckboxClick, + onForgetWalletClick = ::onForgetWalletClick, + isForgetButtonEnabled = false, + ), + ) + + private fun onFirstCheckboxClick() { + uiState.update { + val newValue = !it.firstCheckboxChecked + it.copy( + firstCheckboxChecked = newValue, + isForgetButtonEnabled = newValue && it.secondCheckboxChecked, + ) + } + } + + private fun onSecondCheckboxClick() { + uiState.update { + val newValue = !it.secondCheckboxChecked + it.copy( + secondCheckboxChecked = newValue, + isForgetButtonEnabled = it.firstCheckboxChecked && newValue, + ) + } + } + + private fun onForgetWalletClick() { + // TODO actualize strings [REDACTED_TASK_KEY] + uiMessageSender.send( + DialogMessage( + title = stringReference("Attention"), + message = stringReference("Are you sure you want to do this?"), + firstActionBuilder = { + EventMessageAction( + title = stringReference("Forget"), + isWarning = true, + onClick = ::forgetWallet, + ) + }, + secondActionBuilder = { + EventMessageAction( + title = stringReference("Cancel"), + onClick = {}, + ) + }, + ), + ) + } + + private fun forgetWallet() { + modelScope.launch { + val hasUserWallets = deleteWalletUseCase(params.userWalletId) + .getOrElse { + Timber.e("Unable to delete wallet: $it") + + uiMessageSender.send( + message = SnackbarMessage(resourceReference(R.string.common_unknown_error)), + ) + + return@launch + } + + if (hasUserWallets) { + router.pop() + } else { + router.replaceAll(AppRoute.Home()) + } + } + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/di/ForgetWalletModule.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/di/ForgetWalletModule.kt new file mode 100644 index 0000000000..dcedc8378a --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/di/ForgetWalletModule.kt @@ -0,0 +1,25 @@ +package com.tangem.features.hotwallet.forgetwallet.di + +import com.tangem.core.decompose.model.Model +import com.tangem.features.hotwallet.ForgetWalletComponent +import com.tangem.features.hotwallet.forgetwallet.DefaultForgetWalletComponent +import com.tangem.features.hotwallet.forgetwallet.ForgetWalletModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(SingletonComponent::class) +internal interface ForgetWalletModule { + + @Binds + fun bindForgetWalletComponentFactory(impl: DefaultForgetWalletComponent.Factory): ForgetWalletComponent.Factory + + @Binds + @IntoMap + @ClassKey(ForgetWalletModel::class) + fun bindForgetWalletModel(model: ForgetWalletModel): Model +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/entity/ForgetWalletUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/entity/ForgetWalletUM.kt new file mode 100644 index 0000000000..0cb0af23d1 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/entity/ForgetWalletUM.kt @@ -0,0 +1,11 @@ +package com.tangem.features.hotwallet.forgetwallet.entity + +internal data class ForgetWalletUM( + val onBackClick: () -> Unit, + val firstCheckboxChecked: Boolean, + val secondCheckboxChecked: Boolean, + val onFirstCheckboxClick: () -> Unit, + val onSecondCheckboxClick: () -> Unit, + val onForgetWalletClick: () -> Unit, + val isForgetButtonEnabled: Boolean, +) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ui/ForgetWalletContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ui/ForgetWalletContent.kt new file mode 100644 index 0000000000..7b7f5592a9 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ui/ForgetWalletContent.kt @@ -0,0 +1,155 @@ +package com.tangem.features.hotwallet.forgetwallet.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.material3.* +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.text.style.TextAlign +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.PrimaryButton +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.hotwallet.forgetwallet.entity.ForgetWalletUM + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ForgetWalletContent(state: ForgetWalletUM, modifier: Modifier = Modifier) { + // TODO actualize strings [REDACTED_TASK_KEY] + Column( + modifier = modifier + .background(TangemTheme.colors.background.primary) + .fillMaxSize() + .systemBarsPadding(), + ) { + TangemTopAppBar( + modifier = Modifier.statusBarsPadding(), + startButton = TopAppBarButtonUM.Back(state.onBackClick), + title = TextReference.EMPTY, + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Spacer(modifier = Modifier.height(16.dp)) + Icon( + painter = painterResource(R.drawable.ic_attention_72), + contentDescription = null, + tint = TangemTheme.colors.icon.warning, + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Attention", + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "This wallet will be permanently removed from your device", + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + Spacer(modifier = Modifier.height(48.dp)) + CheckboxItem( + modifier = Modifier.padding(horizontal = 16.dp), + checked = state.firstCheckboxChecked, + onCheckedChange = state.onFirstCheckboxClick, + text = "I understand that removing my wallet does not delete it—but simply removes it from my device.", + ) + Spacer(modifier = Modifier.height(16.dp)) + CheckboxItem( + modifier = Modifier.padding(horizontal = 16.dp), + checked = state.secondCheckboxChecked, + onCheckedChange = state.onSecondCheckboxClick, + text = "I understand that if I haven't backed up my wallet before removing it, I may lose access to it.", + ) + Spacer(modifier = Modifier.height(32.dp)) + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + text = "Forget wallet", + onClick = state.onForgetWalletClick, + enabled = state.isForgetButtonEnabled, + ) + } +} + +@Composable +private fun CheckboxItem(checked: Boolean, onCheckedChange: () -> Unit, text: String, modifier: Modifier = Modifier) { + // TODO actualize strings [REDACTED_TASK_KEY] + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.Top, + ) { + IconToggleButton( + checked = checked, + onCheckedChange = { onCheckedChange() }, + ) { + AnimatedContent( + targetState = checked, + label = "Update checked state", + ) { isChecked -> + Icon( + painter = painterResource( + if (isChecked) { + R.drawable.ic_accepted_20 + } else { + R.drawable.ic_unticked_20 + }, + ), + contentDescription = null, + tint = if (isChecked) { + TangemTheme.colors.control.checked + } else { + TangemTheme.colors.icon.secondary + }, + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = text, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewForgetWalletContent() { + TangemThemePreview { + ForgetWalletContent( + state = ForgetWalletUM( + onBackClick = {}, + firstCheckboxChecked = true, + secondCheckboxChecked = false, + onFirstCheckboxClick = {}, + onSecondCheckboxClick = {}, + onForgetWalletClick = {}, + isForgetButtonEnabled = false, + ), + ) + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/start/ui/ManualBackupStartContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/start/ui/ManualBackupStartContent.kt index 435a9dfbb3..56486d5ef7 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/start/ui/ManualBackupStartContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/start/ui/ManualBackupStartContent.kt @@ -4,16 +4,15 @@ import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign 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.PrimaryButton +import com.tangem.core.ui.components.feature.FeatureBlock import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -91,39 +90,6 @@ internal fun ManualBackupStartContent(state: ManualBackupStartUM, modifier: Modi } } -@Composable -private fun FeatureBlock(title: String, description: String, iconRes: Int, modifier: Modifier = Modifier) { - Row( - modifier = modifier, - ) { - Icon( - modifier = Modifier - .padding(horizontal = 12.dp), - painter = painterResource(iconRes), - contentDescription = null, - tint = TangemTheme.colors.icon.primary1, - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - ) { - Text( - text = title, - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, - ) - Text( - modifier = Modifier - .padding(top = 4.dp), - text = description, - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - } - } -} - @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.kt index 2ae6ae5d0c..b8e01bb64d 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.kt @@ -16,6 +16,7 @@ import com.tangem.core.ui.components.PrimaryButtonIconEnd import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.feature.FeatureBlock import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -112,39 +113,6 @@ internal fun UpgradeWalletContent(state: UpgradeWalletUM, modifier: Modifier = M } } -@Composable -private fun FeatureBlock(title: String, description: String, iconRes: Int, modifier: Modifier = Modifier) { - Row( - modifier = modifier, - ) { - Icon( - modifier = Modifier - .padding(horizontal = 12.dp), - painter = painterResource(iconRes), - contentDescription = null, - tint = TangemTheme.colors.icon.primary1, - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - ) { - Text( - text = title, - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, - ) - Text( - modifier = Modifier - .padding(top = 4.dp), - text = description, - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - } - } -} - @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt index 1f151f2514..e437b475da 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/WalletActivationModel.kt @@ -4,6 +4,7 @@ import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.push import com.arkivanov.decompose.router.stack.replaceAll +import com.tangem.core.analytics.utils.AnalyticsContextProxy import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -39,6 +40,7 @@ internal class WalletActivationModel @Inject constructor( private val router: Router, private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, + private val analyticsContextProxy: AnalyticsContextProxy, ) : Model() { val params = paramsContainer.require() @@ -56,6 +58,15 @@ internal class WalletActivationModel @Inject constructor( val startRoute = WalletActivationRoute.ManualBackupStart val currentRoute: MutableStateFlow = MutableStateFlow(startRoute) + init { + analyticsContextProxy.addHotWalletContext() + } + + override fun onDestroy() { + super.onDestroy() + analyticsContextProxy.removeContext() + } + fun onChildBack() { when (currentRoute.value) { is WalletActivationRoute.ManualBackupStart -> router.pop() diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt index ae20c719d8..60b26282fb 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt @@ -9,9 +9,13 @@ 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.DialogMessage +import com.tangem.domain.managetokens.CreateCryptoCurrencyUseCase +import com.tangem.domain.managetokens.FindTokenUseCase +import com.tangem.domain.managetokens.ValidateTokenFormUseCase import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent import com.tangem.features.managetokens.component.CustomTokenFormComponent import com.tangem.features.managetokens.entity.customtoken.ClickableFieldUM @@ -41,6 +45,10 @@ internal class CustomTokenFormModel @Inject constructor( private val messageSender: UiMessageSender, private val customTokenFormManager: CustomCurrencyFormBuilder, private val analyticsEventHandler: AnalyticsEventHandler, + private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, + private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase, + private val findTokenUseCase: FindTokenUseCase, + private val validateTokenFormUseCase: ValidateTokenFormUseCase, paramsContainer: ParamsContainer, customTokenFormUseCasesFacadeFactory: CustomTokenFormUseCasesFacade.Factory, ) : Model() { @@ -48,7 +56,13 @@ internal class CustomTokenFormModel @Inject constructor( private val params: CustomTokenFormComponent.Params = paramsContainer.require() private var createdCurrency: CryptoCurrency? = null private var useCasesFacade: CustomTokenFormUseCasesFacade = customTokenFormUseCasesFacadeFactory.create(params.mode) - private val customCurrencyValidator = CustomCurrencyValidator(useCasesFacade) + private val customCurrencyValidator = CustomCurrencyValidator( + userWalletId = params.mode.userWalletId, + useCasesFacade = useCasesFacade, + createCryptoCurrencyUseCase = createCryptoCurrencyUseCase, + findTokenUseCase = findTokenUseCase, + validateTokenFormUseCase = validateTokenFormUseCase, + ) val state: MutableStateFlow = MutableStateFlow( value = getInitialState(), @@ -164,8 +178,9 @@ internal class CustomTokenFormModel @Inject constructor( isAlreadyAdded: Boolean, isCustom: Boolean, ) = modelScope.launch { - val needColdWalletInteraction = useCasesFacade.needColdWalletInteraction( - network = mapOf(currency.network.backendId to getDerivationPath().value), + val needColdWalletInteraction = coldWalletAndHasMissedDerivationsUseCase.invoke( + userWalletId = params.mode.userWalletId, + networksWithDerivationPath = mapOf(currency.network.backendId to getDerivationPath().value), ) state.update { state -> diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt index feabc9cb4d..698d65bd5b 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/CustomCurrencyValidator.kt @@ -1,11 +1,15 @@ package com.tangem.features.managetokens.utils import arrow.core.getOrElse +import com.tangem.domain.managetokens.CreateCryptoCurrencyUseCase +import com.tangem.domain.managetokens.FindTokenUseCase +import com.tangem.domain.managetokens.ValidateTokenFormUseCase import com.tangem.domain.managetokens.model.AddCustomTokenForm import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException import com.tangem.domain.managetokens.model.exceptoin.FindTokenException import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.utils.list.CustomTokenFormUseCasesFacade import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveInAndJoin @@ -15,7 +19,11 @@ import kotlinx.coroutines.launch import timber.log.Timber internal class CustomCurrencyValidator( + private val userWalletId: UserWalletId, private val useCasesFacade: CustomTokenFormUseCasesFacade, + private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase, + private val findTokenUseCase: FindTokenUseCase, + private val validateTokenFormUseCase: ValidateTokenFormUseCase, ) { private val validateFormJobHolder = JobHolder() @@ -41,10 +49,7 @@ internal class CustomCurrencyValidator( ) = coroutineScope { updateStatus(Status.Validating) - val result = useCasesFacade.validateTokenFormUseCase( - networkId = networkId, - formValues = formValues, - ) + val result = validateTokenFormUseCase(networkId = networkId, formValues = formValues) val validatedForm = result.getOrElse { e -> updateStatus(Status.FormValidationException(e)) @@ -90,7 +95,8 @@ internal class CustomCurrencyValidator( updateStatus(Status.SearchingToken) - val foundToken = useCasesFacade.findTokenUseCase( + val foundToken = findTokenUseCase.invoke( + userWalletId = userWalletId, contractAddress = validatedForm.contractAddress, networkId = networkId, derivationPath = derivationPath, @@ -121,7 +127,8 @@ internal class CustomCurrencyValidator( ) { updateStatus(Status.SearchingToken) - val token = useCasesFacade.findTokenUseCase( + val token = findTokenUseCase.invoke( + userWalletId = userWalletId, contractAddress = validatedForm.contractAddress, networkId = networkId, derivationPath = derivationPath, @@ -148,7 +155,8 @@ internal class CustomCurrencyValidator( derivationPath: Network.DerivationPath, validatedForm: AddCustomTokenForm.Validated.All?, ) { - val currency = useCasesFacade.createCryptoCurrencyUseCase( + val currency = createCryptoCurrencyUseCase.invoke( + userWalletId = userWalletId, networkId = networkId, derivationPath = derivationPath, formValues = validatedForm, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt index a2ade41660..750c452cdf 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt @@ -1,54 +1,44 @@ package com.tangem.features.managetokens.utils.list import arrow.core.Either -import arrow.core.NonEmptyList +import arrow.core.left +import arrow.core.right +import com.tangem.domain.account.producer.SingleAccountProducer +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase +import com.tangem.domain.account.supplier.SingleAccountSupplier import com.tangem.domain.managetokens.CheckIsCurrencyNotAddedUseCase -import com.tangem.domain.managetokens.CreateCryptoCurrencyUseCase -import com.tangem.domain.managetokens.FindTokenUseCase -import com.tangem.domain.managetokens.ValidateTokenFormUseCase -import com.tangem.domain.managetokens.model.AddCustomTokenForm -import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException -import com.tangem.domain.managetokens.model.exceptoin.FindTokenException import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase -import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase import com.tangem.features.managetokens.component.AddCustomTokenMode import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -@Suppress("LongParameterList") internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, private val derivePublicKeysUseCase: DerivePublicKeysUseCase, - private val validateTokenFormUseCase: ValidateTokenFormUseCase, - private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase, - private val findTokenUseCase: FindTokenUseCase, private val checkIsCurrencyNotAddedUseCase: CheckIsCurrencyNotAddedUseCase, - private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, + private val singleAccountSupplier: SingleAccountSupplier, @Assisted private val mode: AddCustomTokenMode, ) { - suspend fun needColdWalletInteraction(network: Map): Boolean = when (mode) { - is AddCustomTokenMode.Account -> TODO("Account") - is AddCustomTokenMode.Wallet -> coldWalletAndHasMissedDerivationsUseCase.invoke( - userWalletId = mode.userWalletId, - networksWithDerivationPath = network, - ) - } - suspend fun addCryptoCurrenciesUseCase(currency: CryptoCurrency): Either = when (mode) { - is AddCustomTokenMode.Account -> TODO("Account") - is AddCustomTokenMode.Wallet -> addCryptoCurrenciesUseCase.invoke( - userWalletId = mode.userWalletId, - currency = currency, - ) + is AddCustomTokenMode.Account -> { + manageCryptoCurrenciesUseCase(accountId = mode.accountId, add = currency) + } + is AddCustomTokenMode.Wallet -> { + addCryptoCurrenciesUseCase.invoke( + userWalletId = mode.userWalletId, + currency = currency, + ) + } } suspend fun derivePublicKeysUseCase(currencies: List): Either = when (mode) { - is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Account -> Unit.right() is AddCustomTokenMode.Wallet -> derivePublicKeysUseCase.invoke( userWalletId = mode.userWalletId, currencies = currencies, @@ -60,7 +50,19 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( derivationPath: Network.DerivationPath, contractAddress: String?, ): Either = when (mode) { - is AddCustomTokenMode.Account -> TODO("Account") + is AddCustomTokenMode.Account -> { + val account = singleAccountSupplier.getSyncOrNull( + params = SingleAccountProducer.Params(accountId = mode.accountId), + ) + ?: return IllegalStateException("Account not found").left() + + account.cryptoCurrencies.none { currency -> + networkId == currency.network.id && + derivationPath == currency.network.derivationPath && + contractAddress.equals(currency.id.contractAddress, ignoreCase = true) + } + .right() + } is AddCustomTokenMode.Wallet -> checkIsCurrencyNotAddedUseCase.invoke( userWalletId = mode.userWalletId, networkId = networkId, @@ -69,45 +71,6 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( ) } - suspend fun createCryptoCurrencyUseCase( - networkId: Network.ID, - derivationPath: Network.DerivationPath, - formValues: AddCustomTokenForm.Validated.All?, - ): Either = when (mode) { - is AddCustomTokenMode.Account -> TODO("Account") - is AddCustomTokenMode.Wallet -> createCryptoCurrencyUseCase.invoke( - userWalletId = mode.userWalletId, - networkId = networkId, - derivationPath = derivationPath, - formValues = formValues, - ) - } - - suspend fun findTokenUseCase( - contractAddress: String, - networkId: Network.ID, - derivationPath: Network.DerivationPath, - ): Either = when (mode) { - is AddCustomTokenMode.Account -> TODO("Account") - is AddCustomTokenMode.Wallet -> findTokenUseCase.invoke( - userWalletId = mode.userWalletId, - contractAddress = contractAddress, - networkId = networkId, - derivationPath = derivationPath, - ) - } - - suspend fun validateTokenFormUseCase( - networkId: Network.ID, - formValues: AddCustomTokenForm.Raw, - ): Either, AddCustomTokenForm.Validated> = when (mode) { - is AddCustomTokenMode.Account -> TODO("Account") - is AddCustomTokenMode.Wallet -> validateTokenFormUseCase.invoke( - networkId = networkId, - formValues = formValues, - ) - } - @AssistedFactory interface Factory { fun create(mode: AddCustomTokenMode): CustomTokenFormUseCasesFacade diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt index 9398d31472..59bf275e11 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt @@ -90,8 +90,9 @@ internal class ManageTokensListManager @AssistedInject constructor( */ suspend fun launchPagination(isCollapsed: Boolean) = coroutineScope { val loadUserTokensFromRemote = when (mode) { - is ManageTokensMode.Wallet -> source == ManageTokensSource.ONBOARDING + is ManageTokensMode.Wallet, is ManageTokensMode.Account, + -> source == ManageTokensSource.ONBOARDING ManageTokensMode.None, -> false } diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt index 43b4501402..c3fe0bf606 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt @@ -2,10 +2,17 @@ package com.tangem.features.managetokens.utils.list import arrow.core.Either import arrow.core.left +import arrow.core.right +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.producer.SingleAccountProducer +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase +import com.tangem.domain.account.supplier.SingleAccountSupplier import com.tangem.domain.managetokens.* import com.tangem.domain.managetokens.model.CurrencyUnsupportedState import com.tangem.domain.managetokens.model.ManageTokensListConfig import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.managetokens.repository.CustomTokensRepository +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.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase @@ -23,6 +30,10 @@ internal class ManageTokensUseCasesFacade @AssistedInject constructor( private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, private val saveManagedTokensUseCase: SaveManagedTokensUseCase, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, + private val customTokensRepository: CustomTokensRepository, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val singleAccountSupplier: SingleAccountSupplier, @Assisted private val mode: ManageTokensMode, ) { @@ -30,17 +41,33 @@ internal class ManageTokensUseCasesFacade @AssistedInject constructor( get() = IllegalStateException("Unsupported") fun manageTokensListConfig(searchText: String?): ManageTokensListConfig { - val userWalletId: UserWalletId? = when (mode) { - is ManageTokensMode.Account -> TODO("Account") - ManageTokensMode.None -> null - is ManageTokensMode.Wallet -> mode.userWalletId + return when (mode) { + is ManageTokensMode.Account -> { + ManageTokensListConfig.Account(accountId = mode.accountId, searchText = searchText) + } + is ManageTokensMode.Wallet -> { + ManageTokensListConfig.Wallet(userWalletId = mode.userWalletId, searchText = searchText) + } + ManageTokensMode.None -> { + if (accountsFeatureToggles.isFeatureEnabled) { + ManageTokensListConfig.Account(accountId = null, searchText = searchText) + } else { + ManageTokensListConfig.Wallet(userWalletId = null, searchText = searchText) + } + } } - return ManageTokensListConfig(userWalletId, searchText) } suspend fun removeCustomCurrencyUseCase(customCurrency: ManagedCryptoCurrency.Custom): Either { return when (mode) { - is ManageTokensMode.Account -> TODO("Account") + is ManageTokensMode.Account -> { + val currency = customTokensRepository.convertToCryptoCurrency( + userWalletId = mode.accountId.userWalletId, + currency = customCurrency, + ) + + manageCryptoCurrenciesUseCase(accountId = mode.accountId, remove = currency) + } is ManageTokensMode.Wallet -> removeCustomCurrencyUseCase.invoke( userWalletId = mode.userWalletId, customCurrency = customCurrency, @@ -55,7 +82,21 @@ internal class ManageTokensUseCasesFacade @AssistedInject constructor( tempRemovedTokens: Map>, ): Either { return when (mode) { - is ManageTokensMode.Account -> TODO("Account") + is ManageTokensMode.Account -> { + val added = tempAddedTokens.mapToCryptoCurrencies(userWalletId = mode.accountId.userWalletId) + val removed = tempRemovedTokens.mapToCryptoCurrencies(userWalletId = mode.accountId.userWalletId) + + val account = singleAccountSupplier.getSyncOrNull( + params = SingleAccountProducer.Params(accountId = mode.accountId), + ) + ?: return IllegalStateException("Account not found").left() + + (account.cryptoCurrencies + added - removed).any { + it is CryptoCurrency.Token && it.network.backendId == network.backendId && + it.network.derivationPath == network.derivationPath + } + .right() + } is ManageTokensMode.Wallet -> checkHasLinkedTokensUseCase.invoke( userWalletId = mode.userWalletId, network = network, @@ -70,7 +111,10 @@ internal class ManageTokensUseCasesFacade @AssistedInject constructor( sourceNetwork: ManagedCryptoCurrency.SourceNetwork, ): Either { return when (mode) { - is ManageTokensMode.Account -> TODO("Account") + is ManageTokensMode.Account -> checkCurrencyUnsupportedUseCase.invoke( + userWalletId = mode.accountId.userWalletId, + sourceNetwork = sourceNetwork, + ) is ManageTokensMode.Wallet -> checkCurrencyUnsupportedUseCase.invoke( userWalletId = mode.userWalletId, sourceNetwork = sourceNetwork, @@ -80,7 +124,10 @@ internal class ManageTokensUseCasesFacade @AssistedInject constructor( } suspend fun needColdWalletInteraction(network: Map): Boolean = when (mode) { - is ManageTokensMode.Account -> TODO("Account") + is ManageTokensMode.Account -> coldWalletAndHasMissedDerivationsUseCase.invoke( + userWalletId = mode.accountId.userWalletId, + networksWithDerivationPath = network, + ) is ManageTokensMode.Wallet -> coldWalletAndHasMissedDerivationsUseCase.invoke( userWalletId = mode.userWalletId, networksWithDerivationPath = network, @@ -92,15 +139,46 @@ internal class ManageTokensUseCasesFacade @AssistedInject constructor( currenciesToAdd: Map>, currenciesToRemove: Map>, ): Either = when (mode) { - is ManageTokensMode.Account -> TODO("Account") - is ManageTokensMode.Wallet -> saveManagedTokensUseCase.invoke( - userWalletId = mode.userWalletId, - currenciesToAdd = currenciesToAdd, - currenciesToRemove = currenciesToRemove, - ) + is ManageTokensMode.Account -> { + manageCryptoCurrenciesUseCase( + accountId = mode.accountId, + add = currenciesToAdd.mapToCryptoCurrencies(userWalletId = mode.accountId.userWalletId), + remove = currenciesToRemove.mapToCryptoCurrencies(userWalletId = mode.accountId.userWalletId), + ) + } + is ManageTokensMode.Wallet -> { + saveManagedTokensUseCase.invoke( + userWalletId = mode.userWalletId, + currenciesToAdd = currenciesToAdd, + currenciesToRemove = currenciesToRemove, + ) + } ManageTokensMode.None -> nonePortfolioError.left() } + private suspend fun Map>.mapToCryptoCurrencies( + userWalletId: UserWalletId, + ): List { + return flatMap { (token, networks) -> + token.availableNetworks + .filter { sourceNetwork -> networks.contains(sourceNetwork.network) } + .map { sourceNetwork -> + when (sourceNetwork) { + is ManagedCryptoCurrency.SourceNetwork.Default -> customTokensRepository.createToken( + managedCryptoCurrency = token, + sourceNetwork = sourceNetwork, + rawId = CryptoCurrency.RawID(token.id.value), + ) + is ManagedCryptoCurrency.SourceNetwork.Main -> customTokensRepository.createCoin( + userWalletId = userWalletId, + networkId = sourceNetwork.id, + derivationPath = sourceNetwork.network.derivationPath, + ) + } + } + } + } + @AssistedFactory interface Factory { fun create(mode: ManageTokensMode): ManageTokensUseCasesFacade 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 1210aa9da6..b1f66a3c20 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 @@ -13,6 +13,7 @@ import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.markets.details.MarketsTokenDetailsComponent import com.tangem.features.markets.details.MarketsTokenDetailsComponent.Params @@ -33,6 +34,7 @@ internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted params: Params, analyticsEventHandler: AnalyticsEventHandler, + private val accountsFeatureToggles: AccountsFeatureToggles, portfolioComponentFactory: MarketsPortfolioComponent.Factory, ) : AppComponentContext by appComponentContext, MarketsTokenDetailsComponent { @@ -114,6 +116,7 @@ internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor( onBackClick = ::navigateBack, backButtonEnabled = bsState == BottomSheetState.EXPANDED, onHeaderSizeChange = onHeaderSizeChange, + isAccountEnabled = accountsFeatureToggles.isFeatureEnabled, portfolioBlock = portfolioComponent?.let { component -> { blockModifier -> component.Content(blockModifier) @@ -141,6 +144,7 @@ internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor( onBackClick = ::navigateBack, backButtonEnabled = true, onHeaderSizeChange = {}, + isAccountEnabled = accountsFeatureToggles.isFeatureEnabled, portfolioBlock = portfolioComponent?.let { component -> { blockModifier -> component.Content(blockModifier) 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 7ffb94a1db..311f6709fe 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 @@ -58,6 +58,7 @@ internal fun MarketsTokenDetailsContent( onBackClick: () -> Unit, onHeaderSizeChange: (Dp) -> Unit, backButtonEnabled: Boolean, + isAccountEnabled: Boolean, modifier: Modifier = Modifier, portfolioBlock: @Composable ((Modifier) -> Unit)?, ) { @@ -69,6 +70,7 @@ internal fun MarketsTokenDetailsContent( onHeaderSizeChange = onHeaderSizeChange, backButtonEnabled = backButtonEnabled, portfolioBlock = portfolioBlock, + isAccountEnabled = isAccountEnabled, addTopBarStatusBarInsets = addTopBarStatusBarPadding, ) @@ -88,6 +90,7 @@ private fun Content( onBackClick: () -> Unit, onHeaderSizeChange: (Dp) -> Unit, backButtonEnabled: Boolean, + isAccountEnabled: Boolean, modifier: Modifier = Modifier, portfolioBlock: @Composable ((Modifier) -> Unit)?, ) { @@ -153,6 +156,7 @@ private fun Content( tokenMarketDetailsBody( state = state.body, + isAccountEnabled = isAccountEnabled, portfolioBlock = portfolioBlock, ) } @@ -348,6 +352,7 @@ private fun Preview() { backgroundColor = TangemTheme.colors.background.tertiary, portfolioBlock = {}, backButtonEnabled = true, + isAccountEnabled = true, addTopBarStatusBarPadding = false, ) } 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 c2fb267ffe..c94c78f605 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 @@ -4,17 +4,21 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.components.items.DescriptionItem import com.tangem.core.ui.components.items.DescriptionPlaceholder +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM +import com.tangem.features.markets.impl.R internal fun LazyListScope.tokenMarketDetailsBody( state: MarketsTokenDetailsUM.Body, + isAccountEnabled: Boolean, portfolioBlock: @Composable ((Modifier) -> Unit)?, ) { when (state) { @@ -29,6 +33,10 @@ internal fun LazyListScope.tokenMarketDetailsBody( } } + if (isAccountEnabled) { + aboutCoinHeader() + } + loadingInfoBlocks() } is MarketsTokenDetailsUM.Body.Content -> { @@ -42,6 +50,10 @@ internal fun LazyListScope.tokenMarketDetailsBody( } } + if (isAccountEnabled) { + aboutCoinHeader() + } + infoBlocksList(state.infoBlocks) } is MarketsTokenDetailsUM.Body.Error -> { @@ -69,6 +81,21 @@ private fun LazyListScope.error(state: MarketsTokenDetailsUM.Body.Error) { } } +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( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/api/AddToPortfolioComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/api/AddToPortfolioComponent.kt new file mode 100644 index 0000000000..f35084b04a --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/api/AddToPortfolioComponent.kt @@ -0,0 +1,18 @@ +package com.tangem.features.markets.portfolio.add.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent + +internal interface AddToPortfolioComponent : ComposableBottomSheetComponent { + + data class Params( + val addToPortfolioManager: AddToPortfolioManager, + val callback: Callback, + ) + + interface Callback { + fun onDismiss() + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/api/AddToPortfolioManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/api/AddToPortfolioManager.kt new file mode 100644 index 0000000000..28daa1389c --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/api/AddToPortfolioManager.kt @@ -0,0 +1,38 @@ +package com.tangem.features.markets.portfolio.add.api + +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent.AnalyticsParams +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +internal 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 + } + + interface Factory { + fun create( + scope: CoroutineScope, + token: TokenMarketParams, + analyticsParams: AnalyticsParams?, + ): AddToPortfolioManager + } +} \ No newline at end of file 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 400f8b25c2..9ca10389a9 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 @@ -7,6 +7,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.Serializable internal data class AvailableToAddData( val availableToAddWallets: Map, @@ -24,6 +25,7 @@ internal data class AvailableToAddWallet( val availableToAddAccounts: Map, ) +@Serializable internal data class AvailableToAddAccount( val account: AccountStatus, val availableNetworks: Set, @@ -41,6 +43,7 @@ internal data class AvailableToAddAccount( .toSet() } +@Serializable internal data class SelectedPortfolio( val userWallet: UserWallet, val account: AvailableToAddAccount, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/AddTokenComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/AddTokenComponent.kt index 0b1272cba8..3f6407aacc 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/AddTokenComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/AddTokenComponent.kt @@ -8,7 +8,6 @@ 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.TokenMarketParams import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.markets.portfolio.add.api.SelectedNetwork import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio @@ -38,7 +37,6 @@ internal class AddTokenComponent @AssistedInject constructor( } data class Params( - val marketParams: TokenMarketParams, val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, val selectedPortfolio: Flow, val selectedNetwork: Flow, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ChooseNetworkComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ChooseNetworkComponent.kt index 3ab91ac88c..e954b972bd 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ChooseNetworkComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ChooseNetworkComponent.kt @@ -1,48 +1,36 @@ package com.tangem.features.markets.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.markets.portfolio.add.api.SelectedPortfolio +import com.tangem.features.markets.portfolio.add.impl.model.ChooseNetworkModel import com.tangem.features.markets.portfolio.add.impl.ui.ChooseNetworkContent -import com.tangem.features.markets.portfolio.add.impl.ui.state.ChooseNetworkUM -import com.tangem.features.markets.portfolio.impl.model.BlockchainRowUMConverter import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.collections.immutable.toPersistentList internal class ChooseNetworkComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: Params, ) : AppComponentContext by context, ComposableContentComponent { - private val state by lazy { - val converter = BlockchainRowUMConverter( - alreadyAddedNetworks = params.alreadyAdded.mapTo(mutableSetOf()) { it.networkId }, - ) - val allAvailableNetworks = params.allAvailable.map { it to true } - ChooseNetworkUM( - networks = converter.convertList(allAvailableNetworks).toPersistentList(), - onNetworkClick = onNetworkClick@{ row -> - val network = params.allAvailable - .find { it.networkId == row.id } - ?: return@onNetworkClick - params.callbacks.onNetworkSelected(network) - }, - ) - } + private val model: ChooseNetworkModel = getOrCreateModel(params) @Composable override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() ChooseNetworkContent(state) } data class Params( - val alreadyAdded: Set, - val allAvailable: List, + val selectedPortfolio: SelectedPortfolio, val callbacks: Callbacks, ) 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 new file mode 100644 index 0000000000..69b9573e71 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/DefaultAddToPortfolioComponent.kt @@ -0,0 +1,210 @@ +package com.tangem.features.markets.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.markets.impl.R +import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent +import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent.Params +import com.tangem.features.markets.portfolio.add.impl.model.AddToPortfolioModel +import com.tangem.features.markets.portfolio.add.impl.model.AddToPortfolioRoutes +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAddToPortfolioComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: 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 scrollableContent = when (stack.active.configuration) { + AddToPortfolioRoutes.PortfolioSelector -> false + AddToPortfolioRoutes.AddToken, + AddToPortfolioRoutes.Empty, + is AddToPortfolioRoutes.NetworkSelector, + AddToPortfolioRoutes.TokenActions, + -> true + } + if (scrollableContent) { + 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: Params): DefaultAddToPortfolioComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/di/AddToPortfolioComponentModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/di/AddToPortfolioComponentModule.kt new file mode 100644 index 0000000000..39e8de3e0b --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/di/AddToPortfolioComponentModule.kt @@ -0,0 +1,21 @@ +package com.tangem.features.markets.portfolio.add.impl.di + +import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent +import com.tangem.features.markets.portfolio.add.api.AddToPortfolioManager +import com.tangem.features.markets.portfolio.add.impl.DefaultAddToPortfolioComponent +import com.tangem.features.markets.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/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/di/AddToPortfolioModelModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/di/AddToPortfolioModelModule.kt new file mode 100644 index 0000000000..b093d471f6 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/di/AddToPortfolioModelModule.kt @@ -0,0 +1,38 @@ +package com.tangem.features.markets.portfolio.add.impl.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.markets.portfolio.add.impl.model.AddToPortfolioModel +import com.tangem.features.markets.portfolio.add.impl.model.AddTokenModel +import com.tangem.features.markets.portfolio.add.impl.model.ChooseNetworkModel +import com.tangem.features.markets.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/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 new file mode 100644 index 0000000000..4cfe815d2c --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioModel.kt @@ -0,0 +1,315 @@ +package com.tangem.features.markets.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.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.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.markets.impl.R +import com.tangem.features.markets.portfolio.add.api.* +import com.tangem.features.markets.portfolio.add.impl.AddTokenComponent +import com.tangem.features.markets.portfolio.add.impl.ChooseNetworkComponent +import com.tangem.features.markets.portfolio.add.impl.TokenActionsComponent +import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.markets.portfolio.impl.loader.PortfolioData +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* +import timber.log.Timber +import javax.inject.Inject + +@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, + val portfolioSelectorController: PortfolioSelectorController, +) : Model(), + ChooseNetworkComponent.Callbacks by callbackDelegate, + TokenActionsComponent.Callbacks by callbackDelegate, + AddTokenComponent.Callbacks by callbackDelegate { + + private val params = paramsContainer.require() + val navigation = StackNavigation() + var currentStack = listOf(AddToPortfolioRoutes.Empty) + + /* Flows that hold state and provide it to child models */ + val selectedNetwork: MutableSharedFlow = replayMutableSharedFlow() + val selectedPortfolio: MutableSharedFlow = replayMutableSharedFlow() + val tokenActionsData: MutableSharedFlow = replayMutableSharedFlow() + + private val addToPortfolioManager = params.addToPortfolioManager + val portfolioFetcher = addToPortfolioManager.portfolioFetcher + val eventBuilder = PortfolioAnalyticsEvent.EventBuilder( + token = addToPortfolioManager.token, + source = addToPortfolioManager.analyticsParams?.source, + ) + + val featureData: Flow = combineFeatureData() + + init { + navigation.subscribe { currentStack = it.transformer.invoke(currentStack) } + startAddToPortfolioFlow() + } + + private fun replayMutableSharedFlow() = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @Suppress("LongMethod") + private fun startAddToPortfolioFlow() { + channelFlow { + fun finishFlow() { + params.callback.onDismiss() + channel.close() + } + val featureDataFlow: StateFlow = featureData + .filterIsInstance() + .map { it.availableToAddData } + .distinctUntilChanged() + .stateIn(this) + + // use snapshot data, looks like we don’t need to remap at runtime + val data = featureDataFlow.value + + // you must control it via [AddToPortfolioComponent.state] + if (!data.availableToAdd) { + finishFlow() + return@channelFlow + } + + // launch data flows, emits on user/code selection, updates state holder + setupPortfolioFlow(data) + .onEach { selectedPortfolio.emit(it) } + .launchIn(this) + setupNetworkFlow(selectedPortfolio) + .onEach { selectedNetwork.emit(it) } + .launchIn(this) + + 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 { + navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector) + } + + val firstPartOfNavigation = selectedPortfolio + .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 = selectedNetwork, + flow2 = selectedPortfolio, + transform = { a, b -> a to b }, + ).shareIn( + scope = this, + started = SharingStarted.Eagerly, + replay = 1, + ) + + // 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) + + // handle actions from AddToken screen + callbackDelegate.onChangeNetworkClick.receiveAsFlow() + .map { routeToNetworkSelector(selectedPortfolio.first()) } + .onEach { route -> navigation.pushNew(route) } + .launchIn(this) + // handle actions from AddToken screen + callbackDelegate.onChangePortfolioClick.receiveAsFlow() + .onEach { navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector) } + .launchIn(this) + + allRequireForAdd + .onEach { (network, portfolio) -> + // after selecting a new Portfolio, must verify previous selected Network + // if it’s unavailable, navigate to NetworkSelector + val isAvailableSelectedNetwork = portfolio.account.availableToAddNetworks + .any { it.networkId == network.selectedNetwork.networkId } + if (isAvailableSelectedNetwork) { + navigation.popToFirst() + } else { + navigation.pushNew(routeToNetworkSelector(portfolio)) + } + } + .launchIn(this) + + // suspend until token is added + val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() + val selectedPortfolio = selectedPortfolio.first() + + messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) + + setupTokenActionsFlow(selectedPortfolio, addedToken) + .onEach { tokenActionsData.emit(it) } + .onEach { + if (it.actions.isNotEmpty()) { + navigation.replaceAll(AddToPortfolioRoutes.TokenActions) + } else { + finishFlow() + } + } + .onEmpty { finishFlow() } + .launchIn(this) + + callbackDelegate.onLaterClick.receiveAsFlow().first() + finishFlow() + } + .catch { + Timber.e(it) + params.callback.onDismiss() + } + .launchIn(modelScope) + } + + private fun setupTokenActionsFlow( + selectedPortfolio: SelectedPortfolio, + addedToken: CryptoCurrencyStatus, + ): Flow = getCryptoCurrencyActionsUseCase( + currency = addedToken.currency, + accountId = selectedPortfolio.account.account.account.accountId, + ) + .map { + PortfolioData.CryptoCurrencyData( + userWallet = selectedPortfolio.userWallet, + status = addedToken, + actions = it.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 + SelectedPortfolio( + isAccountMode = isAccountMode, + userWallet = availableToAddWallets.userWallet, + account = availableToAddAccount, + availableMorePortfolio = !data.isSinglePortfolio, + ) + }, + ) + .filterNotNull() + .onEach { selectedPortfolio.emit(it) } + + private fun setupNetworkFlow(selectedPortfolioFlow: SharedFlow): Flow = combine( + flow = selectedPortfolioFlow, + flow2 = callbackDelegate.onNetworkSelected.receiveAsFlow(), + transform = transform@{ selectedPortfolio, selectedNetwork -> + SelectedNetwork( + cryptoCurrency = createCryptoCurrency( + userWallet = selectedPortfolio.userWallet, + network = selectedNetwork, + ) ?: return@transform null, + selectedNetwork = selectedNetwork, + availableMoreNetwork = !selectedPortfolio.account.isSingleNetwork, + ) + }, + ) + .filterNotNull() + + private suspend fun createCryptoCurrency( + userWallet: UserWallet, + network: TokenMarketInfo.Network, + ): CryptoCurrency? = getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = addToPortfolioManager.token, + network = network, + ) + + 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/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioRoutes.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioRoutes.kt new file mode 100644 index 0000000000..4f46fac1d3 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioRoutes.kt @@ -0,0 +1,28 @@ +package com.tangem.features.markets.portfolio.add.impl.model + +import androidx.compose.runtime.Immutable +import com.tangem.core.decompose.navigation.Route +import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +internal sealed interface AddToPortfolioRoutes : Route { + + @Serializable + data object Empty : AddToPortfolioRoutes + + @Serializable + data object PortfolioSelector : AddToPortfolioRoutes + + @Serializable + data class NetworkSelector( + val selectedPortfolio: SelectedPortfolio, + ) : AddToPortfolioRoutes + + @Serializable + data object AddToken : AddToPortfolioRoutes + + @Serializable + data object TokenActions : AddToPortfolioRoutes +} \ No newline at end of file diff --git a/features/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 8e227d1ec8..2108472bd0 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 @@ -5,7 +5,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.account.status.usecase.GetAccountCurrencyStatusUseCase -import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase import com.tangem.features.markets.portfolio.add.api.SelectedNetwork import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio @@ -28,7 +28,7 @@ internal class AddTokenModel @Inject constructor( private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, - private val saveCryptoCurrenciesUseCase: SaveCryptoCurrenciesUseCase, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, ) : Model() { @@ -68,10 +68,9 @@ internal class AddTokenModel @Inject constructor( analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames)) val cryptoCurrency = selectedNetwork.cryptoCurrency val accountId = selectedPortfolio.account.account.account.accountId - saveCryptoCurrenciesUseCase( + manageCryptoCurrenciesUseCase( accountId = accountId, - add = listOf(cryptoCurrency), - remove = listOf(), + add = cryptoCurrency, ) val status = getAccountCurrencyStatusUseCase.invokeSync( userWalletId = accountId.userWalletId, 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 new file mode 100644 index 0000000000..fa3e0c3ab8 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/ChooseNetworkModel.kt @@ -0,0 +1,125 @@ +package com.tangem.features.markets.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.markets.impl.R +import com.tangem.features.markets.portfolio.add.impl.ChooseNetworkComponent +import com.tangem.features.markets.portfolio.add.impl.ui.state.ChooseNetworkUM +import com.tangem.features.markets.portfolio.impl.model.BlockchainRowUMConverter +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 { + 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 + } + } + + 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/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 new file mode 100644 index 0000000000..c68c7b7ad5 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt @@ -0,0 +1,73 @@ +package com.tangem.features.markets.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.markets.portfolio.add.api.AddToPortfolioManager +import com.tangem.features.markets.portfolio.add.api.AddToPortfolioManager.State +import com.tangem.features.markets.portfolio.add.impl.converter.AvailableToAddDataConverter +import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +internal class DefaultAddToPortfolioManager @AssistedInject constructor( + private val availableToAddDataConverter: AvailableToAddDataConverter, + @Assisted override val token: TokenMarketParams, + @Assisted override val analyticsParams: MarketsPortfolioComponent.AnalyticsParams?, + @Assisted val scope: CoroutineScope, + dispatchers: CoroutineDispatcherProvider, + portfolioFetcherFactory: PortfolioFetcher.Factory, +) : AddToPortfolioManager { + + private val _allAvailableNetworks = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val allAvailableNetworks: Flow> = _allAvailableNetworks.asSharedFlow() + override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create( + mode = PortfolioFetcher.Mode.All(onlyMultiCurrency = 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.availableToAdd) { + State.AvailableToAdd(data) + } else { + State.NothingToAdd + } + } + .flowOn(dispatchers.default) + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = State.Init, + ) + + override fun setTokenNetworks(networks: List) { + _allAvailableNetworks.tryEmit(networks) + } + + @AssistedFactory + interface Factory : AddToPortfolioManager.Factory { + override fun create( + scope: CoroutineScope, + token: TokenMarketParams, + analyticsParams: MarketsPortfolioComponent.AnalyticsParams?, + ): DefaultAddToPortfolioManager + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt index 8f3e1d6da7..da8bdbd11a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt @@ -14,9 +14,10 @@ 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.domain.models.TokenReceiveConfig +import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent import com.tangem.features.markets.portfolio.impl.model.MarketsPortfolioModel +import com.tangem.features.markets.portfolio.impl.model.MarketsPortfolioRoute import com.tangem.features.markets.portfolio.impl.ui.MyPortfolio import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted @@ -28,19 +29,25 @@ 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 = TokenReceiveConfig.serializer(), + serializer = MarketsPortfolioRoute.serializer(), handleBackButton = false, childFactory = ::bottomSheetChild, ) - override fun setTokenNetworks(networks: List) = model.setTokenNetworks(networks) - override fun setNoNetworksAvailable() = model.setNoNetworksAvailable() + override fun setTokenNetworks(networks: List) { + model.setTokenNetworks(networks) + } + + override fun setNoNetworksAvailable() { + model.setNoNetworksAvailable() + } @Composable override fun Content(modifier: Modifier) { @@ -52,15 +59,24 @@ internal class DefaultMarketsPortfolioComponent @AssistedInject constructor( } private fun bottomSheetChild( - config: TokenReceiveConfig, + config: MarketsPortfolioRoute, componentContext: ComponentContext, - ): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create( - context = childByContext(componentContext), - params = TokenReceiveComponent.Params( - config = config, - onDismiss = model.bottomSheetNavigation::dismiss, - ), - ) + ): 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 { 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 3b1183b867..d5e434ad2a 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 @@ -4,6 +4,7 @@ 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 @@ -16,15 +17,14 @@ 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.TokenReceiveConfig 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 com.tangem.domain.models.wallet.isMultiCurrency @@ -32,11 +32,13 @@ 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.markets.impl.R +import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent import com.tangem.features.markets.portfolio.impl.loader.PortfolioData import com.tangem.features.markets.portfolio.impl.loader.PortfolioDataLoader import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle import com.tangem.features.wallet.utils.UserWalletImageFetcher @@ -49,6 +51,7 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject +import com.tangem.features.markets.portfolio.add.api.AddToPortfolioManager as NewAddToPortfolioManager @Suppress("LongParameterList", "LargeClass") @Stable @@ -69,6 +72,9 @@ internal class MarketsPortfolioModel @Inject constructor( private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, private val userWalletImageFetcher: UserWalletImageFetcher, private val receiveAddressesFactory: ReceiveAddressesFactory, + private val accountsFeatureToggles: AccountsFeatureToggles, + newAddToPortfolioManagerFactory: NewAddToPortfolioManager.Factory, + private val newMarketsPortfolioDelegateFactory: NewMarketsPortfolioDelegate.Factory, ) : Model() { val state: StateFlow get() = _state @@ -80,12 +86,40 @@ internal class MarketsPortfolioModel @Inject constructor( 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 bottomSheetNavigation: SlotNavigation = SlotNavigation() + val addToPortfolioCallback = object : AddToPortfolioComponent.Callback { + override fun onDismiss() = bottomSheetNavigation.dismiss() + } + + 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 currentAppCurrency = getSelectedAppCurrencyUseCase() .map { maybeAppCurrency -> @@ -132,27 +166,7 @@ internal class MarketsPortfolioModel @Inject constructor( }, ), currentState = Provider { _state.value }, - 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) - } - }, - ), + tokenActionsHandler = tokenActionsHandler, updateTokens = { updateBlock -> updateTokensState { state -> state.copy(tokens = updateBlock(state.tokens)) @@ -161,18 +175,50 @@ internal class MarketsPortfolioModel @Inject constructor( ) init { - // Subscribe on selected wallet flow to support actual selected wallet - subscribeOnSelectedMultiWalletUpdates() + if (accountsFeatureToggles.isFeatureEnabled) { + newAddToPortfolioManager = newAddToPortfolioManagerFactory + .create( + modelScope, + params.token, + params.analyticsParams, + ) + newMarketsPortfolioDelegate = newMarketsPortfolioDelegateFactory.create( + scope = modelScope, + token = params.token, + tokenActionsHandler = tokenActionsHandler, + buttonState = newAddToPortfolioManager.state.map { + when (it) { + is NewAddToPortfolioManager.State.AvailableToAdd -> AddButtonState.Available + NewAddToPortfolioManager.State.Init -> AddButtonState.Loading + NewAddToPortfolioManager.State.NothingToAdd -> AddButtonState.Unavailable + } + }, + onAddClick = { 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() + 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() { @@ -378,7 +424,7 @@ internal class MarketsPortfolioModel @Inject constructor( status = quickAction.cryptoCurrencyData.status, userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId, ) ?: return@launch - bottomSheetNavigation.activate(tokenConfig) + bottomSheetNavigation.activate(MarketsPortfolioRoute.TokenReceive(tokenConfig)) } } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioRoute.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioRoute.kt new file mode 100644 index 0000000000..576d9cda78 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioRoute.kt @@ -0,0 +1,17 @@ +package com.tangem.features.markets.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/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 new file mode 100644 index 0000000000..20dd17c10c --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/NewMarketsPortfolioDelegate.kt @@ -0,0 +1,325 @@ +package com.tangem.features.markets.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.markets.portfolio.impl.loader.PortfolioData +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState +import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioHeader +import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioListItem +import com.tangem.features.markets.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.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +@Suppress("LongParameterList") +internal class NewMarketsPortfolioDelegate @AssistedInject constructor( + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val allAccountSupplier: MultiAccountStatusListSupplier, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val 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 -> + fun addFirstTokenUM() = MyPortfolioUM.AddFirstToken( + onAddClick = onAddClick, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + ) + when (portfolioWithCurrency.flattenAddedCurrency.isEmpty()) { + false -> emitAll(contentFlow(portfolioWithCurrency).distinctUntilChanged()) + true -> when (portfolioWithCurrency.hasMultiWallets) { + true -> emit(addFirstTokenUM()) + false -> emit(MyPortfolioUM.UnavailableForWallet) + } + } + } + + private fun contentFlow(portfolio: 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 = + portfolio.portfolios.map { portfolio -> portfolio.actionsFoAccountCurrencies() }.flatten() + + return combine( + flow = combine(allAddedTokenActions) { it.toMap() }.distinctUntilChanged(), + flow2 = buttonState.distinctUntilChanged(), + flow3 = getExpandedHolder(portfolio), + flow4 = settingsFlow.distinctUntilChanged(), + transform = { actions, addButtonState, expanded, settings -> + buildContentState( + portfolio = portfolio, + 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.find { account -> account.addedCurrency.isNotEmpty() } != null } + ?.userWallet + ?.let { setOf(it.walletId to currency.currency.id) } + ?: setOf() + } + else -> setOf() + } + return MutableStateFlow(initValue) + .also { this.expandedHolder = it } + } + + private fun portfolioWithThisCurrencyFLow(): Flow = + allAccountSupplier(Unit).map { list -> list.map { it.addedAccountsFlow() } }.flatMapLatest { flows -> + combine(flows) { + PortfoliosWithThisCurrency( + currencyRawId = currencyRawId, + portfolios = it.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: 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 + ?: emptyList() + val value = PortfolioData.CryptoCurrencyData( + userWallet = userWallet, + status = currencyStatus, + actions = actions, + ) + val expandedKey = portfolioItem.userWallet.walletId to currencyStatus.currency.id + val isExpand = expanded.contains(expandedKey) + + val tokenItem = tokenUMConverter.convertV2( + onTokenItemClick = { wallet, status -> + toggleQuickActions(wallet.walletId to status.currency.id) + }, + value = value, + isQuickActionsShown = isExpand, + ) + uiItems.add(tokenItem) + } + } + } + + return MyPortfolioUM.Content( + items = uiItems.toImmutableList(), + buttonState = addButtonState, + onAddClick = onAddClick, + ) + } + + private fun Account.toAccountPortfolioHeader(): PortfolioHeader = PortfolioHeader( + id = this.accountId.value, + state = AccountTitleUM.Account( + prefixText = TextReference.EMPTY, + name = this.accountName.toUM().value, + icon = when (this) { + is Account.CryptoPortfolio -> this.icon.toUM() + }, + ), + ) + + private fun UserWallet.toWalletPortfolioHeader(): PortfolioHeader = PortfolioHeader( + id = this.walletId.stringValue, + state = AccountTitleUM.Text( + title = stringReference(this.name), + ), + ) + + private fun UserWallet.toWalletHeader(): WalletHeader = WalletHeader( + id = this.walletId.stringValue, + name = stringReference(this.name), + ) + + @Suppress("LongParameterList") + @AssistedFactory + interface Factory { + fun create( + scope: CoroutineScope, + token: TokenMarketParams, + tokenActionsHandler: TokenActionsHandler, + buttonState: Flow, + onAddClick: () -> Unit, + ): NewMarketsPortfolioDelegate + } +} + +private data class PortfoliosWithThisCurrency( + val currencyRawId: CryptoCurrency.RawID, + val portfolios: List, +) { + + val hasMultiWallets: Boolean = portfolios.any { it.userWallet.isMultiCurrency } + + val flattenAddedCurrency: List = + portfolios.map { portfolio -> portfolio.flattenAddedCurrency }.flatten() +} + +private data class Portfolio( + val userWallet: UserWallet, + val accountStatusList: AccountStatusList, + val accountsWithAdded: List, +) { + val flattenAddedCurrency: List = + accountsWithAdded.map { it.addedCurrency }.flatten() +} + +private data class AccountWithAdded( + val addedCurrency: List, + val accountStatus: AccountStatus, +) + +private data class SettingsBox( + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val isAccountMode: Boolean, +) \ No newline at end of file diff --git a/features/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 2bb27f2a78..0467a2e2b3 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 @@ -27,6 +27,24 @@ internal class PortfolioTokenUMConverter( 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, 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 e829c57b6b..a0d60beb71 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 @@ -172,7 +172,7 @@ internal class TokenActionsHandler @AssistedInject constructor( router.push( AppRoute.Staking( userWalletId = cryptoCurrencyData.userWallet.walletId, - cryptoCurrencyId = cryptoCurrencyData.status.currency.id, + cryptoCurrency = cryptoCurrencyData.status.currency, yieldId = yield.id, ), ) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt index 1c88861dac..582bcc4690 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt @@ -10,29 +10,43 @@ 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.common.ui.bottomsheet.receive.TokenReceiveBottomSheet 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.markets.impl.R import com.tangem.features.markets.portfolio.impl.ui.preview.PreviewMyPortfolioUMProvider -import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.markets.portfolio.impl.ui.state.* import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState @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, @@ -55,6 +69,7 @@ internal fun MyPortfolio(state: MyPortfolioUM, modifier: Modifier = Modifier) { MyPortfolioUM.Loading -> LoadingPlaceholder(modifier = contentModifier) MyPortfolioUM.Unavailable -> UnavailableAsset(modifier = contentModifier) MyPortfolioUM.UnavailableForWallet -> UnavailableAssetForWallet(modifier = contentModifier) + is MyPortfolioUM.Content -> PortfolioList(state = state) } } @@ -119,6 +134,7 @@ private fun TokenList(state: MyPortfolioUM.Tokens, modifier: Modifier = 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, ) @@ -129,6 +145,111 @@ private fun TokenList(state: MyPortfolioUM.Tokens, modifier: Modifier = Modifier 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( @@ -199,8 +320,7 @@ private fun Preview(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state TangemThemePreview { Box( modifier = Modifier - .background(TangemTheme.colors.background.tertiary) - .padding(TangemTheme.dimens.spacing8), + .background(TangemTheme.colors.background.tertiary), ) { MyPortfolio(state) } 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 ac68e930d6..4f47bfddab 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 @@ -45,7 +45,6 @@ internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifie TokenItem( state = tokenItemState, isBalanceHidden = state.isBalanceHidden, - modifier = Modifier.background(color = TangemTheme.colors.background.action), itemPaddingValues = PaddingValues( start = TangemTheme.dimens.spacing10, end = TangemTheme.dimens.spacing12, @@ -54,7 +53,6 @@ internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifie PortfolioQuickActions( modifier = Modifier - .background(color = TangemTheme.colors.background.action) .padding( bottom = if (lastInList) { TangemTheme.dimens.spacing12 @@ -82,6 +80,7 @@ private fun Preview(@PreviewParameter(PortfolioTokenUMProvider::class) tokenUM: } PortfolioItem( + modifier = Modifier.background(color = TangemTheme.colors.background.action), state = tokenUM.copy( tokenItemState = when (tokenUM.tokenItemState) { is TokenItemState.Content -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() }) 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 fcf3ade8df..6f8a95f4fc 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 @@ -1,15 +1,19 @@ package com.tangem.features.markets.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.markets.portfolio.impl.ui.state.MyPortfolioUM -import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM -import com.tangem.features.markets.portfolio.impl.ui.state.QuickActionUM +import com.tangem.features.markets.portfolio.impl.ui.state.* +import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens import kotlinx.collections.immutable.persistentListOf +import java.util.UUID internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider { @@ -40,35 +44,107 @@ internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider, + val buttonState: Tokens.AddButtonState, + val onAddClick: () -> Unit, + ) : MyPortfolioUM() { + + override val addToPortfolioBSConfig: TangemBottomSheetConfig = TangemBottomSheetConfig.Empty + } + data class AddFirstToken( override val addToPortfolioBSConfig: TangemBottomSheetConfig, val onAddClick: () -> Unit, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/PortfolioTokenUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/PortfolioTokenUM.kt index dc16268b53..13e65fff09 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/PortfolioTokenUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/state/PortfolioTokenUM.kt @@ -1,16 +1,33 @@ package com.tangem.features.markets.portfolio.impl.ui.state +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 +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, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt index 351cd1a5aa..6692632302 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt @@ -4,6 +4,7 @@ import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.extensions.stringReference 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.format.bigdecimal.percent import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent @@ -14,6 +15,7 @@ import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM import com.tangem.utils.Provider import com.tangem.utils.StringsSigns.MINUS +import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList import java.math.BigDecimal @@ -23,42 +25,64 @@ internal class AllOffersStateFactory( private val allOffersIntents: AllOffersIntents, ) { - fun getLoadedPaymentsState(methodGroups: List): AllOffersStateUM { + fun getLoadedPaymentsState(methodGroups: List, currencyCode: String): AllOffersStateUM { return AllOffersStateUM.Content( - methods = methodGroups.map { methodGroup -> - AllOffersPaymentMethodUM( - offers = mapOffersToUM(methodGroup.offers).toPersistentList(), - methodConfig = OnrampPaymentMethodConfig( - method = methodGroup.paymentMethod, - onClick = { allOffersIntents.onPaymentMethodClicked(methodGroup.paymentMethod.id) }, - ), - diff = methodGroup - .bestRateOffer - ?.rateDif - ?.takeIf { it > BigDecimal.ZERO } - ?.let { diff -> - stringReference("$MINUS${diff.format { percent() }}") - }, - rate = methodGroup.bestRateOffer?.let { offer -> - when (val quote = offer.quote) { - is OnrampQuote.Data -> quote.toAmount.value.format { - crypto( - symbol = quote.toAmount.symbol, - decimals = quote.toAmount.decimals, - ) - } - else -> "" - } - } ?: "", - providersCount = methodGroup.providerCount, - isBestRate = methodGroup.isBestPaymentMethod, - ) + methods = methodGroups.map { group -> + createPaymentMethodUM(group, currencyCode) }.toPersistentList(), - currentMethod = null, + currentMethod = replaceOffersForCurrentMethod(methodGroups, currencyCode), onBackClicked = { allOffersIntents.onBackClicked() }, ) } + private fun createPaymentMethodUM( + methodGroup: OnrampPaymentMethodGroup, + currencyCode: String, + ): AllOffersPaymentMethodUM { + return AllOffersPaymentMethodUM( + offers = mapOffersToUM(methodGroup.offers, currencyCode).toPersistentList(), + methodConfig = createMethodConfig(methodGroup.paymentMethod), + diff = formatRateDiff(methodGroup.bestRateOffer?.rateDif), + rate = formatBestRate(methodGroup.bestRateOffer, currencyCode), + providersCount = methodGroup.providerCount, + isBestRate = methodGroup.isBestPaymentMethod, + paymentMethodStatus = methodGroup.methodStatus, + ) + } + + private fun createMethodConfig(paymentMethod: OnrampPaymentMethod): OnrampPaymentMethodConfig { + return OnrampPaymentMethodConfig( + method = paymentMethod, + onClick = { allOffersIntents.onPaymentMethodClicked(paymentMethod.id) }, + ) + } + + private fun formatRateDiff(rateDif: BigDecimal?) = rateDif + ?.takeIf { it > BigDecimal.ZERO } + ?.let { diff -> stringReference("$MINUS${diff.format { percent() }}") } + + private fun formatBestRate(bestRateOffer: OnrampOffer?, currencyCode: String): String { + return when (val quote = bestRateOffer?.quote) { + is OnrampQuote.Data -> formatCryptoAmount(quote.toAmount) + is OnrampQuote.AmountError -> formatRequiredAmount(quote, currencyCode) + is OnrampQuote.Error, + null, + -> "" + } + } + + private fun formatCryptoAmount(amount: OnrampAmount): String { + return amount.value.format { + crypto(symbol = amount.symbol, decimals = amount.decimals) + } + } + + private fun formatRequiredAmount(quote: OnrampQuote.AmountError, currencyCode: String): String { + return quote.error.requiredAmount.format { + fiat(fiatCurrencySymbol = quote.fromAmount.symbol, fiatCurrencyCode = currencyCode) + } + } + fun getPaymentsState(): AllOffersStateUM { return when (val currentState = currentStateProvider.invoke()) { is AllOffersStateUM.Content -> { @@ -72,34 +96,36 @@ internal class AllOffersStateFactory( } fun getOnrampErrorState(onrampError: OnrampError): AllOffersStateUM { - return when (onrampError) { - is OnrampError.DataError -> getErrorState( - errorCode = onrampError.code, - onRefresh = allOffersIntents::onRefresh, - ) - OnrampError.PairsNotFound, - is OnrampError.DomainError, - -> getErrorState(onRefresh = allOffersIntents::onRefresh) - is OnrampError.AmountError.TooBigError, - is OnrampError.AmountError.TooSmallError, - OnrampError.RedirectError.VerificationFailed, - OnrampError.RedirectError.WrongRequestId, - -> currentStateProvider() + return if (shouldShowErrorState(onrampError)) { + createErrorState(errorCode = (onrampError as? OnrampError.DataError)?.code) + } else { + currentStateProvider() } } - private fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): AllOffersStateUM { - val state = currentStateProvider() - return when (state) { + private fun shouldShowErrorState(error: OnrampError): Boolean { + return when (error) { + is OnrampError.DataError, + OnrampError.PairsNotFound, + is OnrampError.DomainError, + -> true + is OnrampError.AmountError, + is OnrampError.RedirectError, + -> false + } + } + + private fun createErrorState(errorCode: String? = null): AllOffersStateUM { + return when (currentStateProvider()) { is AllOffersStateUM.Content, AllOffersStateUM.Loading, -> AllOffersStateUM.Error( errorNotification = NotificationUM.Warning.OnrampErrorNotification( errorCode = errorCode, - onRefresh = onRefresh, + onRefresh = allOffersIntents::onRefresh, ), ) - is AllOffersStateUM.Error -> state + is AllOffersStateUM.Error -> currentStateProvider() } } @@ -108,52 +134,70 @@ internal class AllOffersStateFactory( OnrampOfferAdvantages.Default -> OnrampOfferAdvantagesUM.Default OnrampOfferAdvantages.BestRate -> OnrampOfferAdvantagesUM.BestRate OnrampOfferAdvantages.Fastest -> OnrampOfferAdvantagesUM.Fastest + OnrampOfferAdvantages.GreatRate -> OnrampOfferAdvantagesUM.GreatRate } } - private fun mapOffersToUM(offers: List): List { - return buildList { - offers.forEach { offer -> - when (val quote = offer.quote) { - is OnrampQuote.Data -> { - add( - OnrampOfferUM( - category = OnrampOfferCategoryUM.Recommended, - advantages = mapOfferAdvantagesDTOtoUM(offer.advantages), - paymentMethod = quote.paymentMethod, - providerId = quote.provider.id, - providerName = quote.provider.info.name, - rate = quote.toAmount.value.format { - crypto( - symbol = quote.toAmount.symbol, - decimals = quote.toAmount.decimals, - ) - }, - diff = offer - .rateDif - ?.takeIf { it > BigDecimal.ZERO } - ?.let { diff -> - stringReference("$MINUS${diff.format { percent() }}") - }, - onBuyClicked = { - allOffersIntents.onBuyClick( - quote = OnrampProviderWithQuote.Data( - provider = quote.provider, - paymentMethod = quote.paymentMethod, - toAmount = quote.toAmount, - fromAmount = quote.fromAmount, - ), - onrampOfferAdvantagesUM = mapOfferAdvantagesDTOtoUM(offer.advantages), - ) - }, - ), - ) - } - is OnrampQuote.AmountError, - is OnrampQuote.Error, - -> Unit - } + private fun mapOffersToUM(offers: List, currencyCode: String): List { + return offers.mapNotNull { offer -> + when (val quote = offer.quote) { + is OnrampQuote.Data -> createDataOfferUM(quote, offer) + is OnrampQuote.AmountError -> createAmountErrorOfferUM(quote, offer, currencyCode) + is OnrampQuote.Error -> null } } } + + private fun createDataOfferUM(quote: OnrampQuote.Data, offer: OnrampOffer): OnrampOfferUM { + return OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = mapOfferAdvantagesDTOtoUM(offer.advantages), + paymentMethod = quote.paymentMethod, + providerName = quote.provider.info.name, + rate = formatCryptoAmount(quote.toAmount), + diff = formatRateDiff(offer.rateDif), + onBuyClicked = { + allOffersIntents.onBuyClick( + quote = OnrampProviderWithQuote.Data( + provider = quote.provider, + paymentMethod = quote.paymentMethod, + toAmount = quote.toAmount, + fromAmount = quote.fromAmount, + ), + onrampOfferAdvantagesUM = mapOfferAdvantagesDTOtoUM(offer.advantages), + ) + }, + ) + } + + private fun createAmountErrorOfferUM( + quote: OnrampQuote.AmountError, + offer: OnrampOffer, + currencyCode: String, + ): OnrampOfferUM { + return OnrampOfferUM( + category = OnrampOfferCategoryUM.Recommended, + advantages = OnrampOfferAdvantagesUM.Unavailable, + paymentMethod = quote.paymentMethod, + providerName = quote.provider.info.name, + rate = formatRequiredAmount(quote, currencyCode), + diff = formatRateDiff(offer.rateDif), + onBuyClicked = {}, + ) + } + + private fun replaceOffersForCurrentMethod( + methodGroups: List, + currencyCode: String, + ): AllOffersPaymentMethodUM? { + val currentMethod = (currentStateProvider() as? AllOffersStateUM.Content)?.currentMethod ?: return null + + val updateForCurrentMethod = methodGroups.find { it.paymentMethod.id == currentMethod.methodConfig.method.id } + + return updateForCurrentMethod?.let { updatedCurrentMethod -> + currentMethod.copy( + offers = mapOffersToUM(updatedCurrentMethod.offers, currencyCode).toImmutableList(), + ) + } + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt index 834bc396b3..8e1847afc5 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateUM.kt @@ -3,6 +3,7 @@ package com.tangem.features.onramp.alloffers.entity import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.onramp.model.OnrampPaymentMethod +import com.tangem.domain.onramp.model.PaymentMethodStatus import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM import kotlinx.collections.immutable.ImmutableList @@ -26,6 +27,7 @@ internal data class AllOffersPaymentMethodUM( val rate: String, val providersCount: Int, val isBestRate: Boolean, + val paymentMethodStatus: PaymentMethodStatus, ) internal data class OnrampPaymentMethodConfig( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt index 29414b13c0..12b43915ad 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/model/AllOffersModel.kt @@ -74,6 +74,7 @@ internal class AllOffersModel @Inject constructor( providerName = quote.provider.info.name, paymentMethodName = quote.paymentMethod.name, )?.let { analyticsEventHandler::send } + dismiss() params.openRedirectPage(quote) } @@ -95,7 +96,12 @@ internal class AllOffersModel @Inject constructor( maybeOffers.fold( ifLeft = ::handleOnrampError, ifRight = { offersGroup -> - _state.update { stateFactory.getLoadedPaymentsState(offersGroup) } + _state.update { + stateFactory.getLoadedPaymentsState( + methodGroups = offersGroup, + currencyCode = params.amountCurrencyCode, + ) + } }, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt index f60660546a..e59a517b99 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt @@ -26,6 +26,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.onramp.model.OnrampPaymentMethod +import com.tangem.domain.onramp.model.PaymentMethodStatus import com.tangem.domain.onramp.model.PaymentMethodType import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM @@ -204,7 +205,6 @@ private fun AllOffersContentSheetPaymentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId1", providerName = "Simplex", rate = "0,0245334 BTC", diff = null, @@ -219,7 +219,6 @@ private fun AllOffersContentSheetPaymentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId2", providerName = "Simplex", rate = "0,00145334 BTC", diff = stringReference("–0.07%"), @@ -239,6 +238,7 @@ private fun AllOffersContentSheetPaymentPreview() { rate = "0,0245334 BTC", providersCount = 2, isBestRate = true, + paymentMethodStatus = PaymentMethodStatus.Available, ) TangemThemePreview { @@ -270,7 +270,6 @@ private fun AllOffersContentSheetOffersPreview() { "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId1", providerName = "Simplex", rate = "0,0245334 BTC", diff = null, @@ -286,7 +285,6 @@ private fun AllOffersContentSheetOffersPreview() { "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId2", providerName = "Simplex", rate = "0,00145334 BTC", diff = stringReference("–0.07%"), @@ -306,6 +304,7 @@ private fun AllOffersContentSheetOffersPreview() { rate = "0,0245334 BTC", providersCount = 2, isBestRate = true, + paymentMethodStatus = PaymentMethodStatus.Available, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt index 7c16f78f2e..928989b690 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt @@ -28,6 +28,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.SelectPaymentMethodBottomSheetTestTags import com.tangem.domain.onramp.model.OnrampPaymentMethod +import com.tangem.domain.onramp.model.PaymentMethodStatus import com.tangem.domain.onramp.model.PaymentMethodType import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig @@ -89,21 +90,50 @@ private fun PaymentMethod(methodUM: AllOffersPaymentMethodUM, modifier: Modifier SpacerW(12.dp) Column { - PaymentMethodInfoBlock( - paymentMethodName = methodUM.methodConfig.method.name, - rate = methodUM.rate, - diff = methodUM.diff, - isBestRate = methodUM.isBestRate, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - ProvidersCountBlockInfo(providersCount = methodUM.providersCount) - SpacerW(8.dp) - TimingBlockInfo(speed = methodUM.methodConfig.method.type.getProcessingSpeed()) + when (methodUM.paymentMethodStatus) { + PaymentMethodStatus.Available -> { + PaymentMethodInfoBlock( + paymentMethodName = methodUM.methodConfig.method.name, + rate = methodUM.rate, + diff = methodUM.diff, + isBestRate = methodUM.isBestRate, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + ProvidersCountBlockInfo(providersCount = methodUM.providersCount) + SpacerW(8.dp) + TimingBlockInfo(speed = methodUM.methodConfig.method.type.getProcessingSpeed()) + } + } + is PaymentMethodStatus.Unavailable -> { + UnavailablePaymentMethodInfoBlock( + paymentMethodName = methodUM.methodConfig.method.name, + errorAmount = methodUM.rate, + ) + } } } } } +@Composable +private fun UnavailablePaymentMethodInfoBlock(paymentMethodName: String, errorAmount: String) { + Column(modifier = Modifier.padding(bottom = 14.dp)) { + Text( + text = paymentMethodName, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.testTag(SelectPaymentMethodBottomSheetTestTags.PAYMENT_METHOD_NAME), + ) + SpacerH(2.dp) + Text( + text = stringResourceSafe(R.string.onramp_provider_min_amount, errorAmount), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.testTag(SelectPaymentMethodBottomSheetTestTags.UP_TO_TEXT), + ) + } +} + @Composable private fun PaymentMethodInfoBlock( paymentMethodName: String, @@ -234,7 +264,6 @@ private fun PaymentMethodsContentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId1", providerName = "Simplex", rate = "0,0245334 BTC", diff = null, @@ -249,7 +278,6 @@ private fun PaymentMethodsContentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId2", providerName = "Simplex", rate = "0,00145334 BTC", diff = stringReference("–0.07%"), @@ -269,6 +297,7 @@ private fun PaymentMethodsContentPreview() { rate = "0,0245334 BTC", providersCount = 2, isBestRate = true, + paymentMethodStatus = PaymentMethodStatus.Available, ) TangemThemePreview { PaymentMethodsContent(persistentListOf(method)) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt index f3c1b1fcd9..bfcf0b1a5d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt @@ -1,12 +1,14 @@ package com.tangem.features.onramp.hottokens.portfolio.model import arrow.core.getOrElse +import arrow.core.left 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.wallets.usecase.DerivePublicKeysUseCase -import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase +import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.onramp.hottokens.portfolio.OnrampAddToPortfolioComponent import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddToPortfolioUM @@ -35,6 +37,7 @@ internal class OnrampAddToPortfolioModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val derivePublicKeysUseCase: DerivePublicKeysUseCase, private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, private val getUserWalletUseCase: GetUserWalletUseCase, ) : Model() { @@ -73,16 +76,25 @@ internal class OnrampAddToPortfolioModel @Inject constructor( modelScope.launch { changeAddButtonProgressStatus(isProgress = true) - derivePublicKeysUseCase(params.userWalletId, listOfNotNull(params.cryptoCurrency)).getOrElse { - Timber.e("Failed to derive public keys: $it") + if (accountsFeatureToggles.isFeatureEnabled) { + // saveCryptoCurrenciesUseCase( + // accountId = params.accountId, + // add = params.cryptoCurrency, + // ) + // TODO account + IllegalStateException("Not implemented yet").left() + } else { + derivePublicKeysUseCase(params.userWalletId, listOfNotNull(params.cryptoCurrency)).getOrElse { + Timber.e("Failed to derive public keys: $it") - changeAddButtonProgressStatus(isProgress = false) + changeAddButtonProgressStatus(isProgress = false) + } + + addCryptoCurrenciesUseCase( + userWalletId = params.userWalletId, + currency = params.cryptoCurrency, + ) } - - addCryptoCurrenciesUseCase( - userWalletId = params.userWalletId, - currency = params.cryptoCurrency, - ) .onRight { params.onSuccessAdding(params.cryptoCurrency.id) } .onLeft { changeAddButtonProgressStatus(isProgress = false) } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt index fe65d540a7..fa206dc5c1 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt @@ -67,7 +67,10 @@ internal class DefaultOnrampV2MainComponent @AssistedInject constructor( cryptoCurrency = params.cryptoCurrency, country = config.country, launchSepa = false, - onDismiss = { model.bottomSheetNavigation.dismiss() }, + onDismiss = { + model.bottomSheetNavigation.dismiss() + model.handleOnrampAvailable() + }, ), ) is OnrampV2MainBottomSheetConfig.CurrenciesList -> selectCurrencyComponentFactory.create( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt index 54ea1f1451..6603b00623 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt @@ -9,19 +9,11 @@ import kotlinx.collections.immutable.ImmutableList @Immutable internal sealed interface OnrampOffersBlockUM { - val isBlockVisible: Boolean + data object Empty : OnrampOffersBlockUM - data object Empty : OnrampOffersBlockUM { - override val isBlockVisible: Boolean - get() = false - } - - data class Loading( - override val isBlockVisible: Boolean, - ) : OnrampOffersBlockUM + data object Loading : OnrampOffersBlockUM data class Content( - override val isBlockVisible: Boolean, val recentOffer: OnrampOfferUM?, val recommended: ImmutableList, val onrampAllOffersButtonConfig: OnrampAllOffersButtonConfig?, @@ -32,7 +24,6 @@ internal data class OnrampOfferUM( val category: OnrampOfferCategoryUM, val advantages: OnrampOfferAdvantagesUM, val paymentMethod: OnrampPaymentMethod, - val providerId: String, val providerName: String, val rate: String, val diff: TextReference?, @@ -44,7 +35,7 @@ internal enum class OnrampOfferCategoryUM { } internal enum class OnrampOfferAdvantagesUM { - Default, BestRate, Fastest; + Default, BestRate, GreatRate, Fastest, Unavailable; fun toAnalyticsEvent( cryptoCurrencySymbol: String, @@ -52,7 +43,7 @@ internal enum class OnrampOfferAdvantagesUM { paymentMethodName: String, ): OnrampAnalyticsEvent? { return when (this) { - BestRate -> OnrampAnalyticsEvent.BestRateClicked( + GreatRate -> OnrampAnalyticsEvent.BestRateClicked( tokenSymbol = cryptoCurrencySymbol, providerName = providerName, paymentMethod = paymentMethodName, @@ -62,7 +53,10 @@ internal enum class OnrampOfferAdvantagesUM { providerName = providerName, paymentMethod = paymentMethodName, ) - Default -> null + Default, + BestRate, + Unavailable, + -> null } } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2AmountBlockUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2AmountBlockUM.kt index b151b7818c..9ae8528504 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2AmountBlockUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2AmountBlockUM.kt @@ -8,7 +8,7 @@ import kotlinx.collections.immutable.ImmutableList internal data class OnrampNewAmountBlockUM( val currencyUM: OnrampNewCurrencyUM, val amountFieldModel: AmountFieldModel, - val secondaryFieldModel: OnrampNewAmountSecondaryFieldUM, + val secondaryFieldModel: OnrampSecondaryFieldErrorUM, ) internal data class OnrampNewCurrencyUM( @@ -20,10 +20,9 @@ internal data class OnrampNewCurrencyUM( ) @Immutable -internal sealed interface OnrampNewAmountSecondaryFieldUM { - data object Loading : OnrampNewAmountSecondaryFieldUM - data class Content(val amount: TextReference) : OnrampNewAmountSecondaryFieldUM - data class Error(val error: TextReference) : OnrampNewAmountSecondaryFieldUM +internal sealed interface OnrampSecondaryFieldErrorUM { + data object Empty : OnrampSecondaryFieldErrorUM + data class Error(val error: TextReference) : OnrampSecondaryFieldErrorUM } internal sealed interface OnrampV2AmountButtonUMState { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2Intents.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2Intents.kt index a9f65922cd..7ed9434b91 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2Intents.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2Intents.kt @@ -9,5 +9,4 @@ internal interface OnrampV2Intents { fun onBuyClick(quote: OnrampProviderWithQuote.Data, onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM) fun openProviders() fun onRefresh() - fun onContinueClick() } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainComponentUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainComponentUM.kt index 0b6560e380..256aeadd72 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainComponentUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainComponentUM.kt @@ -9,33 +9,22 @@ import com.tangem.core.ui.extensions.TextReference internal sealed interface OnrampV2MainComponentUM { val topBarConfig: OnrampV2MainTopBarUM - val continueButtonConfig: ContinueButtonUM val errorNotification: NotificationUM? data class InitialLoading( override val topBarConfig: OnrampV2MainTopBarUM, - override val continueButtonConfig: ContinueButtonUM, override val errorNotification: NotificationUM?, ) : OnrampV2MainComponentUM data class Content( override val topBarConfig: OnrampV2MainTopBarUM, - override val continueButtonConfig: ContinueButtonUM, override val errorNotification: NotificationUM?, val amountBlockState: OnrampNewAmountBlockUM, val offersBlockState: OnrampOffersBlockUM, val onrampAmountButtonUMState: OnrampV2AmountButtonUMState, - val onrampProviderState: OnrampV2ProvidersUM, ) : OnrampV2MainComponentUM } -internal data class ContinueButtonUM( - val text: TextReference, - val onClick: () -> Unit, - val enabled: Boolean, - val showProgress: Boolean = false, -) - internal data class OnrampV2MainTopBarUM( val title: TextReference, val startButtonUM: TopAppBarButtonUM, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt index ec94be047d..34902a7683 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt @@ -3,11 +3,7 @@ package com.tangem.features.onramp.mainv2.entity.converter import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.format.bigdecimal.crypto -import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.parseBigDecimalOrNull -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.onramp.mainv2.entity.* import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory import com.tangem.utils.Provider @@ -18,7 +14,6 @@ internal class OnrampV2AmountFieldChangeConverter( private val currentStateProvider: Provider, private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory, private val onrampIntents: OnrampV2Intents, - private val cryptoCurrency: CryptoCurrency, ) : Converter { override fun convert(value: String): OnrampV2MainComponentUM { @@ -39,12 +34,11 @@ internal class OnrampV2AmountFieldChangeConverter( return state.copy( amountBlockState = amountState.copy( amountFieldModel = amountFieldModel, - secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading, + secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty, ), - continueButtonConfig = state.continueButtonConfig.copy(enabled = false), - onrampProviderState = OnrampV2ProvidersUM.Loading, onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, - offersBlockState = OnrampOffersBlockUM.Empty, + offersBlockState = OnrampOffersBlockUM.Loading, + errorNotification = null, ) } @@ -63,22 +57,14 @@ internal class OnrampV2AmountFieldChangeConverter( return copy( amountBlockState = amountBlockState.copy( amountFieldModel = amountFieldModel, - secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content( - stringReference( - BigDecimal.ZERO.format { - crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) - }, - ), - ), + secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty, ), - continueButtonConfig = continueButtonConfig.copy(enabled = false), offersBlockState = OnrampOffersBlockUM.Empty, onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton( currencySymbol = amountBlockState.currencyUM.unit, currencyCode = amountBlockState.currencyUM.code, onAmountValueChanged = onrampIntents::onAmountValueChanged, ), - onrampProviderState = OnrampV2ProvidersUM.Empty, ) } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt index bb490cc01f..7617651504 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampOffersStateFactory.kt @@ -17,15 +17,12 @@ internal class OnrampOffersStateFactory( private val onrampIntents: OnrampV2Intents, ) { - fun getOnShowOffersState(offers: List): OnrampV2MainComponentUM { + fun getOffersState(offers: List): OnrampV2MainComponentUM { val currentState = currentStateProvider.invoke() return when (currentState) { is OnrampV2MainComponentUM.Content -> { currentState.copy( - offersBlockState = mapOnrampOffersBlockToUM( - offersBlocks = offers, - currentState = currentState, - ), + offersBlockState = mapOnrampOffersBlockToUM(offersBlocks = offers), ) } is OnrampV2MainComponentUM.InitialLoading -> { @@ -34,10 +31,7 @@ internal class OnrampOffersStateFactory( } } - private fun mapOnrampOffersBlockToUM( - offersBlocks: List, - currentState: OnrampV2MainComponentUM.Content, - ): OnrampOffersBlockUM.Content { + private fun mapOnrampOffersBlockToUM(offersBlocks: List): OnrampOffersBlockUM { val allOffersUM = mutableListOf() offersBlocks.map { block -> block.offers.forEach { offer -> @@ -51,7 +45,6 @@ internal class OnrampOffersStateFactory( category = mapOfferCategoryDTOtoUM(block.category), advantages = mapOfferAdvantagesDTOtoUM(offer.advantages), paymentMethod = currentQuote.paymentMethod, - providerId = currentQuote.provider.id, providerName = currentQuote.provider.info.name, rate = currentQuote.toAmount.value.format { crypto( @@ -80,8 +73,9 @@ internal class OnrampOffersStateFactory( } } + if (allOffersUM.isEmpty()) return OnrampOffersBlockUM.Empty + return OnrampOffersBlockUM.Content( - isBlockVisible = currentState.offersBlockState.isBlockVisible, recentOffer = allOffersUM.firstOrNull { it.category == OnrampOfferCategoryUM.RecentlyUsed }, recommended = allOffersUM.filter { it.category == OnrampOfferCategoryUM.Recommended }.toPersistentList(), onrampAllOffersButtonConfig = if (offersBlocks.any { it.hasMoreOffers }) { @@ -107,6 +101,7 @@ internal class OnrampOffersStateFactory( OnrampOfferAdvantages.Default -> OnrampOfferAdvantagesUM.Default OnrampOfferAdvantages.BestRate -> OnrampOfferAdvantagesUM.BestRate OnrampOfferAdvantages.Fastest -> OnrampOfferAdvantagesUM.Fastest + OnrampOfferAdvantages.GreatRate -> OnrampOfferAdvantagesUM.GreatRate } } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt index 9f08657016..2af5055bd7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt @@ -1,14 +1,10 @@ package com.tangem.features.onramp.mainv2.entity.factory import com.tangem.core.analytics.api.AnalyticsEventHandler -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.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.domain.models.currency.CryptoCurrency import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent import com.tangem.domain.onramp.model.OnrampCurrency import com.tangem.domain.onramp.model.OnrampQuote @@ -18,13 +14,11 @@ import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.mainv2.entity.* import com.tangem.features.onramp.mainv2.entity.converter.OnrampV2AmountFieldChangeConverter import com.tangem.utils.Provider -import java.math.BigDecimal internal class OnrampV2AmountStateFactory( private val currentStateProvider: Provider, private val analyticsEventHandler: AnalyticsEventHandler, private val onrampIntents: OnrampV2Intents, - private val cryptoCurrency: CryptoCurrency, private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory, ) { @@ -35,7 +29,6 @@ internal class OnrampV2AmountStateFactory( currentStateProvider = currentStateProvider, onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, onrampIntents = onrampIntents, - cryptoCurrency = cryptoCurrency, ) } @@ -74,127 +67,42 @@ internal class OnrampV2AmountStateFactory( ) } - fun getAmountSecondaryLoadingState(): OnrampV2MainComponentUM { - val currentState = currentStateProvider() - if (currentState !is OnrampV2MainComponentUM.Content) return currentState - - val amountState = currentState.amountBlockState - - return currentState.copy( - amountBlockState = amountState.copy( - secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading, - ), - offersBlockState = OnrampOffersBlockUM.Loading( - isBlockVisible = currentState.offersBlockState.isBlockVisible, - ), - continueButtonConfig = currentState.continueButtonConfig.copy(enabled = false), - errorNotification = null, - onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, - onrampProviderState = OnrampV2ProvidersUM.Loading, - ) - } - - fun getAmountSecondaryUpdatedState(quote: OnrampQuote): OnrampV2MainComponentUM { + fun getSecondaryFieldAmountErrorState(quotes: List): OnrampV2MainComponentUM { val currentState = currentStateProvider() if (currentState !is OnrampV2MainComponentUM.Content) return currentState val amountState = currentState.amountBlockState if (amountState.amountFieldModel.fiatValue.isEmpty()) return currentState + val limitedQuote = getLimitFromAmountErrors(quotes) return currentState.copy( amountBlockState = amountState.copy( amountFieldModel = amountState.amountFieldModel.copy(isError = false), - secondaryFieldModel = quote.toSecondaryFieldUiModel(amountState) ?: amountState.secondaryFieldModel, - ), - continueButtonConfig = currentState.continueButtonConfig.copy( - enabled = quote is OnrampQuote.Data, - onClick = onrampIntents::onContinueClick, + secondaryFieldModel = limitedQuote?.toSecondaryFieldUiModel(amountState) + ?: OnrampSecondaryFieldErrorUM.Empty, ), errorNotification = null, + offersBlockState = OnrampOffersBlockUM.Empty, ) } - fun getUpdatedProviderState(selectedQuote: OnrampQuote): OnrampV2MainComponentUM { - val currentState = currentStateProvider() - if (currentState !is OnrampV2MainComponentUM.Content) return currentState - - analyticsEventHandler.send( - OnrampAnalyticsEvent.ProviderCalculated( - providerName = selectedQuote.provider.info.name, - tokenSymbol = cryptoCurrency.symbol, - paymentMethod = selectedQuote.paymentMethod.name, - ), - ) - return currentState.copy( - onrampProviderState = selectedQuote.toProviderBlockState(), - ) - } - - fun getAmountSecondaryResetState(): OnrampV2MainComponentUM { + fun getAmountSecondaryFieldResetState(): OnrampV2MainComponentUM { val currentState = currentStateProvider() if (currentState !is OnrampV2MainComponentUM.Content) return currentState val amountState = currentState.amountBlockState - - if (amountState.secondaryFieldModel is OnrampNewAmountSecondaryFieldUM.Content) return currentState + if (amountState.secondaryFieldModel is OnrampSecondaryFieldErrorUM.Empty) return currentState return currentState.copy( - amountBlockState = amountState.copy( - secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content( - amount = stringReference( - BigDecimal.ZERO.format { - crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) - }, - ), - ), - ), + amountBlockState = amountState.copy(secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty), onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, errorNotification = null, ) } - fun getShowProvidersState(): OnrampV2MainComponentUM { - val currentState = currentStateProvider() - if (currentState !is OnrampV2MainComponentUM.Content) return currentState - - return when (currentState.offersBlockState) { - is OnrampOffersBlockUM.Content -> { - currentState.copy( - offersBlockState = currentState.offersBlockState.copy(isBlockVisible = true), - ) - } - OnrampOffersBlockUM.Empty, - is OnrampOffersBlockUM.Loading, - -> currentState - } - } - - private fun OnrampQuote.toProviderBlockState(): OnrampV2ProvidersUM { - return OnrampV2ProvidersUM.Content( - paymentMethod = paymentMethod, - providerId = provider.id, - ) - } - - private fun OnrampQuote.toSecondaryFieldUiModel( - amountState: OnrampNewAmountBlockUM, - ): OnrampNewAmountSecondaryFieldUM? { - return when (this) { - is OnrampQuote.Error -> null - is OnrampQuote.Data -> { - val amount = toAmount.value.format { - crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) - } - val contentAmount = combinedReference(stringReference("\u007E"), stringReference(amount)) - OnrampNewAmountSecondaryFieldUM.Content(contentAmount) - } - is OnrampQuote.AmountError -> this.toSecondaryFieldUiModel(amountState) - } - } - private fun OnrampQuote.AmountError.toSecondaryFieldUiModel( amountState: OnrampNewAmountBlockUM, - ): OnrampNewAmountSecondaryFieldUM.Error { + ): OnrampSecondaryFieldErrorUM.Error { val amount = error.requiredAmount.format { fiat( fiatCurrencyCode = amountState.amountFieldModel.fiatAmount.currencySymbol, @@ -213,11 +121,27 @@ internal class OnrampV2AmountStateFactory( } } - return OnrampNewAmountSecondaryFieldUM.Error( + return OnrampSecondaryFieldErrorUM.Error( resourceReference( errorTextRes, wrappedList(amount), ), ) } + + private fun getLimitFromAmountErrors(quotes: List): OnrampQuote.AmountError? { + val amountErrorQuotes = quotes.filterIsInstance() + if (amountErrorQuotes.isEmpty()) { + return null + } + val tooSmallErrors = amountErrorQuotes.filter { it.error is OnrampError.AmountError.TooSmallError } + val tooBigErrors = amountErrorQuotes.filter { it.error is OnrampError.AmountError.TooBigError } + if (tooSmallErrors.isNotEmpty()) { + return tooSmallErrors.minByOrNull { it.error.requiredAmount } + } + if (tooBigErrors.isNotEmpty()) { + return tooBigErrors.maxByOrNull { it.error.requiredAmount } + } + return null + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt index 631c06be7a..ad9a4c4577 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt @@ -11,8 +11,6 @@ import com.tangem.core.ui.extensions.TextReference 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.format.bigdecimal.crypto -import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.onramp.model.OnrampCurrency import com.tangem.domain.onramp.model.error.OnrampError @@ -50,11 +48,6 @@ internal class OnrampV2StateFactory( isEnabled = false, ), ), - continueButtonConfig = ContinueButtonUM( - text = resourceReference(R.string.common_continue), - onClick = {}, - enabled = false, - ), ) } @@ -70,11 +63,6 @@ internal class OnrampV2StateFactory( return OnrampV2MainComponentUM.Content( topBarConfig = state.topBarConfig.copy(endButtonUM = endButton), - continueButtonConfig = ContinueButtonUM( - text = resourceReference(R.string.common_continue), - onClick = onrampIntents::onContinueClick, - enabled = false, - ), amountBlockState = initialAmountBlockState, offersBlockState = OnrampOffersBlockUM.Empty, errorNotification = null, @@ -83,7 +71,6 @@ internal class OnrampV2StateFactory( currencySymbol = currency.unit, onAmountValueChanged = onrampIntents::onAmountValueChanged, ), - onrampProviderState = OnrampV2ProvidersUM.Empty, ) } @@ -103,23 +90,6 @@ internal class OnrampV2StateFactory( } } - private fun getNoPairsErrorState(): OnrampV2MainComponentUM { - val state = currentStateProvider() - val contentState = state as? OnrampV2MainComponentUM.Content ?: return state - - return contentState.copy( - continueButtonConfig = contentState.continueButtonConfig.copy(enabled = false), - amountBlockState = contentState.amountBlockState.copy( - amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true), - secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Error( - error = resourceReference(R.string.onramp_no_available_providers), - ), - ), - onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, - offersBlockState = OnrampOffersBlockUM.Empty, - ) - } - fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): OnrampV2MainComponentUM { val state = currentStateProvider() val endButton = when (val button = state.topBarConfig.endButtonUM) { @@ -130,23 +100,15 @@ internal class OnrampV2StateFactory( return when (state) { is OnrampV2MainComponentUM.Content -> state.copy( topBarConfig = state.topBarConfig.copy(endButtonUM = endButton), - continueButtonConfig = state.continueButtonConfig.copy(enabled = false), - amountBlockState = state.amountBlockState.copy( - secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content( - stringReference( - BigDecimal.ZERO.format { - crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) - }, - ), - ), - ), offersBlockState = OnrampOffersBlockUM.Empty, errorNotification = NotificationUM.Warning.OnrampErrorNotification( errorCode = errorCode, onRefresh = onRefresh, ), onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, - onrampProviderState = OnrampV2ProvidersUM.Empty, + amountBlockState = state.amountBlockState.copy( + secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty, + ), ) is OnrampV2MainComponentUM.InitialLoading -> state.copy( errorNotification = NotificationUM.Warning.OnrampErrorNotification( @@ -157,6 +119,22 @@ internal class OnrampV2StateFactory( } } + private fun getNoPairsErrorState(): OnrampV2MainComponentUM { + val state = currentStateProvider() + val contentState = state as? OnrampV2MainComponentUM.Content ?: return state + + return contentState.copy( + amountBlockState = contentState.amountBlockState.copy( + amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true), + secondaryFieldModel = OnrampSecondaryFieldErrorUM.Error( + error = resourceReference(R.string.onramp_no_available_providers), + ), + ), + onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None, + offersBlockState = OnrampOffersBlockUM.Empty, + ) + } + private fun getInitialAmountBlockState(currency: OnrampCurrency): OnrampNewAmountBlockUM { return OnrampNewAmountBlockUM( currencyUM = OnrampNewCurrencyUM( @@ -185,13 +163,7 @@ internal class OnrampV2StateFactory( isValuePasted = false, onValuePastedTriggerDismiss = {}, ), - secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content( - stringReference( - BigDecimal.ZERO.format { - crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true) - }, - ), - ), + secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt index 3407fe9696..1f36fd34ca 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt @@ -1,6 +1,5 @@ package com.tangem.features.onramp.mainv2.model -import androidx.compose.runtime.mutableStateOf import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -15,7 +14,6 @@ import com.tangem.domain.onramp.model.OnrampProviderWithQuote import com.tangem.domain.onramp.model.OnrampQuote import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.features.onramp.main.entity.OnrampLastUpdate import com.tangem.features.onramp.mainv2.OnrampV2MainComponent import com.tangem.features.onramp.mainv2.entity.* import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory @@ -42,7 +40,7 @@ internal class OnrampV2MainComponentModel @Inject constructor( private val getOnrampCountryUseCase: GetOnrampCountryUseCase, private val clearOnrampCacheUseCase: ClearOnrampCacheUseCase, private val fetchQuotesUseCase: OnrampFetchQuotesUseCase, - private val getOnrampQuotesUseCase: GetOnrampV2QuotesUseCase, + private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase, private val fetchPairsUseCase: OnrampFetchPairsUseCase, private val amountInputManager: InputManager, private val getOnrampOffersUseCase: GetOnrampOffersUseCase, @@ -52,8 +50,6 @@ internal class OnrampV2MainComponentModel @Inject constructor( val params = paramsContainer.require() - private val lastUpdateState = mutableStateOf(null) - private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory by lazy(LazyThreadSafetyMode.NONE) { OnrampAmountButtonUMStateFactory() } @@ -65,20 +61,23 @@ internal class OnrampV2MainComponentModel @Inject constructor( ) } - private val stateFactory = OnrampV2StateFactory( - currentStateProvider = Provider { _state.value }, - cryptoCurrency = params.cryptoCurrency, - onrampIntents = this, - onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, - ) + private val stateFactory: OnrampV2StateFactory by lazy(LazyThreadSafetyMode.NONE) { + OnrampV2StateFactory( + currentStateProvider = Provider { _state.value }, + cryptoCurrency = params.cryptoCurrency, + onrampIntents = this, + onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, + ) + } - private val amountStateFactory = OnrampV2AmountStateFactory( - currentStateProvider = Provider { _state.value }, - analyticsEventHandler = analyticsEventHandler, - onrampIntents = this, - cryptoCurrency = params.cryptoCurrency, - onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, - ) + private val amountStateFactory: OnrampV2AmountStateFactory by lazy(LazyThreadSafetyMode.NONE) { + OnrampV2AmountStateFactory( + currentStateProvider = Provider { _state.value }, + analyticsEventHandler = analyticsEventHandler, + onrampIntents = this, + onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory, + ) + } private val _state: MutableStateFlow = MutableStateFlow( value = stateFactory.getInitialState( @@ -97,7 +96,6 @@ internal class OnrampV2MainComponentModel @Inject constructor( modelScope.launch { clearOnrampCacheUseCase() } - sendScreenOpenAnalytics() checkResidenceCountry() subscribeToAmountChanges() @@ -139,7 +137,7 @@ internal class OnrampV2MainComponentModel @Inject constructor( cryptoCurrencySymbol = params.cryptoCurrency.symbol, providerName = quote.provider.info.name, paymentMethodName = quote.paymentMethod.name, - )?.let { analyticsEventHandler::send } + )?.let(analyticsEventHandler::send) params.openRedirectPage(quote) } @@ -160,27 +158,23 @@ internal class OnrampV2MainComponentModel @Inject constructor( modelScope.launch { clearOnrampCacheUseCase.invoke() checkResidenceCountry() - } - } - - override fun onContinueClick() { - val currentState = _state.value - if (currentState is OnrampV2MainComponentUM.Content) { - _state.update { amountStateFactory.getShowProvidersState() } + handleOnrampAvailable() } } fun onStart() { - quotesTaskScheduler.scheduleTask( - scope = modelScope, - task = loadQuotesTask(), - ) + startLoadingQuotes() } fun onStop() { quotesTaskScheduler.cancelTask() } + fun handleOnrampAvailable() { + subscribeToCountryAndCurrencyUpdates() + subscribeToQuotesUpdate() + } + private fun startLoadingQuotes() { quotesTaskScheduler.cancelTask() quotesTaskScheduler.scheduleTask(scope = modelScope, task = loadQuotesTask()) @@ -235,7 +229,9 @@ internal class OnrampV2MainComponentModel @Inject constructor( maybeOffers.fold( ifLeft = ::handleOnrampError, ifRight = { offers -> - _state.update { onrampOffersStateFactory.getOnShowOffersState(offers) } + if (offers.isNotEmpty()) { + _state.update { onrampOffersStateFactory.getOffersState(offers) } + } }, ) } @@ -245,7 +241,6 @@ internal class OnrampV2MainComponentModel @Inject constructor( amountInputManager.query .filter(String::isNotEmpty) .collectLatest { _ -> - _state.update { amountStateFactory.getAmountSecondaryLoadingState() } startLoadingQuotes() } } @@ -288,74 +283,35 @@ internal class OnrampV2MainComponentModel @Inject constructor( private fun handleQuoteResult(quotes: List) { sendOnrampQuotesErrorAnalytic(quotes) - - val quote = selectOrUpdateQuote(quotes) - - if (quote == null) { - _state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) } - lastUpdateState.value = null - return - } - _state.update { amountStateFactory.getAmountSecondaryUpdatedState(quote = quote) } - } - - private fun selectOrUpdateQuote(quotes: List): OnrampQuote? { - val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error } - - // Check if amount, country or currency has changed - val newQuote = if (checkLastInputState(quoteToCheck)) { - quoteToCheck - } else { - val state = state.value as? OnrampV2MainComponentUM.Content - val providerState = state?.onrampProviderState as? OnrampV2ProvidersUM.Content - - // Get current selected quote to update - val lastSelectedQuote = quotes.firstOrNull { - it.provider.id == providerState?.providerId && - it.paymentMethod.id == providerState.paymentMethod.id + when { + quotes.isEmpty() -> { + _state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) } } - - if (lastSelectedQuote is OnrampQuote.Error) { - quoteToCheck - } else { - lastSelectedQuote + quotes.all { it is OnrampQuote.AmountError } -> { + _state.update { amountStateFactory.getSecondaryFieldAmountErrorState(quotes) } + } + else -> { + _state.update { amountStateFactory.getAmountSecondaryFieldResetState() } } } - newQuote?.let { updateProvider(newQuote) } - - return newQuote } private fun onRetryQuotes() { _state.update { (it as? OnrampV2MainComponentUM.Content)?.copy( errorNotification = null, - onrampProviderState = OnrampV2ProvidersUM.Loading, - offersBlockState = OnrampOffersBlockUM.Loading(isBlockVisible = false), - amountBlockState = it.amountBlockState.copy( - secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading, - ), + offersBlockState = OnrampOffersBlockUM.Loading, + amountBlockState = it.amountBlockState.copy(secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty), ) ?: it } startLoadingQuotes() } private suspend fun updatePairsAndQuotes() { - val state = state.value as? OnrampV2MainComponentUM.Content - - if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) { - _state.update { amountStateFactory.getAmountSecondaryLoadingState() } - } fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold( ifLeft = ::handleOnrampError, ifRight = { - _state.update { - if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) { - return@fold - } else { - amountStateFactory.getAmountSecondaryResetState() - } - } + _state.update { amountStateFactory.getAmountSecondaryFieldResetState() } }, ) startLoadingQuotes() @@ -366,18 +322,6 @@ internal class OnrampV2MainComponentModel @Inject constructor( _state.update { stateFactory.getOnrampErrorState(onrampError) } } - private fun updateProvider(quote: OnrampQuote) { - lastUpdateState.value = OnrampLastUpdate( - fromAmount = quote.fromAmount, - countryCode = quote.countryCode, - paymentMethod = quote.paymentMethod, - ) - - _state.update { - amountStateFactory.getUpdatedProviderState(selectedQuote = quote) - } - } - private fun sendOnrampQuotesErrorAnalytic(quotes: List) { quotes.forEach { errorState -> when (errorState) { @@ -398,11 +342,6 @@ internal class OnrampV2MainComponentModel @Inject constructor( } } - private fun checkLastInputState(quote: OnrampQuote?): Boolean { - return lastUpdateState.value?.fromAmount != quote?.fromAmount || - lastUpdateState.value?.countryCode != quote?.countryCode - } - private fun sendScreenOpenAnalytics() { analyticsEventHandler.send( OnrampAnalyticsEvent.ScreenOpened( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt index 5668fac482..979234f572 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt @@ -14,16 +14,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.Keyboard -import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.keyboardAsState -import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM +import com.tangem.features.onramp.mainv2.entity.OnrampOffersBlockUM import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM @@ -33,14 +30,12 @@ internal fun OnrampFooterContent( boxScope: BoxScope, modifier: Modifier = Modifier, ) { - val keyboardController = LocalSoftwareKeyboardController.current - boxScope.apply { AnimatedVisibility( modifier = Modifier .imePadding() .align(Alignment.BottomCenter), - visible = state.offersBlockState.isBlockVisible.not(), + visible = state.offersBlockState is OnrampOffersBlockUM.Empty, enter = slideInVertically( initialOffsetY = { it }, animationSpec = tween(durationMillis = 300), @@ -55,17 +50,6 @@ internal fun OnrampFooterContent( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - PrimaryButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - text = stringResourceSafe(id = R.string.common_continue), - onClick = { - state.continueButtonConfig.onClick() - keyboardController?.hide() - }, - enabled = state.continueButtonConfig.enabled, - ) SpacerH(16.dp) OnrampAmountButtons(state = state.onrampAmountButtonUMState) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt index cf0d2ca599..1d5565f6a7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt @@ -73,7 +73,8 @@ private fun InitialLoading(state: OnrampV2MainComponentUM.InitialLoading, modifi Column( modifier = modifier .fillMaxWidth() - .wrapContentHeight(), + .wrapContentHeight() + .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { OnrampAmountContentLoading() diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt index c39c79a8da..9a1a053cc5 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt @@ -1,13 +1,10 @@ package com.tangem.features.onramp.mainv2.ui import android.content.res.Configuration -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -43,19 +40,8 @@ import kotlinx.collections.immutable.persistentListOf @Composable internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { - AnimatedVisibility( - visible = state.isBlockVisible, - enter = slideInVertically( - initialOffsetY = { it }, - animationSpec = tween(durationMillis = 300), - ), - exit = slideOutVertically( - targetOffsetY = { it }, - animationSpec = tween(durationMillis = 300), - ), - label = "Offers block animation", - ) { - if (state is OnrampOffersBlockUM.Content) { + when (state) { + is OnrampOffersBlockUM.Content -> { Column(modifier = Modifier.fillMaxWidth()) { state.recentOffer?.let { recentOffer -> Column { @@ -100,6 +86,15 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { } } } + OnrampOffersBlockUM.Loading -> { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + LoadingBlock() + } + } + OnrampOffersBlockUM.Empty -> Unit } } @@ -120,13 +115,18 @@ internal fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier) ) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { OfferHeader(advantage = onrampOfferUM.advantages) - RateBlock(rate = onrampOfferUM.rate, diff = onrampOfferUM.diff) + RateBlock( + rate = onrampOfferUM.rate, + diff = onrampOfferUM.diff, + isOfferUnavailable = onrampOfferUM.advantages == OnrampOfferAdvantagesUM.Unavailable, + ) } SpacerWMax() - SecondaryButton( + PrimaryButton( size = TangemButtonSize.RoundedAction, text = stringResourceSafe(R.string.common_buy), onClick = onrampOfferUM.onBuyClicked, + enabled = onrampOfferUM.advantages != OnrampOfferAdvantagesUM.Unavailable, ) } SpacerH(10.dp) @@ -142,6 +142,14 @@ internal fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier) @Composable private fun OfferHeader(advantage: OnrampOfferAdvantagesUM) { when (advantage) { + OnrampOfferAdvantagesUM.GreatRate -> { + Text( + text = stringResourceSafe(R.string.express_provider_great_rate), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.icon.accent, + modifier = Modifier.testTag(OnrampOffersBlockTestTags.BEST_RATE_TITLE), + ) + } OnrampOfferAdvantagesUM.Default -> { Text( text = stringResourceSafe(R.string.onramp_title_you_get), @@ -169,27 +177,24 @@ private fun OfferHeader(advantage: OnrampOfferAdvantagesUM) { } } OnrampOfferAdvantagesUM.Fastest -> { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_fastest_16), - tint = TangemTheme.colors.icon.attention, - contentDescription = null, - ) - Text( - text = stringResourceSafe(R.string.onramp_offer_type_fastet), - style = TangemTheme.typography.caption1, - color = TangemTheme.colors.icon.attention, - ) - } + Text( + text = stringResourceSafe(R.string.onramp_offer_type_fastet), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.icon.attention, + ) + } + OnrampOfferAdvantagesUM.Unavailable -> { + Text( + text = stringResourceSafe(R.string.onramp_title_available_from), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) } } } @Composable -private fun RateBlock(rate: String, diff: TextReference?) { +private fun RateBlock(rate: String, diff: TextReference?, isOfferUnavailable: Boolean) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -197,7 +202,7 @@ private fun RateBlock(rate: String, diff: TextReference?) { Text( text = rate, style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, + color = if (isOfferUnavailable) TangemTheme.colors.text.tertiary else TangemTheme.colors.text.primary1, modifier = Modifier.testTag(OnrampOffersBlockTestTags.OFFER_TOKEN_AMOUNT), ) diff?.let { @@ -314,7 +319,7 @@ internal fun TimingBlock(speed: PaymentMethodType.PaymentSpeed) { } @Composable -fun DrawDot(color: Color) { +private fun DrawDot(color: Color) { Spacer( modifier = Modifier .size(4.dp) @@ -330,6 +335,30 @@ fun DrawDot(color: Color) { ) } +@Composable +private fun LoadingBlock(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SpacerH(100.dp) + + Text( + text = stringResourceSafe(R.string.onramp_fetching_best_rates), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + + SpacerH(8.dp) + + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = TangemTheme.colors.icon.informative, + strokeWidth = TangemTheme.dimens.size2, + ) + } +} + // will be hardcoded till server will be ready to provide this values private const val FEW_MINS_VALUE = "3-5" private const val FEW_DAYS_VALUE = 3 @@ -340,7 +369,6 @@ private const val PLENTY_DAYS_VALUE = 5 @Composable private fun OnrampOffersContentPreview() { val state = OnrampOffersBlockUM.Content( - isBlockVisible = true, recentOffer = OnrampOfferUM( category = OnrampOfferCategoryUM.RecentlyUsed, advantages = OnrampOfferAdvantagesUM.Default, @@ -350,7 +378,6 @@ private fun OnrampOffersContentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId3", providerName = "Simplex", rate = "0,00045334 BTC", diff = stringReference("–27%"), @@ -359,14 +386,13 @@ private fun OnrampOffersContentPreview() { recommended = persistentListOf( OnrampOfferUM( category = OnrampOfferCategoryUM.Recommended, - advantages = OnrampOfferAdvantagesUM.BestRate, + advantages = OnrampOfferAdvantagesUM.GreatRate, paymentMethod = OnrampPaymentMethod( id = "card", name = "Card", imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId1", providerName = "Simplex", rate = "0,0245334 BTC", diff = null, @@ -381,7 +407,6 @@ private fun OnrampOffersContentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), - providerId = "providerId2", providerName = "Simplex", rate = "0,00145334 BTC", diff = stringReference("–0.07%"), @@ -393,4 +418,13 @@ private fun OnrampOffersContentPreview() { TangemThemePreview { OnrampOffersContent(state) } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun OnrampOffersLoadingPreview() { + TangemThemePreview { + OnrampOffersContent(OnrampOffersBlockUM.Loading) + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt index b0ea37b98b..5e88e12d6b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt @@ -2,7 +2,6 @@ package com.tangem.features.onramp.mainv2.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -22,12 +21,10 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.tangem.common.ui.amountScreen.models.AmountFieldModel import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.fields.AmountTextField import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation import com.tangem.core.ui.extensions.resolveReference @@ -36,20 +33,12 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.test.BuyTokenDetailsScreenTestTags import com.tangem.core.ui.utils.rememberDecimalFormat import com.tangem.features.onramp.impl.R -import com.tangem.features.onramp.mainv2.entity.OnrampNewAmountSecondaryFieldUM import com.tangem.features.onramp.mainv2.entity.OnrampNewCurrencyUM +import com.tangem.features.onramp.mainv2.entity.OnrampSecondaryFieldErrorUM import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM @Composable internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) { - val padding = remember(state.offersBlockState.isBlockVisible) { - if (state.offersBlockState.isBlockVisible) { - 22.dp - } else { - 46.dp - } - } - Column( modifier = modifier .fillMaxWidth() @@ -57,8 +46,8 @@ internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modif color = TangemTheme.colors.background.action, shape = RoundedCornerShape(size = TangemTheme.dimens.radius16), ) - .padding(vertical = padding) - .animateContentSize(animationSpec = tween(durationMillis = 300)), + .padding(vertical = 24.dp, horizontal = 16.dp) + .animateContentSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { OnrampHeaderTitle() @@ -68,8 +57,12 @@ internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modif currencyCode = state.amountBlockState.currencyUM.code, ) - AnimatedVisibility(!state.offersBlockState.isBlockVisible) { - OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel) + AnimatedVisibility( + visible = state.amountBlockState.secondaryFieldModel !is OnrampSecondaryFieldErrorUM.Empty, + ) { + if (state.amountBlockState.secondaryFieldModel is OnrampSecondaryFieldErrorUM.Error) { + OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel) + } } SpacerH(20.dp) @@ -130,7 +123,7 @@ private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: Strin } @Composable -private fun OnrampAmountSecondary(state: OnrampNewAmountSecondaryFieldUM) { +private fun OnrampAmountSecondary(state: OnrampSecondaryFieldErrorUM.Error) { Box( modifier = Modifier .fillMaxWidth() @@ -142,24 +135,12 @@ private fun OnrampAmountSecondary(state: OnrampNewAmountSecondaryFieldUM) { .testTag(BuyTokenDetailsScreenTestTags.TOKEN_AMOUNT), contentAlignment = Alignment.Center, ) { - when (state) { - is OnrampNewAmountSecondaryFieldUM.Content -> Text( - text = state.amount.resolveReference(), - style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr), - color = TangemTheme.colors.text.tertiary, - textAlign = TextAlign.Center, - ) - is OnrampNewAmountSecondaryFieldUM.Error -> Text( - text = state.error.resolveReference(), - color = TangemTheme.colors.text.warning, - style = TangemTheme.typography.caption2, - textAlign = TextAlign.Center, - ) - is OnrampNewAmountSecondaryFieldUM.Loading -> TextShimmer( - style = TangemTheme.typography.caption2, - modifier = Modifier.width(TangemTheme.dimens.size62), - ) - } + Text( + text = state.error.resolveReference(), + color = TangemTheme.colors.text.warning, + style = TangemTheme.typography.caption2, + textAlign = TextAlign.Center, + ) } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/paymentmethod/ui/PaymentMethodIcon.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/paymentmethod/ui/PaymentMethodIcon.kt index 8c6125e33d..6fcafbb281 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/paymentmethod/ui/PaymentMethodIcon.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/paymentmethod/ui/PaymentMethodIcon.kt @@ -21,7 +21,7 @@ internal fun PaymentMethodIcon(imageUrl: String, modifier: Modifier = Modifier) .size(TangemTheme.dimens.size40) .clip(TangemTheme.shapes.roundedCorners8) .background(TangemColorPalette.Light1) - .padding(TangemTheme.dimens.spacing6) + .padding(TangemTheme.dimens.spacing4) .testTag(SelectProviderBottomSheetTestTags.PAYMENT_METHOD_ICON), model = ImageRequest.Builder(context = LocalContext.current) .data(imageUrl) diff --git a/features/referral/domain/build.gradle.kts b/features/referral/domain/build.gradle.kts index 06c587e043..b9bec0d9f3 100644 --- a/features/referral/domain/build.gradle.kts +++ b/features/referral/domain/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(projects.libs.crypto) /** Domain modules */ + implementation(projects.domain.account.status) implementation(projects.domain.card) implementation(projects.domain.models) implementation(projects.domain.tokens) diff --git a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralInteractorImpl.kt b/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralInteractorImpl.kt index 0e24966a47..a2f521e5ea 100644 --- a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralInteractorImpl.kt +++ b/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralInteractorImpl.kt @@ -2,6 +2,7 @@ package com.tangem.feature.referral.domain import arrow.core.getOrElse import com.tangem.common.core.TangemSdkError +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId @@ -21,6 +22,7 @@ internal class ReferralInteractorImpl( private val derivePublicKeysUseCase: DerivePublicKeysUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, ) : ReferralInteractor { private val tokensForReferral = mutableListOf() @@ -45,19 +47,23 @@ internal class ReferralInteractorImpl( val cryptoCurrency = repository.getCryptoCurrency(userWalletId = userWallet.walletId, tokenData = tokenData) ?: error("Failed to create crypto currency") - // todo account different for account? - derivePublicKeysUseCase(userWallet.walletId, listOfNotNull(cryptoCurrency)).getOrElse { - Timber.e("Failed to derive public keys: $it") - throw it.mapToDomainError() - } - when (portfolioId) { - is PortfolioId.Account -> TODO("account") - is PortfolioId.Wallet -> addCryptoCurrenciesUseCase( - userWalletId = userWallet.walletId, - currency = cryptoCurrency, - ) + is PortfolioId.Account -> { + manageCryptoCurrenciesUseCase(accountId = portfolioId.accountId, add = cryptoCurrency) + } + is PortfolioId.Wallet -> { + derivePublicKeysUseCase(userWallet.walletId, listOfNotNull(cryptoCurrency)).getOrElse { + Timber.e("Failed to derive public keys: $it") + throw it.mapToDomainError() + } + + addCryptoCurrenciesUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + ) + } } + .onLeft(Timber::e) val publicAddress = when (portfolioId) { is PortfolioId.Account -> TODO("account") diff --git a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/di/ReferralDomainModule.kt b/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/di/ReferralDomainModule.kt index bb4729aac8..570df9607c 100644 --- a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/di/ReferralDomainModule.kt +++ b/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/di/ReferralDomainModule.kt @@ -1,9 +1,10 @@ package com.tangem.feature.referral.domain.di -import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelComponent -import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase +import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.referral.domain.ReferralInteractor import com.tangem.feature.referral.domain.ReferralInteractorImpl @@ -25,6 +26,7 @@ class ReferralDomainModule { derivePublicKeysUseCase: DerivePublicKeysUseCase, getUserWalletUseCase: GetUserWalletUseCase, addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, + manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, ): ReferralInteractor { return ReferralInteractorImpl( repository = referralRepository, @@ -32,6 +34,7 @@ class ReferralDomainModule { derivePublicKeysUseCase = derivePublicKeysUseCase, getUserWalletUseCase = getUserWalletUseCase, addCryptoCurrenciesUseCase = addCryptoCurrenciesUseCase, + manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase, ) } } \ No newline at end of file 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 647af4a902..a7fd1e0bdc 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 @@ -3,6 +3,7 @@ package com.tangem.features.send.v2.send.confirm.model import android.os.SystemClock import androidx.compose.runtime.Stable import arrow.core.getOrElse +import arrow.core.left import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.TransactionData import com.tangem.common.routing.AppRouter @@ -20,6 +21,7 @@ import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase @@ -105,6 +107,7 @@ internal class SendConfirmModel @Inject constructor( private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val sendAmountReduceTrigger: SendAmountReduceTrigger, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, sendBalanceUpdaterFactory: SendBalanceUpdater.Factory, ) : Model(), SendConfirmClickIntents, FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback { @@ -429,13 +432,21 @@ internal class SendConfirmModel @Inject constructor( val network = receivingUserWallet.network ?: return modelScope.launch(dispatchers.default) { - withContext(NonCancellable) { - addCryptoCurrenciesUseCase( - userWalletId = userWalletId, - cryptoCurrency = cryptoCurrency, - network = network, - ) + if (accountsFeatureToggles.isFeatureEnabled) { + // val tokenToAdd = currenciesRepository.createTokenCurrency(cryptoCurrency, network) + // saveCryptoCurrenciesUseCase(accountId = accountId, add = tokenToAdd) + // TODO account + IllegalStateException("Not implemented yet").left() + } else { + withContext(NonCancellable) { + addCryptoCurrenciesUseCase( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + network = network, + ) + } } + .onLeft(Timber::e) } } 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 7321546e1f..bf5f6a1b9d 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 @@ -9,7 +9,7 @@ interface StakingComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, - val cryptoCurrencyId: CryptoCurrency.ID, + val cryptoCurrency: CryptoCurrency, val yieldId: String, ) diff --git a/features/staking/impl/build.gradle.kts b/features/staking/impl/build.gradle.kts index 995576c43d..c647e6e760 100644 --- a/features/staking/impl/build.gradle.kts +++ b/features/staking/impl/build.gradle.kts @@ -67,6 +67,8 @@ dependencies { implementation(projects.domain.feedback) implementation(projects.domain.feedback.models) implementation(projects.domain.notifications.models) + implementation(projects.domain.account) + implementation(projects.domain.account.status) /** Common */ implementation(projects.common.ui) 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 cae468e2b2..eaa7af9ae5 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 @@ -93,7 +93,7 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( appRouter.push( AppRoute.Staking( userWalletId = selectedUserWalletId, - cryptoCurrencyId = cryptoCurrency.id, + cryptoCurrency = cryptoCurrency, yieldId = yield.id, ), ) 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 54636113ff..f334d19633 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 @@ -24,6 +24,9 @@ import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.core.ui.message.DialogMessage import com.tangem.features.staking.impl.R import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -34,6 +37,7 @@ import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.staking.* @@ -52,11 +56,7 @@ import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction import com.tangem.domain.staking.utils.getValidatorsCount import com.tangem.domain.tokens.* import com.tangem.domain.transaction.error.GetFeeError -import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase -import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase -import com.tangem.domain.transaction.usecase.GetAllowanceUseCase -import com.tangem.domain.transaction.usecase.GetFeeUseCase -import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.transaction.usecase.* import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.staking.api.StakingComponent @@ -78,9 +78,9 @@ import com.tangem.features.staking.impl.presentation.state.transformers.confirma import com.tangem.features.staking.impl.presentation.state.transformers.notifications.AddStakingNotificationsTransformer import com.tangem.features.staking.impl.presentation.state.transformers.notifications.DismissStakingNotificationsStateTransformer import com.tangem.features.staking.impl.presentation.state.transformers.ton.CompleteInitializeBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ton.SetFeeErrorToTonInitializeBottomSheetTransformer import com.tangem.features.staking.impl.presentation.state.transformers.ton.SetFeeToTonInitializeBottomSheetTransformer import com.tangem.features.staking.impl.presentation.state.transformers.ton.ShowTonInitializeBottomSheetTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.ton.SetFeeErrorToTonInitializeBottomSheetTransformer import com.tangem.features.staking.impl.presentation.state.transformers.validator.ValidatorSelectChangeTransformer import com.tangem.features.staking.impl.presentation.state.utils.checkAndCalculateSubtractedAmount import com.tangem.features.staking.impl.presentation.state.utils.isSingleAction @@ -139,12 +139,15 @@ internal class StakingModel @Inject constructor( private val getFeeUseCase: GetFeeUseCase, private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase, private val getActionRequirementAmountUseCase: GetActionRequirementAmountUseCase, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, private val paramsInterceptorHolder: ParamsInterceptorHolder, private val shareManager: ShareManager, private val urlOpener: UrlOpener, @DelayedWork private val coroutineScope: CoroutineScope, private val innerRouter: InnerStakingRouter, private val messageSender: UiMessageSender, + private val accountsFeatureToggles: AccountsFeatureToggles, appRouter: AppRouter, ) : Model(), StakingClickIntents { @@ -159,7 +162,7 @@ internal class StakingModel @Inject constructor( analyticsEventsHandler = analyticsEventHandler, ) - private val cryptoCurrencyId: CryptoCurrency.ID = params.cryptoCurrencyId + private val cryptoCurrencyId: CryptoCurrency.ID = params.cryptoCurrency.id private val userWalletId: UserWalletId = params.userWalletId private val yield: Yield = runBlocking { getYieldUseCase(params.yieldId).getOrElse { @@ -172,6 +175,8 @@ internal class StakingModel @Inject constructor( private var stakingActions: List = emptyList() private var feeCryptoCurrencyStatus: CryptoCurrencyStatus? = null private var minimumTransactionAmount: EnterAmountBoundary? = null + private val isBalanceHiddenFlow: StateFlow + field = MutableStateFlow(false) private var tonAccountInitializeTransaction: TransactionData.Uncompiled? = null @@ -237,6 +242,9 @@ internal class StakingModel @Inject constructor( private var isAnyTokenStaked: Boolean = false private val allowanceTaskScheduler = SingleTaskScheduler() + private var isAccountsModeEnabled: Boolean = true + private var account: Account.CryptoPortfolio? = null + private val transactionsInProgress: CopyOnWriteArrayList = CopyOnWriteArrayList() private var actionsJobHolder: JobHolder = JobHolder() @@ -247,7 +255,6 @@ internal class StakingModel @Inject constructor( init { subscribeOnSelectedAppCurrency() - subscribeOnBalanceHiding() subscribeOnCurrencyStatusUpdates() stateController.initializeWithUserWallet(userWallet) } @@ -297,6 +304,9 @@ internal class StakingModel @Inject constructor( cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, userWalletProvider = Provider { userWallet }, appCurrencyProvider = Provider { appCurrency }, + isBalanceHidden = isBalanceHiddenFlow.value, + isAccountsModeEnabled = isAccountsModeEnabled, + account = account, ).let(::add) AmountMaxValueStateTransformer( cryptoCurrencyStatus = cryptoCurrencyStatus, @@ -1012,10 +1022,12 @@ internal class StakingModel @Inject constructor( ) }, ifRight = { - stateController.update(CompleteInitializeBottomSheetTransformer( - cryptoCurrencyStatus = cryptoCurrencyStatus, - minimumTransactionAmount = minimumTransactionAmount, - )) + stateController.update( + CompleteInitializeBottomSheetTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + minimumTransactionAmount = minimumTransactionAmount, + ), + ) balanceUpdater.partialUpdateWithDelay() stateController.update(DismissBottomSheetStateTransformer) @@ -1045,72 +1057,89 @@ internal class StakingModel @Inject constructor( } private fun subscribeOnCurrencyStatusUpdates() { - getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( - userWalletId = userWalletId, - currencyId = cryptoCurrencyId, - isSingleWalletWithTokens = false, - ) - .conflate() - .distinctUntilChanged() - .filter { - value.currentStep == StakingStep.InitialInfo || isCaseWithUnitializedTonAccount() - } - .onEach { maybeStatus -> - maybeStatus.fold( - ifRight = { status -> - if (!isInitialInfoAnalyticSent) { - isInitialInfoAnalyticSent = true - val balances = status.value.yieldBalance as? YieldBalance.Data - paramsInterceptorHolder.addParamsInterceptor( - interceptor = StakingParamsInterceptor(status.currency.symbol), + if (accountsFeatureToggles.isFeatureEnabled) { + getAccountCurrencyStatusUseCase( + userWalletId = params.userWalletId, + currency = params.cryptoCurrency, + ).conflate().distinctUntilChanged() + .filter { + value.currentStep == StakingStep.InitialInfo || isCaseWithUnitializedTonAccount() + }.onEach { (maybeAccount, maybeStatus) -> + isAccountsModeEnabled = isAccountsModeEnabledUseCase.invokeSync() + account = maybeAccount + onDataLoaded(maybeStatus) + }.flowOn(dispatchers.main) + .launchIn(modelScope) + } else { + getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( + userWalletId = userWalletId, + currencyId = cryptoCurrencyId, + isSingleWalletWithTokens = false, + ).conflate().distinctUntilChanged() + .filter { + value.currentStep == StakingStep.InitialInfo || isCaseWithUnitializedTonAccount() + } + .onEach { maybeStatus -> + maybeStatus.fold( + ifRight = { onDataLoaded(it) }, + ifLeft = { error -> + stakingEventFactory.createGenericErrorAlert(error.toString()) + stateController.update( + SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus = cryptoCurrencyStatus), ) - analyticsEventHandler.send( - StakingAnalyticsEvent.StakingInfoScreenOpened( - validatorsCount = balances?.getValidatorsCount() ?: 0, - ), - ) - } + }, + ) + }.flowOn(dispatchers.main) + .launchIn(modelScope) + } + } - feeCryptoCurrencyStatus = - getFeePaidCryptoCurrencyStatusSyncUseCase(userWalletId, status).getOrNull() - minimumTransactionAmount = - getMinimumTransactionAmountSyncUseCase(userWalletId, status).getOrNull()?.let { - EnterAmountBoundary( - amount = it, - fiatRate = status.value.fiatRate.orZero(), - ) - } - cryptoCurrencyStatus = status + private suspend fun onDataLoaded(status: CryptoCurrencyStatus) { + if (value.currentStep != StakingStep.InitialInfo) return - val isAccountInitializedNew = checkAccountInitializedUseCase.invoke( - userWalletId = userWalletId, - network = cryptoCurrencyStatus.currency.network, - ).getOrElse { - Timber.e(it) - false - } - if (isAccountInitializedNew && !isAccountInitialized) { - isAccountInitialized = true - updateNotifications() - } - isAccountInitialized = isAccountInitializedNew + if (!isInitialInfoAnalyticSent) { + isInitialInfoAnalyticSent = true + val balances = status.value.yieldBalance as? YieldBalance.Data + paramsInterceptorHolder.addParamsInterceptor( + interceptor = StakingParamsInterceptor(status.currency.symbol), + ) + analyticsEventHandler.send( + StakingAnalyticsEvent.StakingInfoScreenOpened( + validatorsCount = balances?.getValidatorsCount() ?: 0, + ), + ) + } - setupApprovalNeeded() - setupIsAnyTokenStaked() - checkIfSubtractAvailable() - subscribeOnActionsUpdates(status) - subscribeOnStepChanges(status) - }, - ifLeft = { error -> - stakingEventFactory.createGenericErrorAlert(error.toString()) - stateController.update( - SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus = cryptoCurrencyStatus), - ) - }, + feeCryptoCurrencyStatus = + getFeePaidCryptoCurrencyStatusSyncUseCase(userWalletId, status).getOrNull() + minimumTransactionAmount = + getMinimumTransactionAmountSyncUseCase(userWalletId, status).getOrNull()?.let { + EnterAmountBoundary( + amount = it, + fiatRate = status.value.fiatRate.orZero(), ) } - .flowOn(dispatchers.main) - .launchIn(modelScope) + cryptoCurrencyStatus = status + + val isAccountInitializedNew = checkAccountInitializedUseCase.invoke( + userWalletId = userWalletId, + network = cryptoCurrencyStatus.currency.network, + ).getOrElse { + Timber.e(it) + false + } + if (isAccountInitializedNew && !isAccountInitialized) { + isAccountInitialized = true + updateNotifications() + } + isAccountInitialized = isAccountInitializedNew + + setupApprovalNeeded() + setupIsAnyTokenStaked() + checkIfSubtractAvailable() + subscribeOnActionsUpdates(status) + subscribeOnStepChanges(status) + subscribeOnBalanceHiding() } private fun subscribeOnBalanceHiding() { @@ -1118,7 +1147,14 @@ internal class StakingModel @Inject constructor( .conflate() .distinctUntilChanged() .onEach { - stateController.update(transformer = HideBalanceStateTransformer(it.isBalanceHidden)) + isBalanceHiddenFlow.value = it.isBalanceHidden + stateController.update( + transformer = HideBalanceStateTransformer( + isBalanceHidden = it.isBalanceHidden, + cryptoCurrencyStatus = cryptoCurrencyStatus, + appCurrency = appCurrency, + ), + ) } .flowOn(dispatchers.main) .launchIn(modelScope) @@ -1193,6 +1229,9 @@ internal class StakingModel @Inject constructor( userWalletProvider = Provider { userWallet }, appCurrencyProvider = Provider { appCurrency }, balancesToShowProvider = Provider { balancesToShow }, + isAccountsModeEnabled = isAccountsModeEnabled, + account = account, + isBalanceHidden = isBalanceHiddenFlow.value, ), SetConfirmationStateEmptyTransformer, ) @@ -1227,6 +1266,9 @@ internal class StakingModel @Inject constructor( cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, userWalletProvider = Provider { userWallet }, appCurrencyProvider = Provider { appCurrency }, + isAccountsModeEnabled = isAccountsModeEnabled, + isBalanceHidden = isBalanceHiddenFlow.value, + account = account, ), AmountChangeStateTransformer( cryptoCurrencyStatus = cryptoCurrencyStatus, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/HideBalanceStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/HideBalanceStateTransformer.kt index 84128a62a1..4de8481fef 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/HideBalanceStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/HideBalanceStateTransformer.kt @@ -1,13 +1,40 @@ package com.tangem.features.staking.impl.presentation.state.transformers +import com.tangem.common.ui.amountScreen.converters.field.AmountBoundaryUpdateTransformer +import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.utils.transformer.Transformer internal class HideBalanceStateTransformer( private val isBalanceHidden: Boolean, + private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val appCurrency: AppCurrency, ) : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { - return prevState.copy(isBalanceHidden = isBalanceHidden) + val cryptoBalanceValue = cryptoCurrencyStatus.value + val (amount, fiatAmount) = if (prevState.actionType !is StakingActionCommonType.Enter) { + prevState.balanceState?.cryptoAmount to prevState.balanceState?.fiatAmount + } else { + cryptoBalanceValue.amount to cryptoBalanceValue.fiatAmount + } + val maxEnterAmount = EnterAmountBoundary( + amount = amount, + fiatAmount = fiatAmount, + fiatRate = cryptoBalanceValue.fiatRate, + ) + + return prevState.copy( + isBalanceHidden = isBalanceHidden, + amountState = AmountBoundaryUpdateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxEnterAmount = maxEnterAmount, + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + ).transform(prevState.amountState), + ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt index 17ba69396f..8a299e98ef 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt @@ -1,6 +1,7 @@ package com.tangem.features.staking.impl.presentation.state.transformers import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.amountScreen.converters.AmountAccountConverter import com.tangem.common.ui.amountScreen.converters.AmountStateConverter import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary @@ -8,6 +9,7 @@ import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIco import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference 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.wallet.UserWallet import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType @@ -17,20 +19,28 @@ import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.utils.Provider import com.tangem.utils.transformer.Transformer +@Suppress("LongParameterList") internal class SetAmountDataTransformer( private val clickIntents: StakingClickIntents, private val cryptoCurrencyStatusProvider: Provider, private val userWalletProvider: Provider, private val appCurrencyProvider: Provider, + private val isBalanceHidden: Boolean, + private val isAccountsModeEnabled: Boolean, + private val account: Account.CryptoPortfolio?, ) : Transformer { private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) override fun transform(prevState: StakingUiState): StakingUiState { - val title = if (prevState.actionType is StakingActionCommonType.Exit) { - resourceReference(R.string.staking_staked_amount) - } else { - stringReference(userWalletProvider().name) + val accountTitleUM = when (prevState.actionType) { + is StakingActionCommonType.Exit -> AccountTitleUM.Text(resourceReference(R.string.staking_staked_amount)) + is StakingActionCommonType.Enter -> AmountAccountConverter( + isAccountsMode = isAccountsModeEnabled, + walletTitle = stringReference(userWalletProvider().name), + prefixText = resourceReference(R.string.common_from), + ).convert(account) + else -> AccountTitleUM.Text(resourceReference(R.string.common_amount)) } val cryptoBalanceValue = cryptoCurrencyStatusProvider().value val (amount, fiatAmount) = if (prevState.actionType !is StakingActionCommonType.Enter) { @@ -51,11 +61,11 @@ internal class SetAmountDataTransformer( maxEnterAmount = maxEnterAmount, appCurrency = appCurrencyProvider(), cryptoCurrencyStatus = cryptoCurrencyStatusProvider(), - isBalanceHidden = false, - accountTitleUM = AccountTitleUM.Text(title), + isBalanceHidden = isBalanceHidden, + accountTitleUM = accountTitleUM, ).convert( AmountParameters( - title = title, + title = stringReference(userWalletProvider().name), value = "", ), ), 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 86c5a2e899..e9e9fbe02b 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 @@ -2,7 +2,7 @@ package com.tangem.features.staking.impl.presentation.state.transformers import com.tangem.blockchain.common.Blockchain import com.tangem.common.extensions.remove -import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.amountScreen.converters.AmountAccountConverter import com.tangem.common.ui.amountScreen.converters.AmountStateConverter import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.common.ui.amountScreen.models.AmountState @@ -15,6 +15,7 @@ import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format 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.wallet.UserWallet @@ -46,6 +47,9 @@ internal class SetInitialDataStateTransformer( private val userWalletProvider: Provider, private val appCurrencyProvider: Provider, private val balancesToShowProvider: Provider>, + private val isAccountsModeEnabled: Boolean, + private val account: Account.CryptoPortfolio?, + private val isBalanceHidden: Boolean, ) : Transformer { private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) @@ -234,8 +238,12 @@ internal class SetInitialDataStateTransformer( appCurrency = appCurrencyProvider(), iconStateConverter = iconStateConverter, maxEnterAmount = maxEnterAmount, - isBalanceHidden = false, - accountTitleUM = AccountTitleUM.Text(stringReference(userWalletProvider().name)), + isBalanceHidden = isBalanceHidden, + accountTitleUM = AmountAccountConverter( + isAccountsMode = isAccountsModeEnabled, + walletTitle = stringReference(userWalletProvider().name), + prefixText = resourceReference(R.string.common_from), + ).convert(account), ).convert( AmountParameters( title = stringReference(userWalletProvider().name), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt index 7efc5b644c..60ba58a048 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt @@ -48,6 +48,7 @@ internal fun StakingFeeBlock(feeState: FeeState) { text = stringResourceSafe(R.string.common_network_fee_title), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing4), ) Box(modifier = Modifier.padding(top = TangemTheme.dimens.spacing8)) { 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 53f66ed8af..53402f0c11 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 @@ -3,15 +3,23 @@ package com.tangem.features.staking.impl.presentation.ui.block 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.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text import androidx.compose.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.testTag -import com.tangem.core.ui.components.inputrow.InputRowImageInfo +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 @@ -42,13 +50,37 @@ internal fun ValidatorBlock(validatorState: StakingStates.ValidatorState, isClic ) .testTag(StakingSendDetailsScreenTestTags.VALIDATOR_BLOCK), ) { - InputRowImageInfo( - title = resourceReference(R.string.staking_validator), - subtitle = stringReference(state.chosenValidator.name), - infoTitle = state.getInfoTitleNeutral(), - imageUrl = state.chosenValidator.image.orEmpty(), - onImageError = { ValidatorImagePlaceholder() }, + Text( + text = stringResourceSafe(R.string.staking_validator), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp, bottom = 4.dp), ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(12.dp), + ) { + InputRowAsyncImage( + imageUrl = validatorState.chosenValidator.image.orEmpty(), + onImageError = { ValidatorImagePlaceholder() }, + modifier = Modifier + .size(24.dp) + .clip(TangemTheme.shapes.roundedCornersXLarge), + ) + Text( + text = validatorState.chosenValidator.name, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + SpacerWMax() + Text( + text = validatorState.getInfoTitleNeutral().resolveReference(), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.tertiary, + ) + } } } diff --git a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsContainerComponent.kt similarity index 54% rename from features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt rename to features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsContainerComponent.kt index 428f063730..137a7efff8 100644 --- a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsContainerComponent.kt @@ -2,9 +2,10 @@ package com.tangem.features.tangempay.components import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayDetailsConfig -interface TangemPayDetailsComponent : ComposableContentComponent { - data class Params(val config: TangemPayDetailsConfig) - interface Factory : ComponentFactory +interface TangemPayDetailsContainerComponent : ComposableContentComponent { + data class Params(val userWalletId: UserWalletId, val config: TangemPayDetailsConfig) + interface Factory : ComponentFactory } \ 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 cc2ac540ec..d3d42f6a91 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -15,8 +15,12 @@ dependencies { /** Core */ implementation(projects.core.configToggles) implementation(projects.core.decompose) - implementation(projects.core.ui) implementation(projects.core.error) + implementation(projects.core.navigation) + implementation(projects.core.ui) + + /** Common */ + implementation(projects.common.ui) /** Features api */ implementation(projects.features.tangempay.details.api) @@ -26,9 +30,12 @@ dependencies { /** Domain */ implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) + implementation(projects.domain.feedback) + implementation(projects.domain.feedback.models) implementation(projects.domain.models) implementation(projects.domain.visa) implementation(projects.domain.visa.models) + implementation(projects.domain.wallets) /** Compose */ implementation(deps.compose.coil) @@ -39,6 +46,9 @@ dependencies { implementation(deps.compose.ui.tooling) implementation(deps.decompose.ext.compose) + /** AndroidX */ + implementation(deps.androidx.activity.compose) + /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) 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 new file mode 100644 index 0000000000..0c8c21c4cc --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -0,0 +1,82 @@ +package com.tangem.features.tangempay.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.activity.compose.BackHandler +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.stack.Children +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.navigation.inner.InnerRouter +import com.tangem.core.ui.decompose.ComposableContentComponent +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( + @Assisted private val appComponentContext: AppComponentContext, + @Assisted private val params: TangemPayDetailsContainerComponent.Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, +) : AppComponentContext by appComponentContext, TangemPayDetailsContainerComponent { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val childStack = childStack( + key = "tangemPayDetailsInnerStack", + source = stackNavigation, + serializer = TangemPayDetailsInnerRoute.serializer(), + initialConfiguration = TangemPayDetailsInnerRoute.Details, + childFactory = ::screenChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val childStack by childStack.subscribeAsState() + + BackHandler(onBack = router::pop) + Children(stack = childStack, animation = stackAnimation()) { child -> + child.instance.Content(modifier = modifier) + } + } + + private fun screenChild( + config: TangemPayDetailsInnerRoute, + componentContext: ComponentContext, + ): ComposableContentComponent = when (config) { + TangemPayDetailsInnerRoute.Details -> TangemPayDetailsComponent( + appComponentContext = childByContext(componentContext), + innerRouter = innerRouter, + params = params, + tokenReceiveComponentFactory = tokenReceiveComponentFactory, + ) + TangemPayDetailsInnerRoute.ChangePIN -> TODO(" [REDACTED_JIRA]") + } + + private fun onChildBack() { + when (childStack.value.active.configuration) { + TangemPayDetailsInnerRoute.ChangePIN -> stackNavigation.pop() + TangemPayDetailsInnerRoute.Details -> router.pop() + } + } + + @AssistedFactory + interface Factory : TangemPayDetailsContainerComponent.Factory { + override fun create( + context: AppComponentContext, + params: TangemPayDetailsContainerComponent.Params, + ): DefaultTangemPayDetailsContainerComponent + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt similarity index 56% rename from features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt rename to features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index adad4cfd19..3f46e8eba1 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -12,24 +12,25 @@ 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.decompose.navigation.Router import com.tangem.core.ui.components.NavigationBar3ButtonsScrim import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent +import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent import com.tangem.features.tangempay.model.TangemPayDetailsModel -import com.tangem.features.tangempay.model.TangemPayDetailsNavigation +import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation import com.tangem.features.tangempay.ui.TangemPayDetailsScreen import com.tangem.features.tokenreceive.TokenReceiveComponent -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -internal class DefaultTangemPayDetailsComponent @AssistedInject constructor( - @Assisted private val appComponentContext: AppComponentContext, - @Assisted private val params: TangemPayDetailsComponent.Params, +internal class TangemPayDetailsComponent( + private val appComponentContext: AppComponentContext, + innerRouter: Router, + private val params: TangemPayDetailsContainerComponent.Params, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, -) : AppComponentContext by appComponentContext, TangemPayDetailsComponent { +) : AppComponentContext by appComponentContext, ComposableContentComponent { - private val model: TangemPayDetailsModel = getOrCreateModel(params = params) + private val model: TangemPayDetailsModel = getOrCreateModel(params = params, router = innerRouter) private val bottomSheetSlot = childSlot( source = model.bottomSheetNavigation, @@ -39,7 +40,10 @@ internal class DefaultTangemPayDetailsComponent @AssistedInject constructor( ) private val txHistoryComponent = DefaultTangemPayTxHistoryComponent( appComponentContext = child("txHistoryComponent"), - params = DefaultTangemPayTxHistoryComponent.Params(customerWalletAddress = params.config.customerWalletAddress), + params = DefaultTangemPayTxHistoryComponent.Params( + customerWalletAddress = params.config.customerWalletAddress, + uiActions = model, + ), ) @Composable @@ -59,26 +63,24 @@ internal class DefaultTangemPayDetailsComponent @AssistedInject constructor( private fun bottomSheetChild( navigation: TangemPayDetailsNavigation, componentContext: ComponentContext, - ): ComposableBottomSheetComponent = when (navigation) { - is TangemPayDetailsNavigation.Error -> TangemPayErrorBottomSheetComponent( - appComponentContext = appComponentContext, - messageUM = navigation.messageUM, - onDismiss = model.bottomSheetNavigation::dismiss, - ) - is TangemPayDetailsNavigation.Receive -> tokenReceiveComponentFactory.create( - context = childByContext(componentContext), - params = TokenReceiveComponent.Params( - config = navigation.config, - onDismiss = model.bottomSheetNavigation::dismiss, - ), - ) - } - - @AssistedFactory - interface Factory : TangemPayDetailsComponent.Factory { - override fun create( - context: AppComponentContext, - params: TangemPayDetailsComponent.Params, - ): DefaultTangemPayDetailsComponent + ): ComposableBottomSheetComponent { + val context = childByContext(componentContext) + return when (navigation) { + is TangemPayDetailsNavigation.Receive -> tokenReceiveComponentFactory.create( + context = context, + params = TokenReceiveComponent.Params( + config = navigation.config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + is TangemPayDetailsNavigation.TransactionDetails -> TangemPayTxHistoryDetailsComponent( + appComponentContext = context, + params = TangemPayTxHistoryDetailsComponent.Params( + transaction = navigation.transaction, + userWalletId = params.userWalletId, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + } } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayErrorBottomSheetComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayErrorBottomSheetComponent.kt deleted file mode 100644 index 62e7a375f5..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayErrorBottomSheetComponent.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.features.tangempay.components - -import androidx.compose.runtime.Composable -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetV2 -import com.tangem.core.ui.decompose.ComposableBottomSheetComponent - -internal class TangemPayErrorBottomSheetComponent( - appComponentContext: AppComponentContext, - private val messageUM: MessageBottomSheetUMV2, - private val onDismiss: () -> Unit, -) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { - - override fun dismiss() { - onDismiss() - } - - @Composable - override fun BottomSheet() { - MessageBottomSheetV2(state = messageUM, onDismissRequest = ::dismiss) - } -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/DefaultTangemPayTxHistoryComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/DefaultTangemPayTxHistoryComponent.kt index 4338d0ecbc..42a85d03c4 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/DefaultTangemPayTxHistoryComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/DefaultTangemPayTxHistoryComponent.kt @@ -7,6 +7,7 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM import com.tangem.features.tangempay.model.TangemPayTxHistoryModel import com.tangem.features.tangempay.ui.tangemPayTxHistoryItems +import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions import kotlinx.coroutines.flow.StateFlow internal class DefaultTangemPayTxHistoryComponent( @@ -21,5 +22,5 @@ internal class DefaultTangemPayTxHistoryComponent( tangemPayTxHistoryItems(listState, state) } - data class Params(val customerWalletAddress: String) + data class Params(val customerWalletAddress: String, val uiActions: TangemPayTxHistoryUiActions) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryDetailsComponent.kt new file mode 100644 index 0000000000..345ca4a497 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryDetailsComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.features.tangempay.components.txHistory + +import androidx.compose.runtime.Composable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.extensions.* +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel +import com.tangem.features.tangempay.ui.TangemPayTxHistoryDetailsContent + +internal class TangemPayTxHistoryDetailsComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: TangemPayTxHistoryDetailsModel = getOrCreateModel(params = params) + + override fun dismiss() { + model.dismiss() + } + + @Composable + override fun BottomSheet() { + TangemPayTxHistoryDetailsContent(state = model.uiState) + } + + data class Params( + val transaction: TangemPayTxHistoryItem, + val userWalletId: UserWalletId, + val onDismiss: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt index 4cd92fc806..0b7e6dac5d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt @@ -1,7 +1,7 @@ package com.tangem.features.tangempay.di -import com.tangem.features.tangempay.components.DefaultTangemPayDetailsComponent -import com.tangem.features.tangempay.components.TangemPayDetailsComponent +import com.tangem.features.tangempay.components.DefaultTangemPayDetailsContainerComponent +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -14,7 +14,7 @@ internal interface TangemPayDetailsFeatureModule { @Binds @Singleton - fun bindTangemPayDetailsComponentFactory( - factory: DefaultTangemPayDetailsComponent.Factory, - ): TangemPayDetailsComponent.Factory + fun bindTangemPayDetailsContainerComponentFactory( + factory: DefaultTangemPayDetailsContainerComponent.Factory, + ): TangemPayDetailsContainerComponent.Factory } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt index ef72f17c45..cc9f121abb 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt @@ -3,6 +3,7 @@ package com.tangem.features.tangempay.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.tangempay.model.TangemPayDetailsModel +import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel import com.tangem.features.tangempay.model.TangemPayTxHistoryModel import dagger.Binds import dagger.Module @@ -23,4 +24,9 @@ internal interface TangemPayModelModule { @IntoMap @ClassKey(TangemPayTxHistoryModel::class) fun bindTangemPayTxHistoryModel(model: TangemPayTxHistoryModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayTxHistoryDetailsModel::class) + fun bindTangemPayTxHistoryDetailsModel(model: TangemPayTxHistoryDetailsModel): Model } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt new file mode 100644 index 0000000000..4bf153edca --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt @@ -0,0 +1,15 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class TangemPayDetailsNavigation { + + @Serializable + data class Receive(val config: TokenReceiveConfig) : TangemPayDetailsNavigation() + + @Serializable + data class TransactionDetails(val transaction: TangemPayTxHistoryItem) : TangemPayDetailsNavigation() +} \ No newline at end of file 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 0946f1aa37..ac4e2192ce 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 @@ -3,8 +3,10 @@ package com.tangem.features.tangempay.entity import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem 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.tangempay.details.impl.R import com.tangem.features.tangempay.utils.CardDetailsFormatUtil import com.tangem.utils.StringsSigns @@ -14,6 +16,7 @@ private const val CARD_NUMBER_SART_DIGITS_COUNT = 12 private const val DATE_PART_LENGTH = 2 private const val CVV_LENGTH = 3 +@Suppress("LongParameterList") internal class TangemPayDetailsStateFactory( private val cardNumberEnd: String, private val onBack: () -> Unit, @@ -21,6 +24,8 @@ internal class TangemPayDetailsStateFactory( private val onReceive: () -> Unit, private val onReveal: () -> Unit, private val onCopy: (String) -> Unit, + private val onClickChangePin: () -> Unit, + private val onClickFreezeCard: () -> Unit, ) { private val cardStartMasked = maskedBlock(CARD_NUMBER_SART_DIGITS_COUNT) @@ -28,7 +33,21 @@ internal class TangemPayDetailsStateFactory( private val cvvMasked = maskedBlock(CVV_LENGTH) fun getInitialState() = TangemPayDetailsUM( - topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = onBack, items = null), + topBarConfig = TangemPayDetailsTopBarConfig( + onBackClick = onBack, + items = persistentListOf( + TangemDropdownMenuItem( + title = TextReference.Res(R.string.tangempay_card_details_change_pin), + textColorProvider = { TangemTheme.colors.text.primary1 }, + onClick = onClickChangePin, + ), + TangemDropdownMenuItem( + title = TextReference.Res(R.string.tangempay_card_details_freeze_card), + textColorProvider = { TangemTheme.colors.text.primary1 }, + onClick = onClickFreezeCard, + ), + ), + ), pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = onRefresh), balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( actionButtons = persistentListOf( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayTxHistoryDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayTxHistoryDetailsUM.kt new file mode 100644 index 0000000000..2ca794500d --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayTxHistoryDetailsUM.kt @@ -0,0 +1,23 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.ColorReference +import com.tangem.core.ui.extensions.ImageReference +import com.tangem.core.ui.extensions.TextReference + +internal data class TangemPayTxHistoryDetailsUM( + val title: TextReference, + val iconState: ImageReference, + val transactionTitle: TextReference, + val transactionSubtitle: TextReference, + val transactionAmount: String, + val transactionAmountColor: ColorReference, + val labelState: LabelUM?, + val notification: NotificationConfig?, + val buttonState: ButtonState, + val dismiss: () -> Unit, +) { + + data class ButtonState(val text: TextReference, val onClick: () -> Unit) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index da92a7606c..b910852a53 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 @@ -4,7 +4,6 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.toArgb import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate -import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -19,13 +18,16 @@ import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.TokenReceiveType import com.tangem.domain.pay.DataForReceiveFactory import com.tangem.domain.pay.repository.CardDetailsRepository -import com.tangem.features.tangempay.components.TangemPayDetailsComponent +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType +import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory import com.tangem.features.tangempay.entity.TangemPayDetailsUM import com.tangem.features.tangempay.model.transformers.* import com.tangem.features.tangempay.utils.TangemPayErrorMessageFactory +import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -53,9 +55,9 @@ internal class TangemPayDetailsModel @Inject constructor( private val dataForReceiveFactory: DataForReceiveFactory, private val clipboardManager: ClipboardManager, private val uiMessageSender: UiMessageSender, -) : Model() { +) : Model(), TangemPayTxHistoryUiActions { - private val params: TangemPayDetailsComponent.Params = paramsContainer.require() + private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() private val stateFactory = TangemPayDetailsStateFactory( cardNumberEnd = params.config.cardNumberEnd, @@ -64,6 +66,8 @@ internal class TangemPayDetailsModel @Inject constructor( onReceive = ::onClickReceive, onReveal = ::revealCardDetails, onCopy = ::copyData, + onClickChangePin = ::onClickChangePin, + onClickFreezeCard = ::onClickFreezeCard, ) val uiState: StateFlow @@ -79,10 +83,18 @@ internal class TangemPayDetailsModel @Inject constructor( fetchBalance() } + private fun onClickChangePin() { + // TODO [REDACTED_JIRA] + } + + private fun onClickFreezeCard() { + // TODO [REDACTED_JIRA] + } + private fun onClickReceive() { val depositAddress = params.config.depositAddress if (depositAddress == null) { - showError() + showBottomSheetError(TangemPayDetailsErrorType.Receive) } else { dataForReceiveFactory.getDataForReceive(depositAddress = depositAddress, chainId = params.config.chainId) .onRight { @@ -101,13 +113,7 @@ internal class TangemPayDetailsModel @Inject constructor( ) bottomSheetNavigation.activate(TangemPayDetailsNavigation.Receive(config)) } - .onLeft { - val messageUM = TangemPayErrorMessageFactory.createError( - type = TangemPayDetailsErrorType.Receive, - onDismiss = bottomSheetNavigation::dismiss, - ) - bottomSheetNavigation.activate(TangemPayDetailsNavigation.Error(messageUM)) - } + .onLeft { showBottomSheetError(TangemPayDetailsErrorType.Receive) } } } @@ -163,4 +169,12 @@ internal class TangemPayDetailsModel @Inject constructor( SnackbarMessage(TextReference.Res(R.string.tangempay_card_details_error_text)), ) } + + override fun onTransactionClick(item: TangemPayTxHistoryItem) { + bottomSheetNavigation.activate(TangemPayDetailsNavigation.TransactionDetails(item)) + } + + private fun showBottomSheetError(type: TangemPayDetailsErrorType) { + uiMessageSender.send(message = TangemPayErrorMessageFactory.createErrorMessage(type = type)) + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsNavigation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsNavigation.kt deleted file mode 100644 index bcf24b8829..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsNavigation.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.features.tangempay.model - -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 -import com.tangem.domain.models.TokenReceiveConfig -import kotlinx.serialization.Serializable - -@Serializable -internal sealed class TangemPayDetailsNavigation { - - data class Receive( - val config: TokenReceiveConfig, - ) : TangemPayDetailsNavigation() - - data class Error( - val messageUM: MessageBottomSheetUMV2, - ) : TangemPayDetailsNavigation() -} \ No newline at end of file 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 new file mode 100644 index 0000000000..48d190991e --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt @@ -0,0 +1,66 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.wallet.requireColdWallet +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent +import com.tangem.features.tangempay.entity.TangemPayTxHistoryDetailsUM +import com.tangem.features.tangempay.model.transformers.TangemPayTxHistoryDetailsConverter +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayTxHistoryDetailsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val getUserWalletsUseCase: GetWalletsUseCase, + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val urlOpener: UrlOpener, + paramsContainer: ParamsContainer, +) : Model() { + + private val params = paramsContainer.require() + val uiState: TangemPayTxHistoryDetailsUM = TangemPayTxHistoryDetailsConverter.convert( + TangemPayTxHistoryDetailsConverter.Input( + item = params.transaction, + onExplorerClick = ::openExplorer, + onDisputeClick = ::dispute, + onDismiss = ::dismiss, + ), + ) + + fun dismiss() { + params.onDismiss() + } + + fun openExplorer(txHash: String?) { + txHash?.let(urlOpener::openUrlExternalBrowser) + } + + fun dispute() { + modelScope.launch { + val userWalletId = params.userWalletId + val userWallet = getUserWalletsUseCase.invokeSync() + .firstOrNull { it.walletId == userWalletId } ?: return@launch + val walletMetaInfo = getWalletMetaInfoUseCase.invoke( + userWallet.requireColdWallet().scanResponse, + ).getOrNull() ?: return@launch + + sendFeedbackEmailUseCase.invoke( + FeedbackEmailType.Visa.DisputeV2( + item = params.transaction, + walletMetaInfo = walletMetaInfo, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt index ac67e1d5b4..85a8c31571 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt @@ -6,17 +6,14 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository -import com.tangem.domain.visa.model.TangemPayTxHistoryItem import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM import com.tangem.features.tangempay.utils.TangemPayTxHistoryListManager -import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions import com.tangem.pagination.PaginationStatus import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import timber.log.Timber import javax.inject.Inject @Stable @@ -26,14 +23,14 @@ internal class TangemPayTxHistoryModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, tangemPayTxHistoryRepository: TangemPayTxHistoryRepository, paramsContainer: ParamsContainer, -) : Model(), TangemPayTxHistoryUiActions { +) : Model() { private val params: DefaultTangemPayTxHistoryComponent.Params = paramsContainer.require() private val listManager = TangemPayTxHistoryListManager( repository = tangemPayTxHistoryRepository, dispatchers = dispatchers, customerWalletAddress = params.customerWalletAddress, - txHistoryUiActions = this, + txHistoryUiActions = params.uiActions, ) val uiState: StateFlow @@ -115,10 +112,6 @@ internal class TangemPayTxHistoryModel @Inject constructor( .launchIn(modelScope) } - override fun onTransactionClick(item: TangemPayTxHistoryItem) { - Timber.d("onTransactionClick: $item") - } - private fun getEmptyState(isBalanceHidden: Boolean): TangemPayTxHistoryUM.Empty { return TangemPayTxHistoryUM.Empty(isBalanceHidden = isBalanceHidden) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryDetailsConverter.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryDetailsConverter.kt new file mode 100644 index 0000000000..edf1708a96 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryDetailsConverter.kt @@ -0,0 +1,202 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.core.ui.components.label.entity.LabelStyle +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayTxHistoryDetailsUM +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isPositive + +internal object TangemPayTxHistoryDetailsConverter : + Converter { + private val dateFormatter = DateTimeFormatters.getBestFormatterBySkeleton("dd MMMM") + + override fun convert(value: Input): TangemPayTxHistoryDetailsUM { + val transaction = value.item + return TangemPayTxHistoryDetailsUM( + title = transaction.extractDate(), + iconState = transaction.extractIcon(), + transactionTitle = transaction.extractTransactionTitle(), + transactionSubtitle = transaction.extractTransactionSubtitle(), + transactionAmount = transaction.extractAmount(), + transactionAmountColor = value.item.extractAmountColor(), + labelState = value.item.extractLabel(), + notification = value.item.extractNotification(), + buttonState = value.extractButtonState(), + dismiss = value.onDismiss, + ) + } + + private fun TangemPayTxHistoryItem.extractDate(): TextReference { + val date = DateTimeFormatters.formatDate(this.date, dateFormatter) + val time = DateTimeFormatters.formatDate(this.date, DateTimeFormatters.timeFormatter) + + return stringReference("$date ${StringsSigns.DOT} $time") + } + + private fun TangemPayTxHistoryItem.extractIcon(): ImageReference { + return when (this) { + is TangemPayTxHistoryItem.Fee -> ImageReference.Res(R.drawable.ic_percent_24) + is TangemPayTxHistoryItem.Payment -> { + if (this.amount.isPositive()) { + ImageReference.Res(R.drawable.ic_arrow_down_24) + } else { + ImageReference.Res(R.drawable.ic_arrow_up_24) + } + } + is TangemPayTxHistoryItem.Spend -> { + val merchantIcon = this.enrichedMerchantIconUrl + if (merchantIcon != null) { + ImageReference.Url(merchantIcon) + } else { + ImageReference.Res(R.drawable.ic_category_24) + } + } + } + } + + private fun TangemPayTxHistoryItem.extractTransactionTitle(): TextReference { + return when (this) { + is TangemPayTxHistoryItem.Fee -> resourceReference(R.string.tangem_pay_fee_title) + is TangemPayTxHistoryItem.Spend -> stringReference(this.enrichedMerchantName ?: this.merchantName) + is TangemPayTxHistoryItem.Payment -> if (this.amount.isPositive()) { + resourceReference(R.string.tangem_pay_deposit) + } else { + resourceReference(R.string.tangem_pay_withdrawal) + } + } + } + + private fun TangemPayTxHistoryItem.extractTransactionSubtitle(): TextReference { + return when (this) { + is TangemPayTxHistoryItem.Fee -> resourceReference(R.string.tangem_pay_fee_subtitle) + is TangemPayTxHistoryItem.Payment -> resourceReference(R.string.common_transfer) + is TangemPayTxHistoryItem.Spend -> stringReference(this.enrichedMerchantCategory ?: this.merchantCategory) + } + } + + private fun TangemPayTxHistoryItem.extractAmount(): String { + return when (this) { + is TangemPayTxHistoryItem.Fee, + is TangemPayTxHistoryItem.Spend, + -> { + val amount = this.amount.format { + fiat( + fiatCurrencyCode = this@extractAmount.currency.currencyCode, + fiatCurrencySymbol = this@extractAmount.currency.symbol, + ) + } + StringsSigns.MINUS + amount + } + is TangemPayTxHistoryItem.Payment -> { + val amount = this.amount.format { + fiat( + fiatCurrencyCode = this@extractAmount.currency.currencyCode, + fiatCurrencySymbol = this@extractAmount.currency.symbol, + ) + } + if (this.amount.isPositive()) { + StringsSigns.PLUS + amount + } else { + StringsSigns.MINUS + amount + } + } + } + } + + private fun TangemPayTxHistoryItem.extractAmountColor(): ColorReference { + return when (this) { + is TangemPayTxHistoryItem.Fee, + is TangemPayTxHistoryItem.Spend, + -> themedColor { TangemTheme.colors.text.primary1 } + is TangemPayTxHistoryItem.Payment -> themedColor { + if (this.amount.isPositive()) { + TangemTheme.colors.text.accent + } else { + TangemTheme.colors.text.primary1 + } + } + } + } + + private fun TangemPayTxHistoryItem.extractLabel(): LabelUM? { + return when (this) { + is TangemPayTxHistoryItem.Fee, + is TangemPayTxHistoryItem.Payment, + -> null + is TangemPayTxHistoryItem.Spend -> when (this.status) { + TangemPayTxHistoryItem.Status.COMPLETED -> LabelUM( + text = resourceReference(R.string.tangem_pay_status_completed), + style = LabelStyle.ACCENT, + ) + TangemPayTxHistoryItem.Status.PENDING -> LabelUM( + text = resourceReference(R.string.tangem_pay_status_pending), + style = LabelStyle.REGULAR, + icon = com.tangem.core.ui.R.drawable.ic_clock_24, + ) + TangemPayTxHistoryItem.Status.DECLINED -> LabelUM( + text = resourceReference(R.string.tangem_pay_status_declined), + style = LabelStyle.WARNING, + ) + TangemPayTxHistoryItem.Status.RESERVED, + TangemPayTxHistoryItem.Status.UNKNOWN, + -> null + } + } + } + + private fun TangemPayTxHistoryItem.extractNotification(): NotificationConfig? { + return when (this) { + is TangemPayTxHistoryItem.Payment -> null + is TangemPayTxHistoryItem.Fee -> NotificationConfig( + title = resourceReference(R.string.tangem_pay_transaction_fee_notification_text), + subtitle = TextReference.EMPTY, + iconResId = R.drawable.ic_token_info_24, + ) + is TangemPayTxHistoryItem.Spend -> when (this.status) { + TangemPayTxHistoryItem.Status.DECLINED -> NotificationConfig( + title = resourceReference(R.string.tangem_pay_transaction_declined_notification_text), + subtitle = TextReference.EMPTY, + iconResId = R.drawable.ic_token_info_24, + ) + TangemPayTxHistoryItem.Status.PENDING, + TangemPayTxHistoryItem.Status.COMPLETED, + TangemPayTxHistoryItem.Status.RESERVED, + TangemPayTxHistoryItem.Status.UNKNOWN, + -> null + } + } + } + + private fun Input.extractButtonState(): TangemPayTxHistoryDetailsUM.ButtonState { + return when (this.item) { + is TangemPayTxHistoryItem.Fee -> TangemPayTxHistoryDetailsUM.ButtonState( + text = resourceReference(R.string.tangem_pay_dispute), + onClick = this.onDisputeClick, + ) + is TangemPayTxHistoryItem.Spend -> TangemPayTxHistoryDetailsUM.ButtonState( + text = resourceReference(R.string.tangem_pay_dispute), + onClick = this.onDisputeClick, + ) + is TangemPayTxHistoryItem.Payment -> TangemPayTxHistoryDetailsUM.ButtonState( + text = resourceReference(R.string.tangem_pay_explore_transaction), + onClick = { this.onExplorerClick(this.item.transactionHash) }, + ) + } + } + + data class Input( + val item: TangemPayTxHistoryItem, + val onExplorerClick: (String?) -> Unit, + val onDisputeClick: () -> Unit, + val onDismiss: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt new file mode 100644 index 0000000000..9aa69d5a1f --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt @@ -0,0 +1,13 @@ +package com.tangem.features.tangempay.navigation + +import com.tangem.core.decompose.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class TangemPayDetailsInnerRoute : Route { + @Serializable + data object Details : TangemPayDetailsInnerRoute() + + @Serializable + data object ChangePIN : TangemPayDetailsInnerRoute() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/InternalComponents.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/InternalComponents.kt new file mode 100644 index 0000000000..874fab6548 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/InternalComponents.kt @@ -0,0 +1,46 @@ +package com.tangem.features.tangempay.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import coil.compose.rememberAsyncImagePainter +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun RemoteIcon(url: String, modifier: Modifier = Modifier) { + Icon( + modifier = modifier.clip(CircleShape), + painter = rememberAsyncImagePainter(url), + contentDescription = null, + tint = Color.Unspecified, + ) +} + +@Composable +internal fun LocalStaticIcon(@DrawableRes id: Int, iconSize: Dp, modifier: Modifier = Modifier) { + Box( + modifier = modifier.background( + color = TangemTheme.colors.icon.secondary.copy(alpha = 0.1F), + shape = CircleShape, + ), + ) { + Icon( + painter = painterResource(id), + contentDescription = null, + modifier = Modifier + .size(iconSize) + .align(Alignment.Center), + tint = TangemTheme.colors.icon.informative, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt index bcaec6f86e..deddf2a265 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt @@ -1,13 +1,11 @@ package com.tangem.features.tangempay.ui -import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -15,7 +13,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign @@ -24,7 +21,6 @@ import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ChainStyle import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension -import coil.compose.rememberAsyncImagePainter import com.tangem.core.ui.R import com.tangem.core.ui.components.CircleShimmer import com.tangem.core.ui.components.RectangleShimmer @@ -264,47 +260,24 @@ private fun Icon(state: TangemPayTransactionState, modifier: Modifier = Modifier if (state.iconUrl != null) { RemoteIcon(modifier = modifier, url = state.iconUrl) } else { - LocalStaticIcon(modifier = modifier, id = R.drawable.ic_category_24) + LocalStaticIcon( + modifier = modifier, + id = R.drawable.ic_category_24, + iconSize = TangemTheme.dimens.size20, + ) } } - is TangemPayTransactionState.Content.Fee -> LocalStaticIcon(modifier = modifier, id = R.drawable.ic_percent_24) + is TangemPayTransactionState.Content.Fee -> LocalStaticIcon( + modifier = modifier, + id = R.drawable.ic_percent_24, + iconSize = TangemTheme.dimens.size20, + ) is TangemPayTransactionState.Content.Payment -> LocalStaticIcon( modifier = modifier, id = if (state.isIncome) R.drawable.ic_arrow_down_24 else R.drawable.ic_arrow_up_24, + iconSize = TangemTheme.dimens.size20, ) - is TangemPayTransactionState.Loading -> { - CircleShimmer(modifier = modifier.size(TangemTheme.dimens.size40)) - } - } -} - -@Composable -private fun RemoteIcon(url: String, modifier: Modifier = Modifier) { - Icon( - modifier = modifier - .size(TangemTheme.dimens.size40) - .clip(CircleShape), - painter = rememberAsyncImagePainter(url), - contentDescription = null, - tint = Color.Unspecified, - ) -} - -@Composable -private fun LocalStaticIcon(@DrawableRes id: Int, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .size(TangemTheme.dimens.size40) - .background(color = TangemTheme.colors.icon.secondary.copy(alpha = 0.1F), shape = CircleShape), - ) { - Icon( - painter = painterResource(id), - contentDescription = null, - modifier = Modifier - .size(TangemTheme.dimens.size20) - .align(Alignment.Center), - tint = TangemTheme.colors.icon.informative, - ) + is TangemPayTransactionState.Loading -> CircleShimmer(modifier = modifier) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangempayTxDetailsUi.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangempayTxDetailsUi.kt new file mode 100644 index 0000000000..7d79f24a4e --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangempayTxDetailsUi.kt @@ -0,0 +1,241 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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 com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SpacerH32 +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.components.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.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.ImageReference +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.themedColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayTxHistoryDetailsUM + +@Composable +internal fun TangemPayTxHistoryDetailsContent(state: TangemPayTxHistoryDetailsUM) { + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = state.dismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = state.dismiss, + containerColor = TangemTheme.colors.background.tertiary, + title = { + TangemModalBottomSheetTitle( + title = state.title, + endIconRes = R.drawable.ic_close_24, + onEndClick = state.dismiss, + ) + }, + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier + .padding(top = 24.dp) + .size(88.dp), + iconState = state.iconState, + ) + Text( + modifier = Modifier.padding(top = 32.dp), + text = state.transactionTitle.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier.padding(top = 2.dp), + text = state.transactionSubtitle.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + Text( + modifier = Modifier.padding(top = 8.dp), + text = state.transactionAmount, + style = TangemTheme.typography.head, + color = state.transactionAmountColor.resolveReference(), + ) + state.labelState?.let { Label(state = state.labelState, modifier = Modifier.padding(top = 12.dp)) } + SpacerH32() + state.notification?.let { + Notification( + config = state.notification, + titleColor = TangemTheme.colors.text.tertiary, + iconTint = TangemTheme.colors.icon.secondary, + ) + } + ButtonContainer( + modifier = Modifier + .padding(vertical = 16.dp) + .fillMaxWidth(), + buttonState = state.buttonState, + ) + } + } +} + +@Composable +private fun ButtonContainer(buttonState: TangemPayTxHistoryDetailsUM.ButtonState, modifier: Modifier = Modifier) { + SecondaryButton(modifier = modifier, text = buttonState.text.resolveReference(), onClick = buttonState.onClick) +} + +@Composable +private fun Icon(iconState: ImageReference, modifier: Modifier = Modifier) { + when (iconState) { + is ImageReference.Res -> LocalStaticIcon(modifier = modifier, id = iconState.resId, iconSize = 40.dp) + is ImageReference.Url -> RemoteIcon(modifier = modifier, url = iconState.url) + } +} + +@Preview(device = Devices.PIXEL_7_PRO) +@Preview(device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemPayTxHistoryDetailsContentPreview( + @PreviewParameter(TangemPayTxHistoryDetailsUMProvider::class) state: TangemPayTxHistoryDetailsUM, +) { + TangemThemePreview { + TangemPayTxHistoryDetailsContent(state = state) + } +} + +private class TangemPayTxHistoryDetailsUMProvider : CollectionPreviewParameterProvider( + listOf( + TangemPayTxHistoryDetailsUM( + title = stringReference("12 June • 12:40"), + iconState = ImageReference.Res(R.drawable.ic_category_24), + transactionTitle = stringReference("Starbucks"), + transactionSubtitle = stringReference("Food and drinks"), + transactionAmount = "-$5.86", + transactionAmountColor = themedColor { TangemTheme.colors.text.primary1 }, + labelState = LabelUM( + text = resourceReference(R.string.tangem_pay_status_pending), + style = LabelStyle.REGULAR, + icon = R.drawable.ic_clock_24, + ), + notification = null, + buttonState = TangemPayTxHistoryDetailsUM.ButtonState( + text = stringReference("Dispute"), + onClick = {}, + ), + dismiss = {}, + ), + TangemPayTxHistoryDetailsUM( + title = stringReference("12 June • 12:40"), + iconState = ImageReference.Res(R.drawable.ic_category_24), + transactionTitle = stringReference("Starbucks"), + transactionSubtitle = stringReference("Food and drinks"), + transactionAmount = "-$5.86", + transactionAmountColor = themedColor { TangemTheme.colors.text.warning }, + labelState = LabelUM( + text = resourceReference(R.string.tangem_pay_status_declined), + style = LabelStyle.WARNING, + ), + notification = NotificationConfig( + title = stringReference("The bank rejected this transaction request."), + subtitle = TextReference.EMPTY, + iconResId = R.drawable.ic_token_info_24, + ), + buttonState = TangemPayTxHistoryDetailsUM.ButtonState( + text = stringReference("Dispute"), + onClick = {}, + ), + dismiss = {}, + ), + TangemPayTxHistoryDetailsUM( + title = stringReference("12 June • 12:40"), + iconState = ImageReference.Res(R.drawable.ic_category_24), + transactionTitle = stringReference("Starbucks"), + transactionSubtitle = stringReference("Food and drinks"), + transactionAmount = "-$5.86", + transactionAmountColor = themedColor { TangemTheme.colors.text.primary1 }, + labelState = LabelUM( + text = resourceReference(R.string.tangem_pay_status_completed), + style = LabelStyle.ACCENT, + ), + notification = null, + buttonState = TangemPayTxHistoryDetailsUM.ButtonState( + text = stringReference("Dispute"), + onClick = {}, + ), + dismiss = {}, + ), + TangemPayTxHistoryDetailsUM( + title = stringReference("12 June • 12:40"), + iconState = ImageReference.Res(R.drawable.ic_percent_24), + transactionTitle = stringReference("Fee"), + transactionSubtitle = stringReference("Service fee"), + transactionAmount = "-$5.86", + transactionAmountColor = themedColor { TangemTheme.colors.text.primary1 }, + labelState = null, + notification = NotificationConfig( + title = stringReference("This fee goes to cover the cost of handling your transfer."), + subtitle = TextReference.EMPTY, + iconResId = R.drawable.ic_token_info_24, + ), + buttonState = TangemPayTxHistoryDetailsUM.ButtonState( + text = stringReference("Dispute"), + onClick = {}, + ), + dismiss = {}, + ), + TangemPayTxHistoryDetailsUM( + title = stringReference("12 June • 12:40"), + iconState = ImageReference.Res(R.drawable.ic_arrow_down_24), + transactionTitle = stringReference("Deposit"), + transactionSubtitle = stringReference("Transfers"), + transactionAmount = "+$20", + transactionAmountColor = themedColor { TangemTheme.colors.text.accent }, + labelState = null, + notification = null, + buttonState = TangemPayTxHistoryDetailsUM.ButtonState( + text = stringReference("Explore transaction"), + onClick = {}, + ), + dismiss = {}, + ), + TangemPayTxHistoryDetailsUM( + title = stringReference("12 June • 12:40"), + iconState = ImageReference.Res(R.drawable.ic_arrow_up_24), + transactionTitle = stringReference("Withdrawal"), + transactionSubtitle = stringReference("Transfers"), + transactionAmount = "-$5.86", + transactionAmountColor = themedColor { TangemTheme.colors.text.primary1 }, + labelState = null, + notification = null, + buttonState = TangemPayTxHistoryDetailsUM.ButtonState( + text = stringReference("Explore transaction"), + onClick = {}, + ), + dismiss = {}, + ), + ), +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayErrorMessageFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayErrorMessageFactory.kt index 2f5ce481ea..6aa000667c 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayErrorMessageFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayErrorMessageFactory.kt @@ -4,13 +4,15 @@ import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.message.* import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.BottomSheetMessageV2 +import com.tangem.core.ui.message.bottomSheetMessage import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType internal object TangemPayErrorMessageFactory { - fun createError(type: TangemPayDetailsErrorType, onDismiss: () -> Unit): MessageBottomSheetUMV2 { + fun createErrorMessage(type: TangemPayDetailsErrorType): BottomSheetMessageV2 { return when (type) { - TangemPayDetailsErrorType.Receive -> messageBottomSheetUM { + TangemPayDetailsErrorType.Receive -> bottomSheetMessage { infoBlock { icon(R.drawable.img_attention_20) { backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Attention @@ -20,7 +22,7 @@ internal object TangemPayErrorMessageFactory { } primaryButton { text = resourceReference(R.string.common_got_it) - onClick { onDismiss() } + onClick { closeBs() } } } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryUiManager.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryUiManager.kt index 0c4fb3698e..d4fc29fe56 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryUiManager.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryUiManager.kt @@ -41,15 +41,25 @@ internal class TangemPayTxHistoryUiManager( val currentUiBatches = state.value.uiBatches val batches = if (clearUiBatches) mutableListOf() else currentUiBatches.toMutableList() + var previousLastDate: String? = null + for ((key, data) in newCurrencyBatches) { // Find if batch with same key exists val existingBatchIndex = batches.indexOfFirst { it.key == key } val shouldUpdateExisting = existingBatchIndex != -1 && currentUiBatches[existingBatchIndex].data.transactionItemsSizeNotEqual(data) + // Get last date of previous batch's data + if (key > 0) { + val prevBatch = newCurrencyBatches.find { it.key == key - 1 } + previousLastDate = prevBatch?.data?.lastOrNull()?.date?.millis?.toDateFormatWithTodayYesterday() + } else { + previousLastDate = null + } + // Case 1: Update existing batch if sizes differ if (shouldUpdateExisting) { - val items = generateUiItems(key, data) + val items = generateUiItems(key, data, previousLastDate) batches[existingBatchIndex] = Batch(key = key, data = items) continue } @@ -60,7 +70,7 @@ internal class TangemPayTxHistoryUiManager( } // Case 3: Create new batch - val items = generateUiItems(key, data) + val items = generateUiItems(key, data, previousLastDate) batches.add(Batch(key = key, data = items)) } @@ -70,6 +80,7 @@ internal class TangemPayTxHistoryUiManager( private fun generateUiItems( key: Int, data: List, + previousLastDate: String? = null, ): List { val items = mutableListOf() @@ -84,12 +95,15 @@ internal class TangemPayTxHistoryUiManager( val firstItem = data.first() val firstDate = firstItem.date.millis.toDateFormatWithTodayYesterday() - items.add( - TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.GroupTitle( - title = firstDate, - itemKey = UUID.randomUUID().toString(), - ), - ) + // Only add group title if different from previous batch's last date + if (firstDate != previousLastDate) { + items.add( + TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.GroupTitle( + title = firstDate, + itemKey = UUID.randomUUID().toString(), + ), + ) + } items.add( TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction(txHistoryItemConverter.convert(firstItem)), ) 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 4aa2e26161..353c7016ce 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 @@ -41,7 +41,7 @@ internal class DefaultTokenDetailsRouter @Inject constructor( router.push( AppRoute.Staking( userWalletId = userWalletId, - cryptoCurrencyId = cryptoCurrency.id, + cryptoCurrency = cryptoCurrency, yieldId = yieldId, ), ) 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 46d7427763..3137a72bf3 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 @@ -34,7 +34,7 @@ import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase -import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -149,7 +149,7 @@ internal class TokenDetailsModel @Inject constructor( private val needShowYieldSupplyDepositedWarningUseCase: NeedShowYieldSupplyDepositedWarningUseCase, private val accountsFeatureToggles: AccountsFeatureToggles, private val getAccountCryptoCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, - private val saveCryptoCurrenciesUseCase: SaveCryptoCurrenciesUseCase, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, ) : Model(), TokenDetailsClickIntents, YieldSupplyDepositedWarningComponent.ModelCallback { private val params = paramsContainer.require() @@ -724,7 +724,7 @@ internal class TokenDetailsModel @Inject constructor( return@launch } - saveCryptoCurrenciesUseCase(accountId = accountId, remove = cryptoCurrency) + manageCryptoCurrenciesUseCase(accountId = accountId, remove = cryptoCurrency) } else { removeCurrencyUseCase(userWalletId, cryptoCurrency) } 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 0ba2ad9f35..1bfc82a05f 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 @@ -6,7 +6,7 @@ import com.tangem.datasource.local.swap.ExpressAnalyticsStatus import com.tangem.datasource.local.swap.SwapTransactionStatusStore import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase -import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency @@ -41,7 +41,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, private val accountsFeatureToggles: AccountsFeatureToggles, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, - private val saveCryptoCurrenciesUseCase: SaveCryptoCurrenciesUseCase, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val swapTransactionStatusStore: SwapTransactionStatusStore, private val analyticsEventsHandler: AnalyticsEventHandler, @Assisted private val clickIntents: TokenDetailsClickIntents, @@ -205,7 +205,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( if (refundNetwork == null || refundContractAddress == null) return null - return saveCryptoCurrenciesUseCase.add( + return manageCryptoCurrenciesUseCase.add( accountId = accountId, contractAddress = refundContractAddress, networkId = refundNetwork, 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 8e67d40a1a..419a988ef4 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 @@ -18,6 +18,7 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.settings.SettingsManager import com.tangem.core.ui.components.bottomsheets.message.* import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.SnackbarMessage @@ -121,6 +122,10 @@ internal class WalletSettingsModel @Inject constructor( } init { + getUserWalletUseCase.invoke(params.userWalletId).onRight { + analyticsContextProxy.addContext(it) + } + fun combineUI(wallet: UserWallet) = combine( getWalletNFTEnabledUseCase.invoke(params.userWalletId), getWalletNotificationsEnabledUseCase(params.userWalletId), @@ -155,6 +160,11 @@ internal class WalletSettingsModel @Inject constructor( .launchIn(modelScope) } + override fun onDestroy() { + super.onDestroy() + analyticsContextProxy.removeContext() + } + private fun isNotificationsPermissionGranted(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { permissionsRepository.hasRuntimePermission( @@ -196,24 +206,7 @@ internal class WalletSettingsModel @Inject constructor( isNFTEnabled = isNFTEnabled, onCheckedNFTChange = ::onCheckedNFTChange, forgetWallet = { - val message = DialogMessage( - message = resourceReference( - id = when (userWallet) { - is UserWallet.Cold -> R.string.user_wallet_list_delete_prompt - is UserWallet.Hot -> R.string.user_wallet_list_delete_hw_prompt - }, - ), - firstActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.common_forget), - isWarning = true, - onClick = ::forgetWallet, - ) - }, - secondActionBuilder = { cancelAction() }, - ) - - messageSender.send(message) + onForgetWalletClick(userWallet) }, onLinkMoreCardsClick = { when (userWallet) { @@ -394,4 +387,80 @@ internal class WalletSettingsModel @Inject constructor( } } } + + // TODO actualize strings [REDACTED_TASK_KEY] and remove MaximumLineLength + @Suppress("LongMethod", "MaximumLineLength") + private fun onForgetWalletClick(userWallet: UserWallet) { + val message = when (userWallet) { + is UserWallet.Cold -> { + DialogMessage( + message = resourceReference( + id = R.string.user_wallet_list_delete_prompt, + ), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_forget), + isWarning = true, + onClick = ::forgetWallet, + ) + }, + secondActionBuilder = { cancelAction() }, + ) + } + is UserWallet.Hot -> { + bottomSheetMessage { + infoBlock { + icon(R.drawable.ic_alert_circle_24) { + type = MessageBottomSheetUMV2.Icon.Type.Warning + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + } + title = stringReference("Forget this wallet?") + body = if (userWallet.backedUp) { + stringReference("A backup for this wallet exists. Review it before forgetting to make sure you can recover later.") + } else { + stringReference("If you forget this wallet without a backup, you’ll permanently lose access to your funds.") + } + } + if (userWallet.backedUp) { + secondaryButton { + text = stringReference("Forget wallet") + onClick { + router.push(AppRoute.ForgetWallet(userWallet.walletId)) + closeBs() + } + } + } else { + secondaryButton { + text = stringReference("Forget Anyway") + onClick { + router.push(AppRoute.ForgetWallet(userWallet.walletId)) + closeBs() + } + } + } + if (userWallet.backedUp) { + primaryButton { + text = stringReference("View backup") + onClick { + unlockWalletIfNeedAndProceed { + router.push(AppRoute.ViewPhrase(userWallet.walletId)) + } + closeBs() + } + } + } else { + primaryButton { + text = stringReference("Go to backup") + onClick { + router.push(AppRoute.CreateWalletBackup(userWallet.walletId)) + closeBs() + } + } + } + } + } + } + + messageSender.send(message) + } } \ No newline at end of file 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 3faf4f6d3a..8099f6dd13 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 @@ -365,7 +365,12 @@ internal class WalletModel @Inject constructor( value = info, onClickIssue = ::issueOrder, onClickKyc = innerWalletRouter::openTangemPayOnboarding, - openDetails = innerWalletRouter::openTangemPayDetails, + openDetails = { config -> + innerWalletRouter.openTangemPayDetails( + userWalletId = stateHolder.getSelectedWalletId(), + config = config, + ) + }, ), ) } 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 e0eeaf7bd6..bebc4bd474 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 @@ -24,7 +24,7 @@ import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase -import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.core.lce.Lce @@ -157,7 +157,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private val removeCurrencyUseCase: RemoveCurrencyUseCase, private val accountsFeatureToggles: AccountsFeatureToggles, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, - private val saveCryptoCurrenciesUseCase: SaveCryptoCurrenciesUseCase, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, ) : BaseWalletClickIntents(), WalletCurrencyActionsClickIntents { override fun onSendClick( @@ -360,7 +360,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( return@launch } - saveCryptoCurrenciesUseCase(accountId = accountId, remove = cryptoCurrencyStatus.currency) + manageCryptoCurrenciesUseCase(accountId = accountId, remove = cryptoCurrencyStatus.currency) } else { removeCurrencyUseCase(userWalletId, cryptoCurrencyStatus.currency) } @@ -507,7 +507,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( appRouter.push( AppRoute.Staking( userWalletId = userWalletId, - cryptoCurrencyId = cryptoCurrency.id, + cryptoCurrency = cryptoCurrency, yieldId = yield?.id ?: return@launch, ), ) 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 8213172d17..4fed890be6 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 @@ -109,8 +109,8 @@ internal class DefaultWalletRouter @Inject constructor( router.push(AppRoute.TangemPayOnboarding(AppRoute.TangemPayOnboarding.Mode.ContinueOnboarding)) } - override fun openTangemPayDetails(config: TangemPayDetailsConfig) { - router.push(AppRoute.TangemPayDetails(config)) + override fun openTangemPayDetails(userWalletId: UserWalletId, config: TangemPayDetailsConfig) { + router.push(AppRoute.TangemPayDetails(userWalletId = userWalletId, config = config)) } override fun openYieldSupplyBottomSheet( 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 d869ca8b84..120a13a248 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 @@ -60,7 +60,7 @@ internal interface InnerWalletRouter { fun openTangemPayOnboarding() - fun openTangemPayDetails(config: TangemPayDetailsConfig) + fun openTangemPayDetails(userWalletId: UserWalletId, config: TangemPayDetailsConfig) /** Open BS abput yield supply active and all money deposited in AAVE */ fun openYieldSupplyBottomSheet( diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 1cd201c597..f7e514619e 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.30-1285" +tangemBlockchainSdk = "develop-1287" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.30-567" +tangemCardSdk = "develop-564" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/ProviderTypeIdMapping.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/ProviderTypeIdMapping.kt index 35b1248da3..cef42efc7c 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/ProviderTypeIdMapping.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/ProviderTypeIdMapping.kt @@ -11,7 +11,7 @@ internal enum class ProviderTypeIdMapping(val id: String, val providerType: Prov BitcoinBlockcypher(id = "blockcypher", providerType = ProviderType.BitcoinLike.Blockcypher), CardanoAdalite(id = "adalite", providerType = ProviderType.Cardano.Adalite), CardanoRosetta(id = "tangemRosetta", providerType = ProviderType.Cardano.Rosetta), - CardanoMock(id = "mock", providerType = ProviderType.Mock), + WireMock(id = "mock", providerType = ProviderType.Mock), ChiaFireAcademy(id = "fireAcademy", providerType = ProviderType.Chia.FireAcademy), ChiaTangem(id = "tangemChia", providerType = ProviderType.Chia.Tangem), ChiaTangemNew(id = "tangemChia3", providerType = ProviderType.Chia.TangemNew), diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt index f977409a5a..9bac246d33 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt @@ -169,9 +169,9 @@ fun Blockchain.Companion.fromNetworkId(networkId: String): Blockchain? { "hyperevm/test" -> Blockchain.HyperliquidTestnet "quai-network" -> Blockchain.Quai "quai-network/test" -> Blockchain.QuaiTestnet - // "linea" -> Blockchain.Linea - // "linea/test" -> Blockchain.LineaTestnet - // "arbitrum-nova" -> Blockchain.ArbitrumNova + "linea" -> Blockchain.Linea + "linea/test" -> Blockchain.LineaTestnet + "arbitrum-nova" -> Blockchain.ArbitrumNova else -> null } } @@ -338,9 +338,9 @@ fun Blockchain.toNetworkId(): String { Blockchain.HyperliquidTestnet -> "hyperevm/test" Blockchain.Quai -> "quai-network" Blockchain.QuaiTestnet -> "quai-network/test" - // Blockchain.Linea -> "linea" - // Blockchain.LineaTestnet -> "linea/test" - // Blockchain.ArbitrumNova -> "arbitrum-nova" + Blockchain.Linea -> "linea" + Blockchain.LineaTestnet -> "linea/test" + Blockchain.ArbitrumNova -> "arbitrum-nova" } } @@ -446,8 +446,8 @@ fun Blockchain.toCoinId(): String { Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> "pepecoin-network" Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> "hyperliquid" Blockchain.Quai, Blockchain.QuaiTestnet -> "quai-network" - // Blockchain.Linea, Blockchain.LineaTestnet -> "linea-ethereum" - // Blockchain.ArbitrumNova -> "arbitrum-nova-ethereum" + Blockchain.Linea, Blockchain.LineaTestnet -> "linea-ethereum" + Blockchain.ArbitrumNova -> "arbitrum-nova-ethereum" } } diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt index 7baee26b63..37c9b75e4d 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt @@ -168,8 +168,8 @@ class AccountNodeRecognizer(private val blockchain: Blockchain) { Blockchain.Pepecoin, Blockchain.Hyperliquid, Blockchain.Scroll, - // Blockchain.Linea, - // Blockchain.ArbitrumNova, + Blockchain.Linea, + Blockchain.ArbitrumNova, Blockchain.Quai, -> true Blockchain.Nexa, // unsupported network @@ -244,7 +244,7 @@ class AccountNodeRecognizer(private val blockchain: Blockchain) { Blockchain.PepecoinTestnet, Blockchain.HyperliquidTestnet, Blockchain.QuaiTestnet, - // Blockchain.LineaTestnet, + Blockchain.LineaTestnet, -> false // endregion }