diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 097bcd4c84..042bf40240 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -113,12 +113,14 @@ dependencies { implementation(projects.domain.legacy) implementation(projects.libs.blockchainSdk) implementation(projects.domain.account) + implementation(projects.domain.account.status) implementation(projects.domain.models) implementation(projects.domain.core) api(projects.domain.common) 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/common/extensions/Specific.kt b/app/src/main/java/com/tangem/tap/common/extensions/Specific.kt deleted file mode 100644 index 0dfce4b8c7..0000000000 --- a/app/src/main/java/com/tangem/tap/common/extensions/Specific.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.tap.common.extensions - -import java.math.BigDecimal -import java.math.RoundingMode -import java.text.DecimalFormat -import java.text.DecimalFormatSymbols -import java.util.Locale - -// TODO: move extensions to utils -fun BigDecimal.toFormattedString( - decimals: Int, - roundingMode: RoundingMode = RoundingMode.DOWN, - locale: Locale = Locale.US, -): String { - val symbols = DecimalFormatSymbols(locale) - val df = DecimalFormat() - df.decimalFormatSymbols = symbols - df.maximumFractionDigits = decimals - df.minimumFractionDigits = 0 - df.isGroupingUsed = true - df.roundingMode = roundingMode - return df.format(this) -} \ 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..56de193f2c 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,9 @@ 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,17 +45,15 @@ 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/AccountDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt index 7c946ee340..fda29040bc 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt @@ -3,6 +3,8 @@ package com.tangem.tap.di.domain import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.fetcher.SingleAccountListFetcher import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.usecase.RecoverCryptoPortfolioUseCase +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.account.tokens.MainAccountTokensMigration import com.tangem.domain.account.usecase.* import dagger.Module @@ -50,10 +52,12 @@ internal object AccountDomainModule { fun provideRecoverCryptoPortfolioUseCase( accountsCRUDRepository: AccountsCRUDRepository, mainAccountTokensMigration: MainAccountTokensMigration, + cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, ): RecoverCryptoPortfolioUseCase { return RecoverCryptoPortfolioUseCase( crudRepository = accountsCRUDRepository, mainAccountTokensMigration = mainAccountTokensMigration, + cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, ) } 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/domain/tasks/product/ResetBackupCardTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/product/ResetBackupCardTask.kt index dd90db55fa..bb0fafa039 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/product/ResetBackupCardTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/product/ResetBackupCardTask.kt @@ -17,7 +17,6 @@ import com.tangem.tap.domain.tasks.UserWalletIdPreflightReadFilter * [REDACTED_AUTHOR] */ -// TODO remove it after test after resolve [REDACTED_JIRA] internal class ResetBackupCardTask( private val userWalletId: UserWalletId, ) : CardSessionRunnable { diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt index 97cb5f65e6..36f5763779 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt @@ -2,44 +2,14 @@ package com.tangem.tap.network.exchangeServices import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token -import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.blockchainsdk.utils.toBlockchain -import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.tap.common.extensions.inject import com.tangem.tap.domain.model.Currency -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store -import com.tangem.utils.converter.TwoWayConverter +import com.tangem.utils.converter.Converter -internal class CryptoCurrencyConverter( - private val excludedBlockchains: ExcludedBlockchains, -) : TwoWayConverter { +internal object CryptoCurrencyConverter : Converter { - private val cryptoCurrencyFactory by lazy { CryptoCurrencyFactory(excludedBlockchains) } - - override fun convert(value: Currency): CryptoCurrency { - return when (value) { - is Currency.Blockchain -> requireNotNull( - cryptoCurrencyFactory.createCoin( - blockchain = value.blockchain, - extraDerivationPath = value.derivationPath, - userWallet = getSelectedWallet(), - ), - ) - is Currency.Token -> requireNotNull( - cryptoCurrencyFactory.createToken( - sdkToken = value.token, - blockchain = value.blockchain, - extraDerivationPath = value.derivationPath, - userWallet = getSelectedWallet(), - ), - ) - } - } - - override fun convertBack(value: CryptoCurrency): Currency { + override fun convert(value: CryptoCurrency): Currency { val blockchain = value.network.toBlockchain() if (blockchain == Blockchain.Unknown) error("CryptoCurrencyConverter convertBack Unknown blockchain") return when (value) { @@ -60,15 +30,4 @@ internal class CryptoCurrencyConverter( ) } } - - fun getSelectedWallet(): UserWallet { - val userWalletListManager = store.inject(DaggerGraphState::generalUserWalletsListManager) - val userWalletsListRepository = store.inject(DaggerGraphState::userWalletsListRepository) - val hotWalletFeatureToggles = store.inject(DaggerGraphState::hotWalletFeatureToggles) - return if (hotWalletFeatureToggles.isHotWalletEnabled) { - requireNotNull(userWalletsListRepository.selectedUserWallet.value) - } else { - requireNotNull(userWalletListManager.selectedUserWalletSync) - } - } } \ No newline at end of file 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..43606fbf69 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 @@ -5,13 +5,11 @@ import arrow.core.raise.catch 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,17 +24,13 @@ 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, ) : RampStateManager { - private val cryptoCurrencyConverter = CryptoCurrencyConverter(excludedBlockchains) - override suspend fun availableForBuy( userWallet: UserWallet, cryptoCurrency: CryptoCurrency, @@ -56,7 +50,7 @@ internal class DefaultRampManager( return either { val isSellSupportedByService = catch( block = { - val serviceCurrency = cryptoCurrencyConverter.convertBack(status.currency) + val serviceCurrency = CryptoCurrencyConverter.convert(status.currency) sellService().availableForSell(currency = serviceCurrency) }, @@ -107,7 +101,7 @@ internal class DefaultRampManager( } override fun getExpressInitializationStatus(userWalletId: UserWalletId): Flow { - return expressServiceLoader.getInitializationStatus(userWalletId) + return expressServiceFetcher.getInitializationStatus(userWalletId) } override suspend fun getSendUnavailabilityReason( @@ -151,14 +145,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 +162,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 +205,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..e7c231fd1f 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 @@ -62,6 +63,7 @@ internal class ChildFactory @Inject constructor( private val detailsComponentFactory: DetailsComponent.Factory, private val walletSettingsComponentFactory: WalletSettingsComponent.Factory, private val walletBackupComponentFactory: WalletBackupComponent.Factory, + private val walletHardwareBackupComponentFactory: WalletHardwareBackupComponent.Factory, private val disclaimerComponentFactory: DisclaimerComponent.Factory, private val manageTokensComponentFactory: ManageTokensComponent.Factory, private val marketsTokenDetailsComponentFactory: MarketsTokenDetailsComponent.Factory, @@ -100,6 +102,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 +110,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 +142,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 } @@ -187,6 +190,15 @@ internal class ChildFactory @Inject constructor( componentFactory = walletBackupComponentFactory, ) } + is AppRoute.WalletHardwareBackup -> { + createComponentChild( + context = context, + params = WalletHardwareBackupComponent.Params( + userWalletId = route.userWalletId, + ), + componentFactory = walletHardwareBackupComponentFactory, + ) + } is AppRoute.MarketsTokenDetails -> { createComponentChild( context = context, @@ -293,7 +305,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 +508,13 @@ internal class ChildFactory @Inject constructor( componentFactory = createWalletSelectionComponentFactory, ) } + is AppRoute.CreateHardwareWallet -> { + createComponentChild( + context = context, + params = Unit, + componentFactory = createHardwareWalletComponentFactory, + ) + } is AppRoute.CreateMobileWallet -> { createComponentChild( context = context, @@ -533,6 +552,7 @@ internal class ChildFactory @Inject constructor( context = context, params = CreateWalletBackupComponent.Params( userWalletId = route.userWalletId, + isUpgradeFlow = route.isUpgradeFlow, ), componentFactory = createWalletBackupComponentFactory, ) @@ -555,6 +575,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 +633,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..aab2ba7f89 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( @@ -217,6 +220,11 @@ sealed class AppRoute(val path: String) : Route { val userWalletId: UserWalletId, ) : AppRoute(path = "/wallet_backup/${userWalletId.stringValue}") + @Serializable + data class WalletHardwareBackup( + val userWalletId: UserWalletId, + ) : AppRoute(path = "/wallet_hardware_backup/${userWalletId.stringValue}") + @Serializable data object Markets : AppRoute(path = "/markets") @@ -322,6 +330,9 @@ sealed class AppRoute(val path: String) : Route { } } + @Serializable + object CreateHardwareWallet : AppRoute(path = "/create_hardware_wallet") + @Serializable object CreateMobileWallet : AppRoute(path = "/create_mobile_wallet") @@ -341,6 +352,7 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class CreateWalletBackup( val userWalletId: UserWalletId, + val isUpgradeFlow: Boolean, ) : AppRoute(path = "/create_wallet_backup/${userWalletId.stringValue}") @Serializable @@ -353,6 +365,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 +400,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..099ff971f8 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 @@ -1,21 +1,30 @@ package com.tangem.common.ui.account import com.tangem.common.ui.R +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter 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 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.core.lce.Lce import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.Account +import com.tangem.domain.models.quote.PriceChange import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isZero +import java.math.BigDecimal class AccountCryptoPortfolioItemStateConverter( private val appCurrency: AppCurrency, private val account: Account.CryptoPortfolio, + private val priceChangeLce: Lce? = null, private val onItemClick: ((Account.CryptoPortfolio) -> Unit)? = null, private val onItemLongClick: ((Account.CryptoPortfolio) -> Unit)? = null, ) : Converter { @@ -31,6 +40,14 @@ class AccountCryptoPortfolioItemStateConverter( private fun Account.CryptoPortfolio.mapToContentState( fiatBalance: TotalFiatBalance.Loaded, ): TokenItemState.Content { + val subtitle2State = when (fiatBalance.amount.isZero()) { + true -> null + false -> priceChangeLce?.fold( + ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading }, + ifError = { null }, + ifContent = { priceChange -> priceChange.toSubtitle2State() }, + ) + } return TokenItemState.Content( id = account.accountId.value, iconState = AccountIconItemStateConverter.convert(this), @@ -50,14 +67,14 @@ class AccountCryptoPortfolioItemStateConverter( .format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, isFlickering = fiatBalance.source == StatusSource.CACHE, ), - subtitle2State = null, + subtitle2State = subtitle2State, onItemClick = onItemClick?.let { onItemClick -> { onItemClick(account) } }, onItemLongClick = onItemLongClick?.let { onItemLongClick -> { onItemLongClick(account) } }, ) } - 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 +88,10 @@ class AccountCryptoPortfolioItemStateConverter( ), isAvailable = false, ), + fiatAmountState = FiatAmountState.Loading, + subtitle2State = Subtitle2State.Loading, + onItemLongClick = null, + onItemClick = onItemClick?.let { onItemClick -> { onItemClick(account) } }, ) } @@ -97,4 +118,14 @@ class AccountCryptoPortfolioItemStateConverter( }, ) } + + private fun BigDecimal.getPriceChangeType(): PriceChangeType = PriceChangeConverter.fromBigDecimal(value = this) + + private fun StatusSource.isFlickering(): Boolean = this == StatusSource.CACHE + + private fun PriceChange.toSubtitle2State(): Subtitle2State = Subtitle2State.PriceChangeContent( + priceChangePercent = this.value.format { percent() }, + type = this.value.getPriceChangeType(), + isFlickering = this.source.isFlickering(), + ) } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountNameUM.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountNameUM.kt index e86dda636b..39ba4af467 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountNameUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountNameUM.kt @@ -33,7 +33,7 @@ sealed interface AccountNameUM { * * @property raw the raw string value of the custom account name */ - class Custom(internal val raw: String) : AccountNameUM { + data class Custom(internal val raw: String) : AccountNameUM { override val value: TextReference = stringReference(value = raw) } 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/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt b/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt new file mode 100644 index 0000000000..91b1895006 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt @@ -0,0 +1,119 @@ +package com.tangem.common.ui.account + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.R +import com.tangem.core.ui.components.SpacerW12 +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.models.account.AccountName + +@Composable +fun PortfolioSelectRow(state: PortfolioSelectUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .clickable(enabled = state.isMultiChoice, onClick = state.onClick) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val leftText = if (state.isAccountMode) R.string.account_details_title else R.string.wc_common_wallet + Text( + modifier = Modifier.weight(1f), + text = stringResourceSafe(leftText), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + SpacerW12() + if (state.icon != null) { + AccountIcon( + name = state.name, + icon = state.icon, + size = AccountIconSize.Small, + ) + } + Text( + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 4.dp), + text = state.name.resolveReference(), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + if (state.isMultiChoice) { + Icon( + modifier = Modifier + .size(width = 18.dp, height = 24.dp), + painter = painterResource(id = R.drawable.ic_select_18_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + } + } +} + +@Immutable +data class PortfolioSelectUM( + val icon: CryptoPortfolioIconUM?, + val name: TextReference, + val isAccountMode: Boolean, + val isMultiChoice: Boolean, + val onClick: () -> Unit, +) + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PortfolioSelectRowPreview(@PreviewParameter(PreviewProvider::class) state: PortfolioSelectUM) { + TangemThemePreview { + PortfolioSelectRow( + state = state, + modifier = Modifier.background(TangemTheme.colors.background.tertiary), + ) + } +} + +private class PreviewProvider : PreviewParameterProvider { + + val account + get() = PortfolioSelectUM( + icon = AccountIconPreviewData.randomAccountIcon(), + name = AccountName.DefaultMain.toUM().value, + isAccountMode = true, + isMultiChoice = true, + onClick = {}, + ) + val wallet + get() = PortfolioSelectUM( + icon = null, + name = stringReference("Wallet Name"), + isMultiChoice = false, + isAccountMode = false, + onClick = {}, + ) + + override val values: Sequence + get() = sequenceOf(account, wallet) +} \ No newline at end of file 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/buttons/SmallButton.kt b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/SmallButton.kt index aeedd6ab92..10ecc64922 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/SmallButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/SmallButton.kt @@ -6,18 +6,21 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.conditional import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -34,6 +37,7 @@ data class SmallButtonConfig( val onClick: () -> Unit, val icon: TangemButtonIconPosition = TangemButtonIconPosition.None, val isEnabled: Boolean = true, + val isLoading: Boolean = false, ) /** @@ -68,7 +72,7 @@ private fun SmallButton(config: SmallButtonConfig, isPrimary: Boolean, modifier: label = "Update background color", ) - Row( + Box( modifier = modifier .defaultMinSize( minWidth = TangemTheme.dimens.size46, @@ -79,7 +83,7 @@ private fun SmallButton(config: SmallButtonConfig, isPrimary: Boolean, modifier: color = backgroundColor, shape = shape, ) - .clickable(enabled = config.isEnabled, onClick = config.onClick) + .clickable(enabled = !config.isLoading && config.isEnabled, onClick = config.onClick) .padding( paddingValues = when (config.icon) { is TangemButtonIconPosition.None -> PaddingValues( @@ -95,42 +99,54 @@ private fun SmallButton(config: SmallButtonConfig, isPrimary: Boolean, modifier: ) }, ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, + contentAlignment = Alignment.Center, ) { - ContentContainer( - iconPosition = config.icon, - text = { - val textColor by animateColorAsState( - targetValue = when { - !config.isEnabled -> TangemTheme.colors.text.disabled - isPrimary -> TangemTheme.colors.text.primary2 - else -> TangemTheme.colors.text.primary1 - }, - label = "Update text color", - ) + if (config.isLoading) { + CircularProgressIndicator( + strokeWidth = TangemTheme.dimens.size2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.size(TangemTheme.dimens.size16), + ) + } + Row( + modifier = Modifier.conditional(config.isLoading) { alpha(0f) }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + ContentContainer( + iconPosition = config.icon, + text = { + val textColor by animateColorAsState( + targetValue = when { + !config.isEnabled -> TangemTheme.colors.text.disabled + isPrimary -> TangemTheme.colors.text.primary2 + else -> TangemTheme.colors.text.primary1 + }, + label = "Update text color", + ) - Text( - modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing4), - text = config.text.resolveReference(), - color = textColor, - maxLines = 1, - style = TangemTheme.typography.button, - ) - }, - icon = { iconResId -> - Icon( - modifier = Modifier.size(TangemTheme.dimens.size16), - painter = painterResource(id = iconResId), - tint = if (config.isEnabled) { - TangemTheme.colors.icon.secondary - } else { - TangemTheme.colors.icon.inactive - }, - contentDescription = null, - ) - }, - ) + Text( + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing4), + text = config.text.resolveReference(), + color = textColor, + maxLines = 1, + style = TangemTheme.typography.button, + ) + }, + icon = { iconResId -> + Icon( + modifier = Modifier.size(TangemTheme.dimens.size16), + painter = painterResource(id = iconResId), + tint = if (config.isEnabled) { + TangemTheme.colors.icon.secondary + } else { + TangemTheme.colors.icon.inactive + }, + contentDescription = null, + ) + }, + ) + } } } @@ -172,6 +188,7 @@ private fun ButtonsSample() { ) PrimarySmallButton(config = config) SecondarySmallButton(config = config.copy(text = TextReference.Str(value = "Add"))) + SecondarySmallButton(config = config.copy(text = TextReference.Str(value = "Add"), isLoading = true)) SecondarySmallButton( config = config.copy( text = TextReference.Str(value = "Rating"), @@ -191,5 +208,12 @@ private fun ButtonsSample() { isEnabled = false, ), ) + SecondarySmallButton( + config = config.copy( + text = TextReference.Str(value = "Add token"), + icon = TangemButtonIconPosition.Start(iconResId = R.drawable.ic_plus_24), + isLoading = true, + ), + ) } } \ 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/components/token/TokenItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt index 68212fff41..374b6de2e3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt @@ -27,6 +27,7 @@ import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.token.internal.* 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.TextReference import com.tangem.core.ui.extensions.rememberHapticFeedback import com.tangem.core.ui.extensions.stringReference @@ -691,7 +692,10 @@ object AccountItemPreviewData { value = stringReference("24 tokens"), isAvailable = false, ), - subtitle2State = null, + subtitle2State = Subtitle2State.PriceChangeContent( + priceChangePercent = "0,43 %", + type = PriceChangeType.UP, + ), onItemClick = {}, onItemLongClick = {}, ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenCryptoAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenCryptoAmount.kt index 58524bee7e..03beb520e2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenCryptoAmount.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenCryptoAmount.kt @@ -11,7 +11,6 @@ import com.tangem.core.ui.R import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.audits.AuditLabel import com.tangem.core.ui.components.text.applyBladeBrush -import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -31,7 +30,7 @@ internal fun TokenCryptoAmount( isFlickering = state.isFlickering, ) } - is TokenItemState.Subtitle2State.LabelContent -> { + is TokenCryptoAmountState.LabelContent -> { AuditLabel(state = state.auditLabelUM, modifier = modifier) } is TokenCryptoAmountState.Unreachable -> { @@ -46,6 +45,15 @@ internal fun TokenCryptoAmount( is TokenCryptoAmountState.Locked -> { LockedRectangle(modifier = modifier.placeholderSize()) } + is TokenCryptoAmountState.PriceChangeContent -> { + PriceBlock( + modifier = modifier, + price = null, + type = state.type, + priceChangePercent = state.priceChangePercent, + isFlickering = state.isFlickering, + ) + } null -> Unit } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenPrice.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenPrice.kt index b2ee3f5568..6b5d2315d8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenPrice.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenPrice.kt @@ -64,8 +64,8 @@ internal fun TokenPrice(state: TokenPriceState?, modifier: Modifier = Modifier) } @Composable -private fun PriceBlock( - price: String, +internal fun PriceBlock( + price: String?, isFlickering: Boolean, modifier: Modifier = Modifier, type: PriceChangeType? = null, @@ -75,13 +75,15 @@ private fun PriceBlock( modifier = modifier, verticalAlignment = Alignment.CenterVertically, ) { - PriceText( - modifier = Modifier.weight(weight = 1f, fill = false), - text = price, - isFlickering = isFlickering, - ) + if (price != null) { + PriceText( + modifier = Modifier.weight(weight = 1f, fill = false), + text = price, + isFlickering = isFlickering, + ) - SpacerW6() + SpacerW6() + } if (type != null) { PriceChangeIcon( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt index 88fc97f796..1e23a9359e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt @@ -230,6 +230,12 @@ sealed class TokenItemState { val isFlickering: Boolean = false, ) : Subtitle2State() + data class PriceChangeContent( + val priceChangePercent: String, + val type: PriceChangeType, + val isFlickering: Boolean = false, + ) : Subtitle2State() + data class LabelContent(val auditLabelUM: AuditLabelUM) : Subtitle2State() data object Unreachable : Subtitle2State() 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/res/TangemColorPalette.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt index 6c0bd9671f..b3bfdd9a48 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColorPalette.kt @@ -20,6 +20,7 @@ object TangemColorPalette { // region Light val Light1 = Color(0xFFF5F5F5) + val Light1V2 = Color(0xFFF4F4F4) val Light2 = Color(0xFFEBEBEB) val Light3 = Color(0xFFD3D3D3) val Light4 = Color(0xFFC9C9C9) @@ -27,6 +28,7 @@ object TangemColorPalette { // endregion Light // region Green + val Green = Color(0xFF0C9F3D) val Meadow = Color(0xFF1ACE80) val MagicMint = Color(0xFFA3EBCC) val DarkGreen = Color(0xFF06311F) @@ -45,4 +47,9 @@ object TangemColorPalette { val Tangerine = Color(0xFFFFB71B) val Mustard = Color(0xFFFDDE55) // endregion Yellow + + // region Overlay + val Overlay1 = Color(0x66000000) + val Overlay2 = Color(0xB2000000) + // endregion Overlay } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt new file mode 100644 index 0000000000..68ce8e0a85 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemColors2.kt @@ -0,0 +1,536 @@ +@file:Suppress("LongParameterList") +package com.tangem.core.ui.res + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color + +@Stable +class TangemColors2 internal constructor( + val text: Text, + val graphic: Graphic, + val button: Button, + val surface: Surface, + val controls: Controls, + val field: Field, + val overlay: Overlay, + val border: Border, + val fill: Fill, + val skeleton: Skeleton, + val markers: Markers, +) { + + @Stable + class Text internal constructor( + val neutral: Neutral, + val status: Status, + ) { + @Stable + class Neutral internal constructor( + primary: Color, + primaryInverted: Color, + secondary: Color, + tertiary: Color, + primaryInvertedConstant: Color, + ) { + var primary by mutableStateOf(primary) + private set + var primaryInverted by mutableStateOf(primaryInverted) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + var primaryInvertedConstant by mutableStateOf(primaryInvertedConstant) + private set + + fun update(other: Neutral) { + primary = other.primary + primaryInverted = other.primaryInverted + secondary = other.secondary + tertiary = other.tertiary + primaryInvertedConstant = other.primaryInvertedConstant + } + } + + @Stable + class Status internal constructor( + disabled: Color, + accent: Color, + warning: Color, + attention: Color, + positive: Color, + ) { + var disabled by mutableStateOf(disabled) + private set + var accent by mutableStateOf(accent) + private set + var warning by mutableStateOf(warning) + private set + var attention by mutableStateOf(attention) + private set + var positive by mutableStateOf(positive) + private set + + fun update(other: Status) { + disabled = other.disabled + accent = other.accent + warning = other.warning + attention = other.attention + positive = other.positive + } + } + + fun update(other: Text) { + neutral.update(other.neutral) + status.update(other.status) + } + } + + @Stable + class Graphic internal constructor( + val neutral: Neutral, + val status: Status, + ) { + @Stable + class Neutral internal constructor( + primary: Color, + primaryInverted: Color, + secondary: Color, + tertiary: Color, + quaternary: Color, + primaryInvertedConstant: Color, + tertiaryConstant: Color, + ) { + var primary by mutableStateOf(primary) + private set + var primaryInverted by mutableStateOf(primaryInverted) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + var quaternary by mutableStateOf(quaternary) + private set + var primaryInvertedConstant by mutableStateOf(primaryInvertedConstant) + private set + var tertiaryConstant by mutableStateOf(tertiaryConstant) + private set + + fun update(other: Neutral) { + primary = other.primary + primaryInverted = other.primaryInverted + secondary = other.secondary + tertiary = other.tertiary + quaternary = other.quaternary + primaryInvertedConstant = other.primaryInvertedConstant + tertiaryConstant = other.tertiaryConstant + } + } + + @Stable + class Status internal constructor( + accent: Color, + warning: Color, + attention: Color, + ) { + var accent by mutableStateOf(accent) + private set + var warning by mutableStateOf(warning) + private set + var attention by mutableStateOf(attention) + private set + + fun update(other: Status) { + accent = other.accent + warning = other.warning + attention = other.attention + } + } + + fun update(other: Graphic) { + neutral.update(other.neutral) + status.update(other.status) + } + } + + @Stable + class Button internal constructor( + backgroundPrimary: Color, + backgroundSecondary: Color, + backgroundDisabled: Color, + backgroundPositive: Color, + textPrimary: Color, + textSecondary: Color, + textDisabled: Color, + iconPrimary: Color, + iconSecondary: Color, + iconDisabled: Color, + borderPrimary: Color, + ) { + var backgroundPrimary by mutableStateOf(backgroundPrimary) + private set + var backgroundSecondary by mutableStateOf(backgroundSecondary) + private set + var backgroundDisabled by mutableStateOf(backgroundDisabled) + private set + var backgroundPositive by mutableStateOf(backgroundPositive) + private set + var textPrimary by mutableStateOf(textPrimary) + private set + var textSecondary by mutableStateOf(textSecondary) + private set + var textDisabled by mutableStateOf(textDisabled) + private set + var iconPrimary by mutableStateOf(iconPrimary) + private set + var iconSecondary by mutableStateOf(iconSecondary) + private set + var iconDisabled by mutableStateOf(iconDisabled) + private set + var borderPrimary by mutableStateOf(borderPrimary) + private set + + fun update(other: Button) { + backgroundPrimary = other.backgroundPrimary + backgroundSecondary = other.backgroundSecondary + backgroundDisabled = other.backgroundDisabled + backgroundPositive = other.backgroundPositive + textPrimary = other.textPrimary + textSecondary = other.textSecondary + textDisabled = other.textDisabled + iconPrimary = other.iconPrimary + iconSecondary = other.iconSecondary + iconDisabled = other.iconDisabled + borderPrimary = other.borderPrimary + } + } + + @Stable + class Surface internal constructor( + level1: Color, + level2: Color, + level3: Color, + level4: Color, + ) { + var level1 by mutableStateOf(level1) + private set + var level2 by mutableStateOf(level2) + private set + var level3 by mutableStateOf(level3) + private set + var level4 by mutableStateOf(level4) + private set + + fun update(other: Surface) { + level1 = other.level1 + level2 = other.level2 + level3 = other.level3 + level4 = other.level4 + } + } + + @Stable + class Controls internal constructor( + backgroundDefault: Color, + backgroundChecked: Color, + iconDefault: Color, + iconDisabled: Color, + ) { + var backgroundDefault by mutableStateOf(backgroundDefault) + private set + var backgroundChecked by mutableStateOf(backgroundChecked) + private set + var iconDefault by mutableStateOf(iconDefault) + private set + var iconDisabled by mutableStateOf(iconDisabled) + private set + + fun update(other: Controls) { + backgroundDefault = other.backgroundDefault + backgroundChecked = other.backgroundChecked + iconDefault = other.iconDefault + iconDisabled = other.iconDisabled + } + } + + @Stable + class Field internal constructor( + backgroundDefault: Color, + backgroundFocused: Color, + textPlaceholder: Color, + textDefault: Color, + textDisabled: Color, + iconDefault: Color, + iconDisabled: Color, + textInvalid: Color, + borderInvalid: Color, + ) { + var backgroundDefault by mutableStateOf(backgroundDefault) + private set + var backgroundFocused by mutableStateOf(backgroundFocused) + private set + var textPlaceholder by mutableStateOf(textPlaceholder) + private set + var textDefault by mutableStateOf(textDefault) + private set + var textDisabled by mutableStateOf(textDisabled) + private set + var iconDefault by mutableStateOf(iconDefault) + private set + var iconDisabled by mutableStateOf(iconDisabled) + private set + var textInvalid by mutableStateOf(textInvalid) + private set + var borderInvalid by mutableStateOf(borderInvalid) + private set + + fun update(other: Field) { + backgroundDefault = other.backgroundDefault + backgroundFocused = other.backgroundFocused + textPlaceholder = other.textPlaceholder + textDefault = other.textDefault + textDisabled = other.textDisabled + iconDefault = other.iconDefault + iconDisabled = other.iconDisabled + textInvalid = other.textInvalid + borderInvalid = other.borderInvalid + } + } + + @Stable + class Overlay internal constructor( + overlayPrimary: Color, + overlaySecondary: Color, + ) { + var overlayPrimary by mutableStateOf(overlayPrimary) + private set + var overlaySecondary by mutableStateOf(overlaySecondary) + private set + + fun update(other: Overlay) { + overlayPrimary = other.overlayPrimary + overlaySecondary = other.overlaySecondary + } + } + + @Stable + class Border internal constructor( + val neutral: Neutral, + val status: Status, + ) { + + @Stable + class Neutral internal constructor( + primary: Color, + secondary: Color, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + + fun update(other: Neutral) { + primary = other.primary + secondary = other.secondary + } + } + + @Stable + class Status internal constructor( + accent: Color, + warning: Color, + attention: Color, + ) { + var accent by mutableStateOf(accent) + private set + var warning by mutableStateOf(warning) + private set + var attention by mutableStateOf(attention) + private set + + fun update(other: Status) { + accent = other.accent + warning = other.warning + attention = other.attention + } + } + + fun update(other: Border) { + neutral.update(other.neutral) + status.update(other.status) + } + } + + @Stable + class Fill internal constructor( + val neutral: Neutral, + val status: Status, + ) { + + @Stable + class Neutral internal constructor( + primary: Color, + primaryInverted: Color, + primaryInvertedConstant: Color, + secondary: Color, + tertiaryConstant: Color, + quaternary: Color, + ) { + var primary by mutableStateOf(primary) + private set + var primaryInverted by mutableStateOf(primaryInverted) + private set + var primaryInvertedConstant by mutableStateOf(primaryInvertedConstant) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiaryConstant by mutableStateOf(tertiaryConstant) + private set + var quaternary by mutableStateOf(quaternary) + private set + + fun update(other: Neutral) { + primary = other.primary + primaryInverted = other.primaryInverted + primaryInvertedConstant = other.primaryInvertedConstant + secondary = other.secondary + tertiaryConstant = other.tertiaryConstant + quaternary = other.quaternary + } + } + + @Stable + class Status internal constructor( + accent: Color, + warning: Color, + attention: Color, + ) { + var accent by mutableStateOf(accent) + private set + var warning by mutableStateOf(warning) + private set + var attention by mutableStateOf(attention) + private set + + fun update(other: Status) { + accent = other.accent + warning = other.warning + attention = other.attention + } + } + + fun update(other: Fill) { + neutral.update(other.neutral) + status.update(other.status) + } + } + + @Stable + class Skeleton internal constructor( + backgroundPrimary: Color, + ) { + var backgroundPrimary by mutableStateOf(backgroundPrimary) + private set + + fun update(other: Skeleton) { + backgroundPrimary = other.backgroundPrimary + } + } + + @Stable + class Markers internal constructor( + backgroundSolidGray: Color, + backgroundDisabled: Color, + backgroundSolidBlue: Color, + textGray: Color, + textDisabled: Color, + iconGray: Color, + iconDisabled: Color, + borderGray: Color, + backgroundTintedBlue: Color, + textBlue: Color, + backgroundSolidRed: Color, + backgroundTintedRed: Color, + iconBlue: Color, + iconRed: Color, + textRed: Color, + backgroundTintedGray: Color, + borderTintedBlue: Color, + borderTintedRed: Color, + ) { + var backgroundSolidGray by mutableStateOf(backgroundSolidGray) + private set + var backgroundDisabled by mutableStateOf(backgroundDisabled) + private set + var backgroundSolidBlue by mutableStateOf(backgroundSolidBlue) + private set + var textGray by mutableStateOf(textGray) + private set + var textDisabled by mutableStateOf(textDisabled) + private set + var iconGray by mutableStateOf(iconGray) + private set + var iconDisabled by mutableStateOf(iconDisabled) + private set + var borderGray by mutableStateOf(borderGray) + private set + var backgroundTintedBlue by mutableStateOf(backgroundTintedBlue) + private set + var textBlue by mutableStateOf(textBlue) + private set + var backgroundSolidRed by mutableStateOf(backgroundSolidRed) + private set + var backgroundTintedRed by mutableStateOf(backgroundTintedRed) + private set + var iconBlue by mutableStateOf(iconBlue) + private set + var iconRed by mutableStateOf(iconRed) + private set + var textRed by mutableStateOf(textRed) + private set + var backgroundTintedGray by mutableStateOf(backgroundTintedGray) + private set + var borderTintedBlue by mutableStateOf(borderTintedBlue) + private set + var borderTintedRed by mutableStateOf(borderTintedRed) + private set + + fun update(other: Markers) { + backgroundSolidGray = other.backgroundSolidGray + backgroundDisabled = other.backgroundDisabled + backgroundSolidBlue = other.backgroundSolidBlue + textGray = other.textGray + textDisabled = other.textDisabled + iconGray = other.iconGray + iconDisabled = other.iconDisabled + borderGray = other.borderGray + backgroundTintedBlue = other.backgroundTintedBlue + textBlue = other.textBlue + backgroundSolidRed = other.backgroundSolidRed + backgroundTintedRed = other.backgroundTintedRed + iconBlue = other.iconBlue + iconRed = other.iconRed + textRed = other.textRed + backgroundTintedGray = other.backgroundTintedGray + borderTintedBlue = other.borderTintedBlue + borderTintedRed = other.borderTintedRed + } + } + + fun update(other: TangemColors2) { + text.update(other.text) + graphic.update(other.graphic) + button.update(other.button) + surface.update(other.surface) + controls.update(other.controls) + field.update(other.field) + overlay.update(other.overlay) + border.update(other.border) + fill.update(other.fill) + skeleton.update(other.skeleton) + markers.update(other.markers) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index 01765f772d..9c862a5c77 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -138,6 +138,11 @@ object TangemTheme { @ReadOnlyComposable get() = LocalTangemColors.current + val colors2: TangemColors2 + @Composable + @ReadOnlyComposable + get() = LocalTangemColors2.current + val typography: TangemTypography @Composable @ReadOnlyComposable @@ -156,7 +161,7 @@ object TangemTheme { @Stable @Composable -private fun tangemColorScheme(colors: TangemColors): ColorScheme { +internal fun tangemColorScheme(colors: TangemColors): ColorScheme { return ColorScheme( primary = colors.background.primary, onPrimary = colors.text.primary1, @@ -206,7 +211,7 @@ private fun tangemColorScheme(colors: TangemColors): ColorScheme { @Composable @ReadOnlyComposable -private fun lightThemeColors(): TangemColors { +internal fun lightThemeColors(redesign: Boolean = false): TangemColors { return TangemColors( text = TangemColors.Text( primary1 = TangemColorPalette.Dark6, @@ -233,8 +238,8 @@ private fun lightThemeColors(): TangemColors { ), background = TangemColors.Background( primary = TangemColorPalette.White, - secondary = TangemColorPalette.Light1, - tertiary = TangemColorPalette.Light1, + secondary = if (redesign) TangemColorPalette.Light1V2 else TangemColorPalette.Light1, + tertiary = if (redesign) TangemColorPalette.Light1V2 else TangemColorPalette.Light1, action = TangemColorPalette.White, ), control = TangemColors.Control( @@ -248,7 +253,7 @@ private fun lightThemeColors(): TangemColors { transparency = TangemColorPalette.White, ), field = TangemColors.Field( - primary = TangemColorPalette.Light1, + primary = if (redesign) TangemColorPalette.Light1V2 else TangemColorPalette.Light1, focused = TangemColorPalette.Light2, ), overlay = TangemColors.Overlay( @@ -260,7 +265,7 @@ private fun lightThemeColors(): TangemColors { @Composable @ReadOnlyComposable -private fun darkThemeColors(): TangemColors { +internal fun darkThemeColors(): TangemColors { return TangemColors( text = TangemColors.Text( primary1 = TangemColorPalette.White, @@ -320,12 +325,16 @@ private val TangemTextSelectionColors: TextSelectionColors backgroundColor = TangemTheme.colors.text.accent.copy(alpha = 0.3f), ) -private val LocalTangemColors = staticCompositionLocalOf { +internal val LocalTangemColors = staticCompositionLocalOf { error("No TangemColors provided") } -private val LocalTangemTypography = staticCompositionLocalOf { - TangemTypography() +internal val LocalTangemColors2 = staticCompositionLocalOf { + error("No TangemColors2 provided") +} + +internal val LocalTangemTypography = staticCompositionLocalOf { + TangemTypography(RobotoFamily) } private val LocalTangemDimens = staticCompositionLocalOf { diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt new file mode 100644 index 0000000000..7a93d0463c --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt @@ -0,0 +1,309 @@ +@file:Suppress("LongMethod") +package com.tangem.core.ui.res + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* + +/** + * Provides additional theming for redesigned components. + * Used together with [TangemTheme]. + * @param content Composable content where the theme is applied. + */ +@Composable +fun TangemThemeRedesign(content: @Composable () -> Unit) { + val themeColors = if (LocalIsInDarkTheme.current) darkThemeColors() else lightThemeColors(redesign = true) + val rememberedColors = remember { themeColors } + .also { it.update(themeColors) } + val rootBackgroundColor = rememberedColors.background.secondary + + MaterialTheme( + colorScheme = tangemColorScheme(colors = themeColors), + ) { + CompositionLocalProvider( + LocalTangemColors provides themeColors, + LocalTangemColors2 provides if (LocalIsInDarkTheme.current) darkThemeColors2() else lightThemeColors2(), + LocalTangemTypography provides TangemTypography(InterFamily), + LocalRootBackgroundColor provides remember(rootBackgroundColor) { mutableStateOf(rootBackgroundColor) }, + ) { + content() + } + } +} + +@Composable +@ReadOnlyComposable +private fun lightThemeColors2(): TangemColors2 { + val text = TangemColors2.Text( + neutral = TangemColors2.Text.Neutral( + primary = TangemColorPalette.Dark6, + primaryInverted = TangemColorPalette.White, + secondary = TangemColorPalette.Dark2, + tertiary = TangemColorPalette.Dark3, + primaryInvertedConstant = TangemColorPalette.White, + ), + status = TangemColors2.Text.Status( + disabled = TangemColorPalette.Light4, + accent = TangemColorPalette.Azure, + warning = TangemColorPalette.Amaranth, + attention = TangemColorPalette.Tangerine, + positive = TangemColorPalette.Green, + ), + ) + val graphic = TangemColors2.Graphic( + neutral = TangemColors2.Graphic.Neutral( + primary = TangemColorPalette.Dark6, + primaryInverted = TangemColorPalette.White, + secondary = TangemColorPalette.Dark2, + tertiary = TangemColorPalette.Dark3, + quaternary = TangemColorPalette.Light4, + primaryInvertedConstant = TangemColorPalette.White, + tertiaryConstant = TangemColorPalette.Dark3, + ), + status = TangemColors2.Graphic.Status( + accent = TangemColorPalette.Azure, + warning = TangemColorPalette.Amaranth, + attention = TangemColorPalette.Tangerine, + ), + ) + val border = TangemColors2.Border( + neutral = TangemColors2.Border.Neutral( + primary = TangemColorPalette.Light3, + secondary = TangemColorPalette.Light5, + ), + status = TangemColors2.Border.Status( + accent = TangemColorPalette.Azure, + warning = TangemColorPalette.Amaranth, + attention = TangemColorPalette.Tangerine, + ), + ) + val overlay = TangemColors2.Overlay( + overlayPrimary = TangemColorPalette.Overlay1, + overlaySecondary = TangemColorPalette.Overlay2, + ) + val fill = TangemColors2.Fill( + neutral = TangemColors2.Fill.Neutral( + primary = TangemColorPalette.Dark6, + primaryInverted = TangemColorPalette.White, + primaryInvertedConstant = TangemColorPalette.White, + secondary = TangemColorPalette.Dark3, + tertiaryConstant = TangemColorPalette.Dark3, + quaternary = TangemColorPalette.Light4, + ), + status = TangemColors2.Fill.Status( + accent = TangemColorPalette.Azure, + warning = TangemColorPalette.Amaranth, + attention = TangemColorPalette.Tangerine, + ), + ) + val button = TangemColors2.Button( + backgroundPrimary = TangemColorPalette.Dark6, + backgroundSecondary = TangemColorPalette.Dark6.copy(alpha = 0.1f), + backgroundDisabled = TangemColorPalette.Light3, + backgroundPositive = TangemColorPalette.Azure, + textSecondary = TangemColorPalette.Dark6, + textPrimary = TangemColorPalette.Light2, + textDisabled = text.neutral.tertiary, + iconPrimary = TangemColorPalette.Dark6, + iconSecondary = TangemColorPalette.Light1V2, + iconDisabled = TangemColorPalette.Light2, + borderPrimary = TangemColorPalette.Dark6, + ) + val surface = TangemColors2.Surface( + level1 = TangemColorPalette.White, + level2 = TangemColorPalette.Light1V2, + level3 = TangemColorPalette.Light1V2, + level4 = TangemColorPalette.White, + ) + val controls = TangemColors2.Controls( + backgroundChecked = TangemColorPalette.Dark6, + backgroundDefault = TangemColorPalette.Light2, + iconDefault = TangemColorPalette.White, + iconDisabled = TangemColorPalette.White, + ) + val field = TangemColors2.Field( + backgroundDefault = TangemColorPalette.Light1V2, + backgroundFocused = TangemColorPalette.Light3, + textPlaceholder = text.neutral.secondary, + textDefault = text.neutral.primary, + textDisabled = text.neutral.tertiary, + iconDefault = graphic.neutral.tertiary, + iconDisabled = graphic.neutral.quaternary, + textInvalid = text.status.warning, + borderInvalid = border.status.warning, + ) + val skeleton = TangemColors2.Skeleton( + backgroundPrimary = TangemColorPalette.Light1V2, + ) + val markers = TangemColors2.Markers( + backgroundSolidGray = TangemColorPalette.Light3, + backgroundDisabled = TangemColorPalette.Light3, + backgroundSolidBlue = TangemColorPalette.Azure, + textGray = TangemColorPalette.Dark2, + textDisabled = text.neutral.tertiary, + iconGray = TangemColorPalette.Dark1, + iconDisabled = TangemColorPalette.Light2, + borderGray = TangemColorPalette.Light3, + backgroundTintedBlue = TangemColorPalette.Azure.copy(alpha = 0.1f), + textBlue = text.status.accent, + backgroundSolidRed = TangemColorPalette.Amaranth, + backgroundTintedRed = TangemColorPalette.Amaranth.copy(alpha = 0.1f), + iconBlue = TangemColorPalette.Azure, + iconRed = TangemColorPalette.Amaranth, + textRed = TangemColorPalette.Amaranth, + backgroundTintedGray = TangemColorPalette.Dark6.copy(alpha = 0.1f), + borderTintedBlue = TangemColorPalette.Azure.copy(alpha = 0.1f), + borderTintedRed = TangemColorPalette.Amaranth.copy(alpha = 0.1f), + ) + return TangemColors2( + text = text, + graphic = graphic, + border = border, + overlay = overlay, + fill = fill, + button = button, + surface = surface, + controls = controls, + field = field, + skeleton = skeleton, + markers = markers, + ) +} + +@Composable +@ReadOnlyComposable +private fun darkThemeColors2(): TangemColors2 { + val text = TangemColors2.Text( + neutral = TangemColors2.Text.Neutral( + primary = TangemColorPalette.White, + primaryInverted = TangemColorPalette.Dark6, + secondary = TangemColorPalette.Light5, + tertiary = TangemColorPalette.Dark1, + primaryInvertedConstant = TangemColorPalette.White, + ), + status = TangemColors2.Text.Status( + disabled = TangemColorPalette.Dark3, + accent = TangemColorPalette.Azure, + warning = TangemColorPalette.Flamingo, + attention = TangemColorPalette.Mustard, + positive = TangemColorPalette.Green, + ), + ) + val graphic = TangemColors2.Graphic( + neutral = TangemColors2.Graphic.Neutral( + primary = TangemColorPalette.White, + primaryInverted = TangemColorPalette.Dark6, + secondary = TangemColorPalette.Light5, + tertiary = TangemColorPalette.Dark3, + quaternary = TangemColorPalette.Dark3, + tertiaryConstant = TangemColorPalette.Dark3, + primaryInvertedConstant = TangemColorPalette.White, + ), + status = TangemColors2.Graphic.Status( + accent = TangemColorPalette.Azure, + warning = TangemColorPalette.Flamingo, + attention = TangemColorPalette.Mustard, + ), + ) + val border = TangemColors2.Border( + neutral = TangemColors2.Border.Neutral( + primary = TangemColorPalette.Dark4, + secondary = TangemColorPalette.Dark4, + ), + status = TangemColors2.Border.Status( + accent = TangemColorPalette.Azure, + warning = TangemColorPalette.Flamingo, + attention = TangemColorPalette.Mustard, + ), + ) + val overlay = TangemColors2.Overlay( + overlayPrimary = TangemColorPalette.Overlay1, + overlaySecondary = TangemColorPalette.Overlay2, + ) + val fill = TangemColors2.Fill( + neutral = TangemColors2.Fill.Neutral( + primary = TangemColorPalette.White, + primaryInverted = TangemColorPalette.Dark6, + primaryInvertedConstant = TangemColorPalette.White, + secondary = TangemColorPalette.Light5, + tertiaryConstant = TangemColorPalette.Dark1, + quaternary = TangemColorPalette.Dark3, + ), + status = TangemColors2.Fill.Status( + accent = TangemColorPalette.Azure, + warning = TangemColorPalette.Flamingo, + attention = TangemColorPalette.Mustard, + ), + ) + val button = TangemColors2.Button( + backgroundPrimary = TangemColorPalette.Light1V2, + backgroundSecondary = TangemColorPalette.White.copy(alpha = 0.1f), + backgroundDisabled = TangemColorPalette.Dark5, + backgroundPositive = TangemColorPalette.Azure, + textSecondary = TangemColorPalette.Light4, + textPrimary = TangemColorPalette.Dark4, + textDisabled = text.neutral.secondary, + iconPrimary = TangemColorPalette.Light4, + iconSecondary = TangemColorPalette.Dark4, + iconDisabled = TangemColorPalette.Dark5, + borderPrimary = TangemColorPalette.Light4, + ) + val surface = TangemColors2.Surface( + level1 = TangemColorPalette.Dark6, + level2 = TangemColorPalette.Black, + level3 = TangemColorPalette.Dark6, + level4 = TangemColorPalette.Dark5, + ) + val controls = TangemColors2.Controls( + backgroundChecked = TangemColorPalette.Azure, + backgroundDefault = TangemColorPalette.Dark4, + iconDefault = TangemColorPalette.White, + iconDisabled = TangemColorPalette.White, + ) + val field = TangemColors2.Field( + backgroundDefault = TangemColorPalette.Dark6, + backgroundFocused = TangemColorPalette.Dark4, + textPlaceholder = text.neutral.secondary, + textDefault = text.neutral.primary, + textDisabled = text.neutral.tertiary, + iconDefault = graphic.neutral.tertiary, + iconDisabled = graphic.neutral.quaternary, + textInvalid = text.status.warning, + borderInvalid = border.status.warning, + ) + val skeleton = TangemColors2.Skeleton( + backgroundPrimary = TangemColorPalette.Dark5, + ) + val markers = TangemColors2.Markers( + backgroundSolidGray = TangemColorPalette.Dark5, + backgroundDisabled = TangemColorPalette.Dark5, + backgroundSolidBlue = TangemColorPalette.Azure, + textGray = TangemColorPalette.Light4, + textDisabled = text.neutral.secondary, + iconGray = TangemColorPalette.Dark2, + iconDisabled = TangemColorPalette.Dark5, + borderGray = TangemColorPalette.White.copy(alpha = 0.2f), + backgroundTintedBlue = TangemColorPalette.Azure.copy(alpha = 0.1f), + textBlue = text.status.accent, + backgroundSolidRed = TangemColorPalette.Amaranth, + backgroundTintedRed = TangemColorPalette.Amaranth.copy(alpha = 0.1f), + iconBlue = TangemColorPalette.Azure, + iconRed = TangemColorPalette.Flamingo, + textRed = TangemColorPalette.Flamingo, + backgroundTintedGray = TangemColorPalette.White.copy(alpha = 0.1f), + borderTintedBlue = TangemColorPalette.Azure.copy(alpha = 0.1f), + borderTintedRed = TangemColorPalette.Amaranth.copy(alpha = 0.1f), + ) + return TangemColors2( + text = text, + graphic = graphic, + border = border, + overlay = overlay, + fill = fill, + button = button, + surface = surface, + controls = controls, + field = field, + skeleton = skeleton, + markers = markers, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt index 9c029684fe..d50a43f6ed 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.TextUnit @@ -11,15 +12,22 @@ import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.sp import com.tangem.core.ui.R -private val RobotoFamily = FontFamily( +internal val RobotoFamily = FontFamily( Font(R.font.roboto_regular, FontWeight.Normal), Font(R.font.roboto_medium, FontWeight.Medium), ) +internal val InterFamily = FontFamily( + Font(R.font.inter_regular), + Font(R.font.inter_italic, style = FontStyle.Italic), +) + @Immutable -data class TangemTypography internal constructor( +class TangemTypography internal constructor( + fontFamily: FontFamily, +) { val head: TextStyle = TextStyle( - fontFamily = RobotoFamily, + fontFamily = fontFamily, fontSize = 34.sp, fontWeight = FontWeight.SemiBold, letterSpacing = TextUnit(value = 0f, type = TextUnitType.Sp), @@ -28,9 +36,9 @@ data class TangemTypography internal constructor( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, ), - ), + ) val h1: TextStyle = TextStyle( - fontFamily = RobotoFamily, + fontFamily = fontFamily, fontSize = 34.sp, fontWeight = FontWeight.Normal, letterSpacing = TextUnit(value = 0f, type = TextUnitType.Sp), @@ -39,9 +47,9 @@ data class TangemTypography internal constructor( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, ), - ), + ) val h2: TextStyle = TextStyle( - fontFamily = RobotoFamily, + fontFamily = fontFamily, fontSize = 24.sp, fontWeight = FontWeight.Medium, letterSpacing = TextUnit(value = 0.18f, type = TextUnitType.Sp), @@ -50,9 +58,9 @@ data class TangemTypography internal constructor( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, ), - ), + ) val h3: TextStyle = TextStyle( - fontFamily = RobotoFamily, + fontFamily = fontFamily, fontSize = 20.sp, fontWeight = FontWeight.Medium, letterSpacing = TextUnit(value = 0.15f, type = TextUnitType.Sp), @@ -61,9 +69,9 @@ data class TangemTypography internal constructor( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, ), - ), + ) val subtitle1: TextStyle = TextStyle( - fontFamily = RobotoFamily, + fontFamily = fontFamily, fontSize = 16.sp, fontWeight = FontWeight.Medium, letterSpacing = TextUnit(value = 0.15f, type = TextUnitType.Sp), @@ -72,9 +80,9 @@ data class TangemTypography internal constructor( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, ), - ), + ) val subtitle2: TextStyle = TextStyle( - fontFamily = RobotoFamily, + fontFamily = fontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, letterSpacing = TextUnit(value = 0.1f, type = TextUnitType.Sp), @@ -83,9 +91,9 @@ data class TangemTypography internal constructor( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, ), - ), + ) val body1: TextStyle = TextStyle( - fontFamily = RobotoFamily, + fontFamily = fontFamily, fontSize = 16.sp, fontWeight = FontWeight.Normal, letterSpacing = TextUnit(value = 0.5f, type = TextUnitType.Sp), @@ -94,9 +102,9 @@ data class TangemTypography internal constructor( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, ), - ), + ) val body2: TextStyle = TextStyle( - fontFamily = RobotoFamily, + fontFamily = fontFamily, fontSize = 14.sp, fontWeight = FontWeight.Normal, letterSpacing = TextUnit(value = 0.25f, type = TextUnitType.Sp), @@ -105,9 +113,9 @@ data class TangemTypography internal constructor( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, ), - ), + ) val button: TextStyle = TextStyle( - fontFamily = RobotoFamily, + fontFamily = fontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, letterSpacing = TextUnit(value = 0.1f, type = TextUnitType.Sp), @@ -116,9 +124,9 @@ data class TangemTypography internal constructor( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, ), - ), + ) val caption1: TextStyle = TextStyle( - fontFamily = RobotoFamily, + fontFamily = fontFamily, fontSize = 12.sp, fontWeight = FontWeight.Medium, letterSpacing = TextUnit(value = 0.4f, type = TextUnitType.Sp), @@ -127,9 +135,9 @@ data class TangemTypography internal constructor( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, ), - ), + ) val caption2: TextStyle = TextStyle( - fontFamily = RobotoFamily, + fontFamily = fontFamily, fontSize = 12.sp, fontWeight = FontWeight.Normal, letterSpacing = TextUnit(value = 0.4f, type = TextUnitType.Sp), @@ -138,9 +146,9 @@ data class TangemTypography internal constructor( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, ), - ), + ) val overline: TextStyle = TextStyle( - fontFamily = RobotoFamily, + fontFamily = fontFamily, fontSize = 10.sp, fontWeight = FontWeight.Medium, letterSpacing = TextUnit(value = 1.5f, type = TextUnitType.Sp), @@ -149,5 +157,5 @@ data class TangemTypography internal constructor( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, ), - ), -) \ No newline at end of file + ) +} \ 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-hdpi/img_tangem_cards_vertical.webp b/core/ui/src/main/res/drawable-hdpi/img_tangem_cards_vertical.webp new file mode 100644 index 0000000000..331074925c Binary files /dev/null and b/core/ui/src/main/res/drawable-hdpi/img_tangem_cards_vertical.webp differ diff --git a/core/ui/src/main/res/drawable-mdpi/img_tangem_cards_vertical.webp b/core/ui/src/main/res/drawable-mdpi/img_tangem_cards_vertical.webp new file mode 100644 index 0000000000..554d4f864a Binary files /dev/null and b/core/ui/src/main/res/drawable-mdpi/img_tangem_cards_vertical.webp differ diff --git a/core/ui/src/main/res/drawable-xhdpi/img_tangem_cards_vertical.webp b/core/ui/src/main/res/drawable-xhdpi/img_tangem_cards_vertical.webp new file mode 100644 index 0000000000..b82c8c3be9 Binary files /dev/null and b/core/ui/src/main/res/drawable-xhdpi/img_tangem_cards_vertical.webp differ diff --git a/core/ui/src/main/res/drawable-xxhdpi/img_tangem_cards_vertical.webp b/core/ui/src/main/res/drawable-xxhdpi/img_tangem_cards_vertical.webp new file mode 100644 index 0000000000..af8944386f Binary files /dev/null and b/core/ui/src/main/res/drawable-xxhdpi/img_tangem_cards_vertical.webp differ diff --git a/core/ui/src/main/res/drawable-xxxhdpi/img_tangem_cards_vertical.webp b/core/ui/src/main/res/drawable-xxxhdpi/img_tangem_cards_vertical.webp new file mode 100644 index 0000000000..0cfeca614e Binary files /dev/null and b/core/ui/src/main/res/drawable-xxxhdpi/img_tangem_cards_vertical.webp differ 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/core/ui/src/main/res/font/inter_italic.ttf b/core/ui/src/main/res/font/inter_italic.ttf new file mode 100644 index 0000000000..43ed4f5ee6 Binary files /dev/null and b/core/ui/src/main/res/font/inter_italic.ttf differ diff --git a/core/ui/src/main/res/font/inter_regular.ttf b/core/ui/src/main/res/font/inter_regular.ttf new file mode 100644 index 0000000000..e31b51e3e9 Binary files /dev/null and b/core/ui/src/main/res/font/inter_regular.ttf differ diff --git a/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineDispatcherProvider.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineDispatcherProvider.kt index 53cfb7ed29..06218ad8f4 100644 --- a/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineDispatcherProvider.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineDispatcherProvider.kt @@ -28,5 +28,4 @@ class TestingCoroutineDispatcherProvider( override val io: CoroutineDispatcher = Dispatchers.Unconfined, override val default: CoroutineDispatcher = Dispatchers.Unconfined, override val single: CoroutineDispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher(), -) : CoroutineDispatcherProvider - +) : CoroutineDispatcherProvider \ No newline at end of file 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/converter/CryptoPortfolioConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt index fd06cb7bba..daf35d8a49 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt @@ -36,6 +36,7 @@ internal class CryptoPortfolioConverter @AssistedInject constructor( responseCryptoCurrenciesFactory.createCurrencies( tokens = tokens, userWallet = userWallet, + accountIndex = value.derivationIndex.toDerivationIndex(), ).toSet() } else { emptySet() diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt index 002af1472c..b996298a5e 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -10,9 +10,9 @@ import com.tangem.data.account.store.ArchivedAccountsStoreFactory import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration import com.tangem.data.common.account.WalletAccountsFetcher 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.tangemTech.TangemTechApi +import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.repository.AccountsCRUDRepository @@ -42,7 +42,6 @@ internal object AccountDataModule { accountsResponseStoreFactory: AccountsResponseStoreFactory, userWalletsStore: UserWalletsStore, userTokensSaver: UserTokensSaver, - eTagsStore: ETagsStore, accountConverterFactoryContainer: AccountConverterFactoryContainer, dispatchers: CoroutineDispatcherProvider, ): AccountsCRUDRepository { @@ -53,7 +52,7 @@ internal object AccountDataModule { archivedAccountsStoreFactory = ArchivedAccountsStoreFactory, userWalletsStore = userWalletsStore, userTokensSaver = userTokensSaver, - eTagsStore = eTagsStore, + archivedAccountsETagStore = RuntimeStateStore(emptyMap()), convertersContainer = accountConverterFactoryContainer, dispatchers = dispatchers, ) 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..9722001f29 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,9 +3,9 @@ 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.local.userwallet.UserWalletsStore +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer @@ -48,12 +48,16 @@ internal class AccountListCryptoCurrenciesProducer @AssistedInject constructor( .map { response -> if (response == null) return@map emptySet() - responseCryptoCurrenciesFactory.createCurrencies( - response = response.toUserTokensResponse(), - userWallet = userWallet, - ).toSet() + response.accounts.flatMapTo(hashSetOf()) { accountDTO -> + responseCryptoCurrenciesFactory.createCurrencies( + tokens = accountDTO.tokens.orEmpty(), + userWallet = userWallet, + accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull(), + ) + } } .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..cdb3dcba43 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,16 @@ 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.api.safeApiCall import com.tangem.data.common.currency.UserTokensSaver -import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException +import com.tangem.datasource.api.common.response.ETAG_HEADER 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.datastore.RuntimeStateStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.datasource.utils.getSyncOrNull import com.tangem.domain.account.models.AccountList @@ -43,7 +46,7 @@ internal class DefaultAccountsCRUDRepository( private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory, private val userWalletsStore: UserWalletsStore, private val userTokensSaver: UserTokensSaver, - private val eTagsStore: ETagsStore, + private val archivedAccountsETagStore: RuntimeStateStore>, private val convertersContainer: AccountConverterFactoryContainer, private val dispatchers: CoroutineDispatcherProvider, ) : AccountsCRUDRepository { @@ -90,19 +93,38 @@ internal class DefaultAccountsCRUDRepository( } override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) { - val response = withContext(dispatchers.io) { - tangemTechApi.getWalletArchivedAccounts( - walletId = userWalletId.stringValue, - eTag = getETag(userWalletId), - ).getOrThrow() - } - + val eTag = archivedAccountsETagStore.getSyncOrNull()?.get(key = userWalletId.stringValue) val store = getArchivedAccountsStore(userWalletId = userWalletId) - val converter = ArchivedAccountConverter(userWalletId = userWalletId) - val archivedAccounts = converter.convertList(input = response.accounts) + val response = safeApiCall( + call = { + val apiResponse = withContext(dispatchers.io) { + tangemTechApi.getWalletArchivedAccounts( + walletId = userWalletId.stringValue, + eTag = eTag, + ) + } - store.store(value = archivedAccounts) + saveETag(userWalletId, apiResponse) + + apiResponse.bind() + }, + onError = { + if (it is HttpException && it.code == HttpException.Code.NOT_MODIFIED) { + null + } else { + throw it + } + }, + ) + + if (response != null) { + val converter = ArchivedAccountConverter(userWalletId = userWalletId) + + val archivedAccounts = converter.convertList(input = response.accounts) + + store.store(value = archivedAccounts) + } } override suspend fun saveAccounts(accountList: AccountList) { @@ -154,9 +176,17 @@ internal class DefaultAccountsCRUDRepository( return accountListResponse.wallet.totalAccounts.toOption() } - override fun getTotalAccountsCount(userWalletId: UserWalletId): Flow> { + override suspend fun getTotalActiveAccountsCountSync(userWalletId: UserWalletId): Option = option { + val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId) + + ensureNotNull(accountListResponse) + + return accountListResponse.accounts.size.toOption() + } + + override fun getTotalActiveAccountsCount(userWalletId: UserWalletId): Flow> { return getAccountsResponseStore(userWalletId = userWalletId).data - .map { it?.wallet?.totalAccounts.toOption() } + .map { it?.accounts?.size.toOption() } } override fun getUserWallet(userWalletId: UserWalletId): UserWallet { @@ -167,8 +197,12 @@ internal class DefaultAccountsCRUDRepository( override fun getUserWalletsSync(): List = userWalletsStore.userWalletsSync - private suspend fun getETag(userWalletId: UserWalletId): String? { - return eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts) + private suspend fun saveETag(userWalletId: UserWalletId, apiResponse: ApiResponse<*>) { + val eTag = apiResponse.headers[ETAG_HEADER]?.firstOrNull() + + archivedAccountsETagStore.update { + it + (userWalletId.stringValue to eTag) + } } private suspend fun getAccountsResponseSync(userWalletId: UserWalletId): GetWalletAccountsResponse? { diff --git a/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt b/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt index 90a1de9362..2000100c0e 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 @@ -78,6 +78,8 @@ internal class DefaultMainAccountTokensMigration( }, ) + store.updateData { updatedResponse } + userTokensSaver.push( userWalletId = userWalletId, response = updatedResponse.toUserTokensResponse(), 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/repository/DefaultAccountsCRUDRepositoryTest.kt b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt index 5881a3194e..f338ee9cf1 100644 --- a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt @@ -10,7 +10,6 @@ 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.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.ApiResponse import com.tangem.datasource.api.tangemTech.TangemTechApi @@ -54,7 +53,7 @@ class DefaultAccountsCRUDRepositoryTest { private val userWalletsStore: UserWalletsStore = mockk() private val userTokensSaver: UserTokensSaver = mockk() - private val eTagsStore: ETagsStore = mockk() + private val archivedAccountsETagStore: RuntimeStateStore> = mockk(relaxUnitFun = true) private val convertersContainer: AccountConverterFactoryContainer = mockk() private val accountListConverter: AccountListConverter = mockk() @@ -67,7 +66,7 @@ class DefaultAccountsCRUDRepositoryTest { archivedAccountsStoreFactory = archivedAccountsStoreFactory, userWalletsStore = userWalletsStore, userTokensSaver = userTokensSaver, - eTagsStore = eTagsStore, + archivedAccountsETagStore = archivedAccountsETagStore, convertersContainer = convertersContainer, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -532,7 +531,7 @@ class DefaultAccountsCRUDRepositoryTest { val archivedAccount = ArchivedAccountConverter(userWalletId).convert(accountDTO) - coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag + coEvery { archivedAccountsETagStore.getSyncOrNull() } returns mapOf(userWalletId.stringValue to eTag) coEvery { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) @@ -546,9 +545,10 @@ class DefaultAccountsCRUDRepositoryTest { Truth.assertThat(actual).containsExactly(archivedAccount) coVerifyOrder { - eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) - tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) + archivedAccountsETagStore.getSyncOrNull() archivedAccountsStoreFactory.create(userWalletId) + tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) + archivedAccountsETagStore.update(any()) } } @@ -558,7 +558,7 @@ class DefaultAccountsCRUDRepositoryTest { val eTag = "etag123" val exception = Exception("API error") - coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag + coEvery { archivedAccountsETagStore.getSyncOrNull() } returns mapOf(userWalletId.stringValue to eTag) coEvery { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) } throws exception // Act @@ -570,7 +570,7 @@ class DefaultAccountsCRUDRepositoryTest { Truth.assertThat(archivedAccountsStore.getSyncOrNull()).isNull() coVerifyOrder { - eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) + archivedAccountsETagStore.getSyncOrNull() tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) } } @@ -644,15 +644,15 @@ class DefaultAccountsCRUDRepositoryTest { @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) - inner class GetTotalAccountsCountSync { + inner class GetTotalActiveAccountsCountSync { @Test - fun `getTotalAccountsCountSync returns None if account list response is null`() = runTest { + fun `getTotalActiveAccountsCountSync returns None if account list response is null`() = runTest { // Arrange accountsResponseStoreFlow.value = null // Act - val actual = repository.getTotalAccountsCountSync(userWalletId) + val actual = repository.getTotalActiveAccountsCountSync(userWalletId) // Assert Truth.assertThat(actual).isEqualTo(None) @@ -664,20 +664,22 @@ class DefaultAccountsCRUDRepositoryTest { } @Test - fun `getTotalAccountsCountSync returns Some with totalAccounts when response is valid`() = runTest { + fun `getTotalActiveAccountsCountSync returns Some with totalAccounts when response is valid`() = runTest { // Arrange val totalAccounts = 5 - val response = mockk { - every { this@mockk.wallet.totalAccounts } returns totalAccounts + val response = createGetWalletAccountsResponse(userWalletId).let { + it.copy( + wallet = it.wallet.copy(totalAccounts = totalAccounts), + ) } accountsResponseStoreFlow.value = response // Act - val actual = repository.getTotalAccountsCountSync(userWalletId) + val actual = repository.getTotalActiveAccountsCountSync(userWalletId) // Assert - val expected = totalAccounts.toOption() + val expected = 1.toOption() Truth.assertThat(actual).isEqualTo(expected) verifyOrder { @@ -689,15 +691,15 @@ class DefaultAccountsCRUDRepositoryTest { @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) - inner class GetTotalAccountsCount { + inner class GetTotalActiveAccountsCount { @Test - fun `getTotalAccountsCount emits 0 when account list response is null`() = runTest { + fun `getTotalActiveAccountsCount emits 0 when account list response is null`() = runTest { // Arrange accountsResponseStoreFlow.value = null // Act - val flow = repository.getTotalAccountsCount(userWalletId) + val flow = repository.getTotalActiveAccountsCount(userWalletId) val actual = getEmittedValues(flow) // Assert @@ -710,21 +712,23 @@ class DefaultAccountsCRUDRepositoryTest { } @Test - fun `getTotalAccountsCount emits correct value when response is valid`() = runTest { + fun `getTotalActiveAccountsCount emits correct value when response is valid`() = runTest { // Arrange val totalAccounts = 7 - val response = mockk { - every { this@mockk.wallet.totalAccounts } returns totalAccounts + val response = createGetWalletAccountsResponse(userWalletId).let { + it.copy( + wallet = it.wallet.copy(totalAccounts = totalAccounts), + ) } accountsResponseStoreFlow.value = response // Act - val flow = repository.getTotalAccountsCount(userWalletId) + val flow = repository.getTotalActiveAccountsCount(userWalletId) val actual = getEmittedValues(flow) // Assert - Truth.assertThat(actual).containsExactly(totalAccounts.toOption()) + Truth.assertThat(actual).containsExactly(1.toOption()) verifyOrder { accountsResponseStoreFactory.create(userWalletId) accountsResponseStore.data 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..b0bd00999e 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 @@ -183,6 +183,8 @@ class DefaultMainAccountTokensMigrationTest { accountsResponseStoreFlow.value = response + coEvery { accountsResponseStore.updateData(any()) } returns mockk() + // Act val actual = migration.migrate(userWalletId, derivationIndex) @@ -199,6 +201,7 @@ class DefaultMainAccountTokensMigrationTest { coVerifySequence { accountsResponseStoreFactory.create(userWalletId) accountsResponseStore.data + accountsResponseStore.updateData(any()) userTokensSaver.push( userWalletId = userWalletId, response = migratedResponse.toUserTokensResponse(), 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/CryptoCurrencyFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt index 311c07652a..2541ac4d46 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt @@ -7,6 +7,7 @@ import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toCoinId import com.tangem.data.common.network.NetworkFactory +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet @@ -18,7 +19,7 @@ class CryptoCurrencyFactory( private val excludedBlockchains: ExcludedBlockchains, ) { - private val networkFactory by lazy(LazyThreadSafetyMode.NONE) { NetworkFactory(excludedBlockchains) } + val networkFactory by lazy(LazyThreadSafetyMode.NONE) { NetworkFactory(excludedBlockchains) } @Suppress("LongParameterList") // Yep, it's long fun createToken( @@ -48,6 +49,7 @@ class CryptoCurrencyFactory( blockchain: Blockchain, extraDerivationPath: String?, userWallet: UserWallet, + accountIndex: DerivationIndex? = null, ): CryptoCurrency.Token? { if (blockchain == Blockchain.Unknown) { Timber.e("Unable to map the SDK token to the domain token with Unknown blockchain") @@ -58,6 +60,7 @@ class CryptoCurrencyFactory( blockchain = blockchain, extraDerivationPath = extraDerivationPath, userWallet = userWallet, + accountIndex = accountIndex, ) ?: return null val id = getTokenId(network, sdkToken) @@ -74,11 +77,16 @@ class CryptoCurrencyFactory( ) } - fun createCoin(chainId: Int, extraDerivationPath: String?, userWallet: UserWallet): CryptoCurrency.Coin? { + fun createCoin( + chainId: Int, + extraDerivationPath: String?, + userWallet: UserWallet, + accountIndex: DerivationIndex? = null, + ): CryptoCurrency.Coin? { val blockchain: Blockchain? = Chain.entries.find { it.id == chainId }?.blockchain return if (blockchain != null) { - createCoin(blockchain, extraDerivationPath, userWallet) + createCoin(blockchain, extraDerivationPath, userWallet, accountIndex) } else { Timber.e("Unable to get blockchain from chainId == $chainId") null @@ -89,6 +97,7 @@ class CryptoCurrencyFactory( blockchain: Blockchain, extraDerivationPath: String?, userWallet: UserWallet, + accountIndex: DerivationIndex? = null, ): CryptoCurrency.Coin? { if (blockchain == Blockchain.Unknown) { Timber.e("Unable to map the SDK token to the domain token with Unknown blockchain") @@ -99,6 +108,7 @@ class CryptoCurrencyFactory( blockchain = blockchain, extraDerivationPath = extraDerivationPath, userWallet = userWallet, + accountIndex = accountIndex, ) ?: return null return createCoin(network) 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..f10df788e9 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,14 +1,16 @@ 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.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.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet @@ -23,10 +25,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 +104,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,15 +139,32 @@ internal class DefaultCardCryptoCurrencyFactory( userWallet: UserWallet, networks: Set, ): Map> { - val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId) - ?: return emptyMap() + val existingNetworkWithCurrencies = if (accountsFeatureToggles.isFeatureEnabled) { + val response = walletAccountsFetcher.getSaved(userWallet.walletId) + ?: return emptyMap() - val existingNetworkWithCurrencies = responseCryptoCurrenciesFactory.createCurrencies( - tokens = response.tokens.filter { token -> - networks.any { it.backendId == token.networkId && it.derivationPath.value == token.derivationPath } - }, - userWallet = userWallet, - ) + response.accounts.flatMapTo(hashSetOf()) { accountDTO -> + responseCryptoCurrenciesFactory.createCurrencies( + tokens = accountDTO.tokens.orEmpty().filter { token -> + networks.any { + it.backendId == token.networkId && it.derivationPath.value == token.derivationPath + } + }, + userWallet = userWallet, + accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull(), + ) + } + } else { + val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId) + ?: return emptyMap() + + responseCryptoCurrenciesFactory.createCurrencies( + tokens = response.tokens.filter { token -> + networks.any { it.backendId == token.networkId && it.derivationPath.value == token.derivationPath } + }, + userWallet = userWallet, + ) + } .groupBy(CryptoCurrency::network) return networks.associateWith { emptyList() } + existingNetworkWithCurrencies @@ -170,15 +174,28 @@ internal class DefaultCardCryptoCurrencyFactory( userWallet: UserWallet, rawIds: Set, ): Map> { - val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId) - ?: return emptyMap() - val networkIds = rawIds.map { it.toBlockchain().toNetworkId() } - return responseCryptoCurrenciesFactory.createCurrencies( - tokens = response.tokens.filter { token -> token.networkId in networkIds }, - userWallet = userWallet, - ) + return if (accountsFeatureToggles.isFeatureEnabled) { + val response = walletAccountsFetcher.getSaved(userWallet.walletId) + ?: return emptyMap() + + response.accounts.flatMapTo(hashSetOf()) { accountDTO -> + responseCryptoCurrenciesFactory.createCurrencies( + tokens = accountDTO.tokens.orEmpty().filter { token -> token.networkId in networkIds }, + userWallet = userWallet, + accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull(), + ) + } + } else { + val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId) + ?: return emptyMap() + + responseCryptoCurrenciesFactory.createCurrencies( + tokens = response.tokens.filter { token -> token.networkId in networkIds }, + userWallet = userWallet, + ) + } .groupBy { it.network.id.rawId } } diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt index 9bba514a03..dab13ed0a5 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt @@ -7,6 +7,7 @@ import com.tangem.blockchainsdk.utils.toCoinId import com.tangem.data.common.network.NetworkFactory import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import timber.log.Timber @@ -17,26 +18,31 @@ class ResponseCryptoCurrenciesFactory @Inject constructor( private val networkFactory: NetworkFactory, ) { - fun createCurrency(currencyId: String, response: UserTokensResponse, userWallet: UserWallet): CryptoCurrency { - return response.tokens - .asSequence() - .mapNotNull { createCurrency(it, userWallet) } - .first { it.id.value == currencyId } + fun createCurrencies( + response: UserTokensResponse, + userWallet: UserWallet, + accountIndex: DerivationIndex? = null, + ): List { + return createCurrencies(tokens = response.tokens, userWallet = userWallet, accountIndex = accountIndex) } - fun createCurrencies(response: UserTokensResponse, userWallet: UserWallet): List { - return createCurrencies(tokens = response.tokens, userWallet = userWallet) - } - - fun createCurrencies(tokens: List, userWallet: UserWallet): List { + fun createCurrencies( + tokens: List, + userWallet: UserWallet, + accountIndex: DerivationIndex? = null, + ): List { return tokens .asSequence() - .mapNotNull { createCurrency(it, userWallet) } + .mapNotNull { createCurrency(it, userWallet, accountIndex) } .distinctBy(CryptoCurrency::id) .toList() } - fun createCurrency(responseToken: UserTokensResponse.Token, userWallet: UserWallet): CryptoCurrency? { + fun createCurrency( + responseToken: UserTokensResponse.Token, + userWallet: UserWallet, + accountIndex: DerivationIndex? = null, + ): CryptoCurrency? { var blockchain = Blockchain.fromNetworkId(responseToken.networkId) if (blockchain == null || blockchain == Blockchain.Unknown) { Timber.e("Unable to find a blockchain with the network ID: ${responseToken.networkId}") @@ -49,9 +55,9 @@ class ResponseCryptoCurrenciesFactory @Inject constructor( val sdkToken = createSdkToken(responseToken) return if (sdkToken == null) { - createCoin(blockchain, responseToken, userWallet) + createCoin(blockchain, responseToken, userWallet, accountIndex) } else { - createToken(blockchain, sdkToken, responseToken.derivationPath, userWallet) + createToken(blockchain, sdkToken, responseToken.derivationPath, userWallet, accountIndex) } } @@ -71,11 +77,13 @@ class ResponseCryptoCurrenciesFactory @Inject constructor( blockchain: Blockchain, responseToken: UserTokensResponse.Token, userWallet: UserWallet, + accountIndex: DerivationIndex?, ): CryptoCurrency.Coin? { val network = networkFactory.create( blockchain = blockchain, extraDerivationPath = responseToken.derivationPath, userWallet = userWallet, + accountIndex = accountIndex, ) ?: return null return CryptoCurrency.Coin( @@ -106,11 +114,13 @@ class ResponseCryptoCurrenciesFactory @Inject constructor( sdkToken: Token, responseDerivationPath: String?, userWallet: UserWallet, + accountIndex: DerivationIndex?, ): CryptoCurrency.Token? { val network = networkFactory.create( blockchain = blockchain, extraDerivationPath = responseDerivationPath, userWallet = userWallet, + accountIndex = accountIndex, ) ?: return null val id = getTokenId(network, sdkToken) 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..0484bd5bfe 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,11 +1,17 @@ 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] class UserTokensResponseFactory @Inject constructor() { fun createUserTokensResponse( @@ -43,4 +49,38 @@ class UserTokensResponseFactory @Inject constructor() { ) } } + + fun createDefaultResponse( + userWallet: UserWallet?, + networkFactory: NetworkFactory, + accountId: AccountId?, + ): 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..d43b2d8de1 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,12 @@ 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.account.DerivationIndex 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 com.tangem.lib.crypto.derivation.toMutable import timber.log.Timber import javax.inject.Inject @@ -21,6 +23,7 @@ import javax.inject.Inject * [REDACTED_AUTHOR] */ +@Suppress("LargeClass") class NetworkFactory @Inject constructor( private val excludedBlockchains: ExcludedBlockchains, ) { @@ -32,13 +35,19 @@ class NetworkFactory @Inject constructor( * @param extraDerivationPath extra derivation path * @param userWallet user wallet */ - fun create(blockchain: Blockchain, extraDerivationPath: String?, userWallet: UserWallet): Network? { + fun create( + blockchain: Blockchain, + extraDerivationPath: String?, + userWallet: UserWallet, + accountIndex: DerivationIndex? = null, + ): Network? { return create( blockchain = blockchain, derivationPath = createDerivationPath( blockchain = blockchain, extraDerivationPath = extraDerivationPath, cardDerivationStyleProvider = userWallet.derivationStyleProvider, + accountIndex = accountIndex, ), canHandleTokens = userWallet.canHandleToken( blockchain = blockchain, @@ -80,6 +89,7 @@ class NetworkFactory @Inject constructor( extraDerivationPath: String?, derivationStyleProvider: DerivationStyleProvider?, canHandleTokens: Boolean, + accountIndex: DerivationIndex? = null, ): Network? { return create( blockchain = blockchain, @@ -87,6 +97,7 @@ class NetworkFactory @Inject constructor( blockchain = blockchain, extraDerivationPath = extraDerivationPath, cardDerivationStyleProvider = derivationStyleProvider, + accountIndex = accountIndex, ), canHandleTokens = canHandleTokens, ) @@ -130,14 +141,15 @@ class NetworkFactory @Inject constructor( return true } - private fun createDerivationPath( + fun createDerivationPath( blockchain: Blockchain, extraDerivationPath: String?, cardDerivationStyleProvider: DerivationStyleProvider?, + accountIndex: DerivationIndex? = null, ): Network.DerivationPath { if (cardDerivationStyleProvider == null) return Network.DerivationPath.None - val defaultDerivationPath = getDefaultDerivationPath(blockchain, cardDerivationStyleProvider) + val defaultDerivationPath = getDefaultDerivationPath(blockchain, cardDerivationStyleProvider, accountIndex) return if (extraDerivationPath.isNullOrBlank()) { if (defaultDerivationPath.isNullOrBlank()) { @@ -146,10 +158,11 @@ class NetworkFactory @Inject constructor( Network.DerivationPath.Card(defaultDerivationPath) } } else { - if (extraDerivationPath == defaultDerivationPath) { - Network.DerivationPath.Card(defaultDerivationPath) - } else { + val isMainIndexOrNull = accountIndex == null || accountIndex == DerivationIndex.Main + if (extraDerivationPath != defaultDerivationPath && isMainIndexOrNull) { Network.DerivationPath.Custom(extraDerivationPath) + } else { + Network.DerivationPath.Card(extraDerivationPath) } } } @@ -157,8 +170,19 @@ class NetworkFactory @Inject constructor( private fun getDefaultDerivationPath( blockchain: Blockchain, derivationStyleProvider: DerivationStyleProvider, + accountIndex: DerivationIndex?, ): String? { - return blockchain.derivationPath(derivationStyleProvider.getDerivationStyle())?.rawPath + val default = blockchain.derivationPath(derivationStyleProvider.getDerivationStyle()) + ?: return null + + return if (accountIndex == null || accountIndex == DerivationIndex.Main) { + default + } else { + default.toMutable() + .replaceAccountNode(value = accountIndex.value.toLong(), blockchain = blockchain) + .apply() + } + .rawPath } private fun getNetworkStandardType(blockchain: Blockchain): Network.StandardType { @@ -326,8 +350,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/build.gradle.kts b/data/manage-tokens/build.gradle.kts index 9095c4f1ab..bc3abef19a 100644 --- a/data/manage-tokens/build.gradle.kts +++ b/data/manage-tokens/build.gradle.kts @@ -15,6 +15,7 @@ android { dependencies { /** Project - Domain */ + implementation(projects.domain.account) implementation(projects.domain.demo) implementation(projects.domain.models) implementation(projects.domain.manageTokens) 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..c511d37855 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 @@ -16,20 +17,20 @@ import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher import com.tangem.data.managetokens.utils.ManagedCryptoCurrencyFactory import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.CoinsResponse import com.tangem.datasource.api.tangemTech.models.UserTokensResponse 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.account.featuretoggle.AccountsFeatureToggles 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 +41,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,10 +52,16 @@ internal class DefaultManageTokensRepository( private val excludedBlockchains: ExcludedBlockchains, private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, private val dispatchers: CoroutineDispatcherProvider, + private val walletAccountsFetcher: WalletAccountsFetcher, + private val accountsFeatureToggles: AccountsFeatureToggles, networkFactory: NetworkFactory, ) : ManageTokensRepository { - private val managedCryptoCurrencyFactory = ManagedCryptoCurrencyFactory(networkFactory, excludedBlockchains) + private val managedCryptoCurrencyFactory = ManagedCryptoCurrencyFactory( + networkFactory = networkFactory, + excludedBlockchains = excludedBlockchains, + accountsFeatureToggles = accountsFeatureToggles, + ) private val userTokensResponseFactory = UserTokensResponseFactory() // region getTokenListBatchFlow @@ -82,7 +89,10 @@ internal class DefaultManageTokensRepository( val userWallet = request.params.userWalletId?.let(userWalletsStore::getSyncStrict) if (userWallet is UserWallet.Cold && userWallet.scanResponse.card.isTestCard) { - fetchTestnetCurrencies(userWallet, request) + when (val params = request.params) { + is ManageTokensListConfig.Account -> fetchTestnetCurrencies(userWallet, params) + is ManageTokensListConfig.Wallet -> fetchTestnetCurrenciesLegacy(userWallet, params) + } } else { fetchCurrencies( userWallet = userWallet, @@ -94,7 +104,6 @@ internal class DefaultManageTokensRepository( }, ) - @Suppress("ComplexCondition") private suspend fun fetchCurrencies( userWallet: UserWallet?, request: Request, @@ -127,31 +136,22 @@ internal class DefaultManageTokensRepository( coins = coinsResponse.coins.filterNot { l2BlockchainsCoinIds.contains(it.id) }, ) - 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) - } - } else { - getSavedUserTokensResponseSync(userWalletId) - } - } - val items = if (isFirstBatchFetching && - tokensResponse != null && - userWallet != null && - query == null - ) { - managedCryptoCurrencyFactory.createWithCustomTokens( - coinsResponse = updatedCoinsResponse, - tokensResponse = tokensResponse, + val items = when (val params = request.params) { + is ManageTokensListConfig.Account -> createManagedCryptoCurrencyList( + params = params, userWallet = userWallet, + isFirstBatchFetching = isFirstBatchFetching, + loadUserTokensFromRemote = loadUserTokensFromRemote, + query = query, + updatedCoinsResponse = updatedCoinsResponse, ) - } else { - managedCryptoCurrencyFactory.create( - coinsResponse = updatedCoinsResponse, - tokensResponse = tokensResponse, + is ManageTokensListConfig.Wallet -> createManagedCryptoCurrencyListLegacy( + params = params, userWallet = userWallet, + isFirstBatchFetching = isFirstBatchFetching, + loadUserTokensFromRemote = loadUserTokensFromRemote, + query = query, + updatedCoinsResponse = updatedCoinsResponse, ) } @@ -162,6 +162,112 @@ internal class DefaultManageTokensRepository( ) } + private suspend fun createManagedCryptoCurrencyList( + params: ManageTokensListConfig.Account, + userWallet: UserWallet?, + isFirstBatchFetching: Boolean, + loadUserTokensFromRemote: Boolean, + query: String?, + updatedCoinsResponse: CoinsResponse, + ): List { + val response = params.userWalletId?.let { userWalletId -> + if (loadUserTokensFromRemote && userWallet != null) { + runCatching { walletAccountsFetcher.fetch(userWalletId = userWallet.walletId) }.getOrNull() + } else { + walletAccountsFetcher.getSaved(userWalletId) + } + } + + val accountId = when { + params.accountId == null -> null + loadUserTokensFromRemote -> { + AccountId.forCryptoPortfolio( + userWalletId = requireNotNull(params.accountId).userWalletId, + derivationIndex = DerivationIndex.Main, + ) + } + else -> requireNotNull(params.accountId) + } + + val accountDTO = if (response != null && accountId != null) { + response.accounts.firstOrNull { it.id == accountId.value } + } else { + null + } + + val tokensResponse = response?.let { + UserTokensResponse( + group = response.wallet.group, + sort = response.wallet.sort, + tokens = accountDTO?.tokens.orEmpty(), + ) + } + + val isCreateWithCustom = isFirstBatchFetching && + tokensResponse != null && + userWallet != null && + query == null + + val items = if (isCreateWithCustom) { + managedCryptoCurrencyFactory.createWithCustomTokens( + coinsResponse = updatedCoinsResponse, + tokensResponse = tokensResponse, + userWallet = userWallet, + accountIndex = accountDTO?.derivationIndex?.let(DerivationIndex::invoke)?.getOrNull(), + ) + } else { + managedCryptoCurrencyFactory.create( + coinsResponse = updatedCoinsResponse, + tokensResponse = tokensResponse, + userWallet = userWallet, + accountIndex = accountDTO?.derivationIndex?.let(DerivationIndex::invoke)?.getOrNull(), + ) + } + + return items + } + + private suspend fun createManagedCryptoCurrencyListLegacy( + params: ManageTokensListConfig.Wallet, + userWallet: UserWallet?, + isFirstBatchFetching: Boolean, + loadUserTokensFromRemote: Boolean, + query: String?, + updatedCoinsResponse: CoinsResponse, + ): List { + val tokensResponse = 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) + } + } else { + getSavedUserTokensResponseSync(userWalletId) + } + } + + val isCreateWithCustom = isFirstBatchFetching && + tokensResponse != null && + userWallet != null && + query == null + + return if (isCreateWithCustom) { + managedCryptoCurrencyFactory.createWithCustomTokens( + coinsResponse = updatedCoinsResponse, + tokensResponse = tokensResponse, + userWallet = userWallet, + accountIndex = null, + ) + } else { + managedCryptoCurrencyFactory.create( + coinsResponse = updatedCoinsResponse, + tokensResponse = tokensResponse, + userWallet = userWallet, + accountIndex = null, + ) + } + } + private suspend fun createAndSaveDefaultUserTokensResponse(userWallet: UserWallet): UserTokensResponse { val userTokensResponse = createDefaultUserTokensResponse(userWallet) userTokenSaver.store(userWallet.walletId, userTokensResponse, useEnricher = false) @@ -170,9 +276,59 @@ internal class DefaultManageTokensRepository( private suspend fun fetchTestnetCurrencies( userWallet: UserWallet, - request: Request, + params: ManageTokensListConfig.Account, ): BatchFetchResult.Success> { - val searchText = request.params.searchText + val searchText = params.searchText + val testnetTokensConfig = testnetTokensStorage.getConfig() + + val response = params.userWalletId?.let { userWalletId -> + walletAccountsFetcher.getSaved(userWalletId) + } + + val accountId = params.accountId + + val accountDTO = if (response != null && accountId != null) { + response.accounts.firstOrNull { it.id == accountId.value } + } else { + null + } + + val tokensResponse = response?.let { + UserTokensResponse( + group = response.wallet.group, + sort = response.wallet.sort, + tokens = accountDTO?.tokens.orEmpty(), + ) + } + + val items = managedCryptoCurrencyFactory.createTestnetWithCustomTokens( + testnetTokensConfig = if (!searchText.isNullOrBlank()) { + testnetTokensConfig.copy( + tokens = testnetTokensConfig.tokens.filter { token -> + token.symbol.contains(other = searchText, ignoreCase = true) || + token.name.contains(other = searchText, ignoreCase = true) + }, + ) + } else { + testnetTokensConfig + }, + tokensResponse = tokensResponse, + userWallet = userWallet, + accountIndex = accountDTO?.derivationIndex?.let(DerivationIndex::invoke)?.getOrNull(), + ) + + return BatchFetchResult.Success( + data = items, + empty = items.isEmpty(), + last = true, + ) + } + + private suspend fun fetchTestnetCurrenciesLegacy( + userWallet: UserWallet, + params: ManageTokensListConfig.Wallet, + ): BatchFetchResult.Success> { + val searchText = params.searchText val testnetTokensConfig = testnetTokensStorage.getConfig() val items = managedCryptoCurrencyFactory.createTestnetWithCustomTokens( @@ -188,6 +344,7 @@ internal class DefaultManageTokensRepository( }, tokensResponse = getSavedUserTokensResponseSync(userWallet.walletId), userWallet = userWallet, + accountIndex = null, ) return BatchFetchResult.Success( 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..97838b82c5 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 @@ -11,6 +12,7 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi 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.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.managetokens.repository.CustomTokensRepository import com.tangem.domain.managetokens.repository.ManageTokensRepository import com.tangem.domain.walletmanager.WalletManagersFacade @@ -38,6 +40,8 @@ internal object ManageTokensDataModule { excludedBlockchains: ExcludedBlockchains, cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, networkFactory: NetworkFactory, + accountsFeatureToggles: AccountsFeatureToggles, + walletAccountsFetcher: WalletAccountsFetcher, ): ManageTokensRepository { return DefaultManageTokensRepository( tangemTechApi = tangemTechApi, @@ -50,6 +54,8 @@ internal object ManageTokensDataModule { cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, networkFactory = networkFactory, dispatchers = dispatchers, + accountsFeatureToggles = accountsFeatureToggles, + walletAccountsFetcher = walletAccountsFetcher, ) } diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManagedCryptoCurrencyFactory.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManagedCryptoCurrencyFactory.kt index d785c4e2b6..5ecaec838c 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManagedCryptoCurrencyFactory.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManagedCryptoCurrencyFactory.kt @@ -13,9 +13,11 @@ import com.tangem.data.common.network.NetworkFactory import com.tangem.datasource.api.tangemTech.models.CoinsResponse import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.config.testnet.models.TestnetTokensConfig +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.card.common.extensions.canHandleToken import com.tangem.domain.managetokens.model.ManagedCryptoCurrency import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet @@ -26,15 +28,17 @@ import timber.log.Timber internal class ManagedCryptoCurrencyFactory( private val networkFactory: NetworkFactory, private val excludedBlockchains: ExcludedBlockchains, + private val accountsFeatureToggles: AccountsFeatureToggles, ) { fun create( coinsResponse: CoinsResponse, tokensResponse: UserTokensResponse?, userWallet: UserWallet?, + accountIndex: DerivationIndex?, ): List { return coinsResponse.coins.mapNotNull { coin -> - createToken(coin, tokensResponse, coinsResponse.imageHost, userWallet) + createToken(coin, tokensResponse, coinsResponse.imageHost, userWallet, accountIndex) } } @@ -42,9 +46,10 @@ internal class ManagedCryptoCurrencyFactory( coinsResponse: CoinsResponse, tokensResponse: UserTokensResponse, userWallet: UserWallet, + accountIndex: DerivationIndex?, ): List { - val customTokens = createCustomTokens(tokensResponse, userWallet) - val tokens = create(coinsResponse, tokensResponse, userWallet) + val customTokens = createCustomTokens(tokensResponse, userWallet, accountIndex) + val tokens = create(coinsResponse, tokensResponse, userWallet, accountIndex) return customTokens + tokens } @@ -53,9 +58,10 @@ internal class ManagedCryptoCurrencyFactory( testnetTokensConfig: TestnetTokensConfig, tokensResponse: UserTokensResponse?, userWallet: UserWallet, + accountIndex: DerivationIndex?, ): List { val customTokens = tokensResponse - ?.let { createCustomTokens(it, userWallet) } + ?.let { createCustomTokens(it, userWallet, accountIndex) } ?: emptyList() val testnetTokens = testnetTokensConfig.tokens.map { testnetToken -> ManagedCryptoCurrency.Token( @@ -69,9 +75,10 @@ internal class ManagedCryptoCurrencyFactory( contractAddress = network.address, decimals = network.decimalCount, userWallet = userWallet, + accountIndex = accountIndex, ) } ?: emptyList(), - addedIn = findAddedInNetworks(testnetToken.id, tokensResponse, userWallet), + addedIn = findAddedInNetworks(testnetToken.id, tokensResponse, userWallet, accountIndex), ) } @@ -81,28 +88,47 @@ internal class ManagedCryptoCurrencyFactory( private fun createCustomTokens( tokensResponse: UserTokensResponse, userWallet: UserWallet, + accountIndex: DerivationIndex?, ): List = tokensResponse.tokens .mapNotNull { token -> - maybeCreateCustomToken(token, userWallet) + maybeCreateCustomToken(token, userWallet, accountIndex) } private fun maybeCreateCustomToken( token: UserTokensResponse.Token, userWallet: UserWallet, + accountIndex: DerivationIndex?, ): ManagedCryptoCurrency? { val blockchain = Blockchain.fromNetworkId(token.networkId) ?.takeUnless { it in excludedBlockchains } ?: return null - if (!checkIsCustomToken(token, blockchain, userWallet.derivationStyleProvider)) { - return null + val network = if (accountsFeatureToggles.isFeatureEnabled) { + val network = networkFactory.create( + blockchain = blockchain, + extraDerivationPath = token.derivationPath, + userWallet = userWallet, + accountIndex = accountIndex, + ) ?: return null + + if (!checkIsCustomToken(token, network.derivationPath)) { + return null + } + + network + } else { + if (!checkIsCustomToken(token, blockchain, userWallet.derivationStyleProvider)) { + return null + } + + networkFactory.create( + blockchain = blockchain, + extraDerivationPath = token.derivationPath, + userWallet = userWallet, + accountIndex = accountIndex, + ) ?: return null } - val network = networkFactory.create( - blockchain = blockchain, - extraDerivationPath = token.derivationPath, - userWallet = userWallet, - ) ?: return null val contractAddress = token.contractAddress return if (contractAddress.isNullOrBlank()) { @@ -135,6 +161,7 @@ internal class ManagedCryptoCurrencyFactory( tokensResponse: UserTokensResponse?, imageHost: String?, userWallet: UserWallet?, + accountIndex: DerivationIndex?, ): ManagedCryptoCurrency? { if (coinResponse.networks.isEmpty() || !coinResponse.active) return null @@ -146,6 +173,7 @@ internal class ManagedCryptoCurrencyFactory( contractAddress = network.contractAddress, decimals = network.decimalCount?.toInt(), userWallet = userWallet, + accountIndex = accountIndex, ) } .ifEmpty { return null } @@ -156,7 +184,7 @@ internal class ManagedCryptoCurrencyFactory( symbol = coinResponse.symbol, iconUrl = getIconUrl(coinResponse.id, imageHost), availableNetworks = availableNetworks, - addedIn = findAddedInNetworks(coinResponse.id, tokensResponse, userWallet), + addedIn = findAddedInNetworks(coinResponse.id, tokensResponse, userWallet, accountIndex), ) } @@ -166,6 +194,7 @@ internal class ManagedCryptoCurrencyFactory( decimals: Int?, userWallet: UserWallet?, extraDerivationPath: String? = null, + accountIndex: DerivationIndex?, ): SourceNetwork? { val blockchain = Blockchain.fromNetworkId(networkId) ?.takeUnless { it in excludedBlockchains } @@ -176,6 +205,7 @@ internal class ManagedCryptoCurrencyFactory( extraDerivationPath = extraDerivationPath, derivationStyleProvider = userWallet?.derivationStyleProvider, canHandleTokens = userWallet?.canHandleToken(blockchain, excludedBlockchains) == true, + accountIndex = accountIndex, ) ?: return null return when { @@ -205,6 +235,7 @@ internal class ManagedCryptoCurrencyFactory( currencyId: String, tokensResponse: UserTokensResponse?, userWallet: UserWallet?, + accountIndex: DerivationIndex?, ): Set { if (tokensResponse == null) return emptySet() @@ -219,6 +250,7 @@ internal class ManagedCryptoCurrencyFactory( extraDerivationPath = token.derivationPath, derivationStyleProvider = userWallet?.derivationStyleProvider, canHandleTokens = userWallet?.canHandleToken(blockchain, excludedBlockchains) != false, + accountIndex = accountIndex, ) } else { null @@ -237,6 +269,10 @@ internal class ManagedCryptoCurrencyFactory( ): Boolean = token.id.isNullOrBlank() || checkIsCustomDerivationPath(token.derivationPath, blockchain, derivationStyleProvider) + private fun checkIsCustomToken(token: UserTokensResponse.Token, derivationPath: Network.DerivationPath): Boolean { + return token.id.isNullOrBlank() || derivationPath is Network.DerivationPath.Custom + } + private fun checkIsCustomDerivationPath( derivationPath: String?, blockchain: Blockchain, diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index d843dc2cca..9dcc66efb0 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt @@ -22,6 +22,7 @@ import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.markets.* import com.tangem.domain.markets.repositories.MarketsTokenRepository +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.pagination.* @@ -250,6 +251,7 @@ internal class DefaultMarketsTokenRepository( userWalletId: UserWalletId, token: TokenMarketParams, network: TokenMarketInfo.Network, + accountIndex: DerivationIndex?, ): CryptoCurrency? { val userWallet = userWalletsStore.getSyncOrNull(userWalletId) ?: error("UserWalletId [$userWalletId] not found") val blockchain = Blockchain.fromNetworkId(network.networkId) ?: error("Unknown network [${network.networkId}]") @@ -259,12 +261,14 @@ internal class DefaultMarketsTokenRepository( blockchain = blockchain, extraDerivationPath = null, userWallet = userWallet, + accountIndex = accountIndex, ) } else { val currencyNetwork = networkFactory.create( blockchain = blockchain, extraDerivationPath = null, userWallet = userWallet, + accountIndex = accountIndex, ) ?: return null cryptoCurrencyFactory.createToken( 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/converters/HotCryptoCurrencyConverter.kt b/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt index 256cc0c439..539de8d55c 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt @@ -75,6 +75,7 @@ internal class HotCryptoCurrencyConverter( } } + // TODO account private fun createNetwork(networkId: String): Network? { val blockchain = Blockchain.fromNetworkId(networkId) ?: return null 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..6e48f04871 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, @@ -322,50 +301,6 @@ internal class DefaultCurrenciesRepository( ) } - override suspend fun getMultiCurrencyWalletCachedCurrenciesSync(userWalletId: UserWalletId) = - withContext(dispatchers.io) { - val userWallet = userWalletsStore.getSyncStrict(userWalletId) - ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true) - - val storedTokens = requireNotNull( - value = getSavedUserTokensResponseSync(key = userWallet.walletId), - lazyMessage = { - "Unable to find tokens response for user wallet with provided ID: $userWalletId" - }, - ) - - responseCryptoCurrenciesFactory.createCurrencies( - storedTokens, - userWallet = userWallet, - ) - } - - override suspend fun getMultiCurrencyWalletCurrency( - userWalletId: UserWalletId, - id: CryptoCurrency.ID, - ): CryptoCurrency = withContext(dispatchers.io) { - getMultiCurrencyWalletCurrency(userWalletId, id.value) - } - - override suspend fun getMultiCurrencyWalletCurrency(userWalletId: UserWalletId, id: String): CryptoCurrency = - withContext(dispatchers.io) { - val userWallet = userWalletsStore.getSyncStrict(userWalletId) - ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true) - - val response = requireNotNull( - value = getSavedUserTokensResponseSync(key = userWalletId), - lazyMessage = { - "Unable to find tokens response for user wallet with provided ID: $userWalletId" - }, - ) - - responseCryptoCurrenciesFactory.createCurrency( - currencyId = id, - response = response, - userWallet = userWallet, - ) - } - override suspend fun getNetworkCoin( userWalletId: UserWalletId, networkId: Network.ID, @@ -667,16 +602,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 +621,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 +633,7 @@ internal class DefaultCurrenciesRepository( skipCache = refresh, block = { coroutineScope { - launch { expressServiceLoader.update(userWallet, tokens) } + launch { expressServiceFetcher.fetch(userWallet, tokens) } } }, ) @@ -731,6 +667,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/models/AccountList.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt index e305566047..80cf0e378a 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt @@ -48,11 +48,11 @@ data class AccountList private constructor( */ operator fun plus(other: Account): Either { val isNewAccount = this.accounts.none { it.accountId == other.accountId } - val accounts = this.accounts.addOrReplace(other) { it.accountId == other.accountId } + val accounts = this.accounts.toList().addOrReplace(other) { it.accountId == other.accountId } return invoke( userWalletId = this.userWalletId, - accounts = accounts, + accounts = accounts.toSet(), totalAccounts = this.totalAccounts + if (isNewAccount) 1 else 0, sortType = this.sortType, groupType = this.groupType, 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/repository/AccountsCRUDRepository.kt b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt index e910bf695e..598771815e 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt @@ -79,18 +79,26 @@ interface AccountsCRUDRepository { suspend fun syncTokens(userWalletId: UserWalletId) /** - * Retrieves the total count of accounts associated with a specific user wallet including archived accounts + * Retrieves the total count of active accounts associated with a specific user wallet excluding archived accounts * * @param userWalletId the unique identifier of the user wallet */ suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option /** - * Provides a flow of the total count of accounts associated with a specific user wallet including archived accounts + * Retrieves the total count of active accounts associated with a specific user wallet excluding archived accounts * * @param userWalletId the unique identifier of the user wallet */ - fun getTotalAccountsCount(userWalletId: UserWalletId): Flow> + suspend fun getTotalActiveAccountsCountSync(userWalletId: UserWalletId): Option + + /** + * Provides a flow of the total count of active accounts associated with a specific user wallet excluding + * archived accounts + * + * @param userWalletId the unique identifier of the user wallet + */ + fun getTotalActiveAccountsCount(userWalletId: UserWalletId): Flow> /** * Retrieves a user wallet by its unique identifier 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/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt index afaa2a587e..697b9e2001 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt @@ -1,7 +1,6 @@ package com.tangem.domain.account.usecase import arrow.core.Either -import arrow.core.getOrElse import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.core.lce.Lce @@ -16,7 +15,6 @@ import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.retryWhen -import kotlinx.coroutines.launch typealias ArchivedAccountList = List @@ -37,32 +35,19 @@ class GetArchivedAccountsUseCase( * @param userWalletId the unique identifier of the user wallet */ operator fun invoke(userWalletId: UserWalletId): LceFlow = channelFlow { - val archivedAccounts = getArchivedAccounts(userWalletId = userWalletId) + send(lceLoading()) - archivedAccounts - .onRight { send(it.lceContent()) } - .onLeft { - send(lceLoading()) - - launch { - fetchArchivedAccounts(userWalletId).getOrElse { - send(it.lceError()) - } - } + fetchArchivedAccounts(userWalletId = userWalletId) + .onRight { + subscribeOnArchivedAccounts(userWalletId) + } + .onLeft { + send(it.lceError()) + return@channelFlow } - - subscribeOnArchivedAccounts(userWalletId) } .distinctUntilChanged() - private suspend fun getArchivedAccounts(userWalletId: UserWalletId): Either { - return Either.catch { - crudRepository.getArchivedAccountListSync(userWalletId = userWalletId).getOrElse { - error("Archived accounts not found for user wallet: $userWalletId") - } - } - } - private suspend fun fetchArchivedAccounts(userWalletId: UserWalletId): Either { return Either.catch { crudRepository.fetchArchivedAccounts(userWalletId) } } diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt index 1a93dc67e5..86aa4adc9c 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCase.kt @@ -45,18 +45,19 @@ class IsAccountsModeEnabledUseCase( // If the wallet does not support multiple currencies, we consider its account count as 0 if (!userWallet.isMultiCurrency) return@map 0 - crudRepository.getTotalAccountsCountSync(userWalletId = userWallet.walletId).getOrZero() + crudRepository.getTotalActiveAccountsCountSync(userWalletId = userWallet.walletId).getOrZero() } .isModeEnabled() } + @Suppress("UnusedFlow") private fun getTotalAccountsCountList(userWallets: List): List> { return userWallets .map { userWallet -> // If the wallet does not support multiple currencies, we consider its account count as 0 if (!userWallet.isMultiCurrency) return@map flowOf(0) - crudRepository.getTotalAccountsCount(userWalletId = userWallet.walletId) + crudRepository.getTotalActiveAccountsCount(userWalletId = userWallet.walletId) .map { maybeCount -> maybeCount.getOrZero() } } } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt index c469708e18..09fa80ad9b 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt @@ -31,7 +31,7 @@ class GetUnoccupiedAccountIndexUseCaseTest { @Test fun `invoke should return error if repository returns 0`() = runTest { // Arrange - coEvery { crudRepository.getTotalAccountsCountSync(userWalletId) } returns 0.toOption() + coEvery { crudRepository.getTotalActiveAccountsCountSync(userWalletId) } returns 0.toOption() // Act val actual = useCase(userWalletId = userWalletId).leftOrNull() as Error.DataOperationFailed @@ -42,13 +42,13 @@ class GetUnoccupiedAccountIndexUseCaseTest { Truth.assertThat(actual.cause).isInstanceOf(expected::class.java) Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.message) - coVerify { crudRepository.getTotalAccountsCountSync(userWalletId) } + coVerify { crudRepository.getTotalActiveAccountsCountSync(userWalletId) } } @Test fun `invoke should return next unoccupied index when repository returns count`() = runTest { // Arrange - coEvery { crudRepository.getTotalAccountsCountSync(userWalletId) } returns 3.toOption() + coEvery { crudRepository.getTotalActiveAccountsCountSync(userWalletId) } returns 3.toOption() // Act val actual = useCase(userWalletId = userWalletId) @@ -57,14 +57,14 @@ class GetUnoccupiedAccountIndexUseCaseTest { val expected = DerivationIndex(3) Truth.assertThat(actual).isEqualTo(expected) - coVerify { crudRepository.getTotalAccountsCountSync(userWalletId) } + coVerify { crudRepository.getTotalActiveAccountsCountSync(userWalletId) } } @Test fun `invoke should return error if repository throws exception`() = runTest { // Arrange val exception = IllegalStateException("Test error") - coEvery { crudRepository.getTotalAccountsCountSync(userWalletId) } throws exception + coEvery { crudRepository.getTotalActiveAccountsCountSync(userWalletId) } throws exception // Act val actual = useCase(userWalletId = userWalletId) @@ -73,6 +73,6 @@ class GetUnoccupiedAccountIndexUseCaseTest { val expected = Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerify { crudRepository.getTotalAccountsCountSync(userWalletId) } + coVerify { crudRepository.getTotalActiveAccountsCountSync(userWalletId) } } } \ No newline at end of file diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt index 06b7c267d0..e5d8cfedc1 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/IsAccountsModeEnabledUseCaseTest.kt @@ -19,6 +19,7 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance +@Suppress("UnusedFlow") @TestInstance(TestInstance.Lifecycle.PER_CLASS) class IsAccountsModeEnabledUseCaseTest { @@ -68,7 +69,7 @@ class IsAccountsModeEnabledUseCaseTest { accountsCRUDRepository.getUserWallets() } - verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(any()) } + verify(inverse = true) { accountsCRUDRepository.getTotalActiveAccountsCount(any()) } } @Test @@ -90,7 +91,7 @@ class IsAccountsModeEnabledUseCaseTest { accountsCRUDRepository.getUserWallets() } - verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(any()) } + verify(inverse = true) { accountsCRUDRepository.getTotalActiveAccountsCount(any()) } } @Test @@ -100,7 +101,7 @@ class IsAccountsModeEnabledUseCaseTest { every { featureToggles.isFeatureEnabled } returns true every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet)) - every { accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) } returns flowOf(2.some()) + every { accountsCRUDRepository.getTotalActiveAccountsCount(wallet.walletId) } returns flowOf(2.some()) // Act val actual = useCase.invoke().first() @@ -111,7 +112,7 @@ class IsAccountsModeEnabledUseCaseTest { verifyOrder { featureToggles.isFeatureEnabled accountsCRUDRepository.getUserWallets() - accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) + accountsCRUDRepository.getTotalActiveAccountsCount(wallet.walletId) } } @@ -122,7 +123,7 @@ class IsAccountsModeEnabledUseCaseTest { every { featureToggles.isFeatureEnabled } returns true every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet)) - every { accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) } returns flowOf(none()) + every { accountsCRUDRepository.getTotalActiveAccountsCount(wallet.walletId) } returns flowOf(none()) // Act val actual = useCase.invoke().first() @@ -133,7 +134,7 @@ class IsAccountsModeEnabledUseCaseTest { verifyOrder { featureToggles.isFeatureEnabled accountsCRUDRepository.getUserWallets() - accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) + accountsCRUDRepository.getTotalActiveAccountsCount(wallet.walletId) } } @@ -145,7 +146,7 @@ class IsAccountsModeEnabledUseCaseTest { every { featureToggles.isFeatureEnabled } returns true every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet1, wallet2)) - every { accountsCRUDRepository.getTotalAccountsCount(wallet2.walletId) } returns flowOf(2.some()) + every { accountsCRUDRepository.getTotalActiveAccountsCount(wallet2.walletId) } returns flowOf(2.some()) // Act val actual = useCase.invoke().first() @@ -156,10 +157,10 @@ class IsAccountsModeEnabledUseCaseTest { verifyOrder { featureToggles.isFeatureEnabled accountsCRUDRepository.getUserWallets() - accountsCRUDRepository.getTotalAccountsCount(wallet2.walletId) + accountsCRUDRepository.getTotalActiveAccountsCount(wallet2.walletId) } - verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(wallet1.walletId) } + verify(inverse = true) { accountsCRUDRepository.getTotalActiveAccountsCount(wallet1.walletId) } } } @@ -199,7 +200,7 @@ class IsAccountsModeEnabledUseCaseTest { accountsCRUDRepository.getUserWalletsSync() } - coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(any()) } + coVerify(inverse = true) { accountsCRUDRepository.getTotalActiveAccountsCountSync(any()) } } @Test @@ -221,7 +222,7 @@ class IsAccountsModeEnabledUseCaseTest { accountsCRUDRepository.getUserWalletsSync() } - coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(any()) } + coVerify(inverse = true) { accountsCRUDRepository.getTotalActiveAccountsCountSync(any()) } } @Test @@ -231,7 +232,7 @@ class IsAccountsModeEnabledUseCaseTest { every { featureToggles.isFeatureEnabled } returns true every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet) - coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) } returns 2.some() + coEvery { accountsCRUDRepository.getTotalActiveAccountsCountSync(wallet.walletId) } returns 2.some() // Act val actual = useCase.invokeSync() @@ -242,7 +243,7 @@ class IsAccountsModeEnabledUseCaseTest { coVerifyOrder { featureToggles.isFeatureEnabled accountsCRUDRepository.getUserWalletsSync() - accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) + accountsCRUDRepository.getTotalActiveAccountsCountSync(wallet.walletId) } } @@ -253,7 +254,7 @@ class IsAccountsModeEnabledUseCaseTest { every { featureToggles.isFeatureEnabled } returns true every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet) - coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) } returns none() + coEvery { accountsCRUDRepository.getTotalActiveAccountsCountSync(wallet.walletId) } returns none() // Act val actual = useCase.invokeSync() @@ -264,7 +265,7 @@ class IsAccountsModeEnabledUseCaseTest { coVerifyOrder { featureToggles.isFeatureEnabled accountsCRUDRepository.getUserWalletsSync() - accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) + accountsCRUDRepository.getTotalActiveAccountsCountSync(wallet.walletId) } } @@ -276,7 +277,7 @@ class IsAccountsModeEnabledUseCaseTest { every { featureToggles.isFeatureEnabled } returns true every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet1, wallet2) - coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet2.walletId) } returns 2.some() + coEvery { accountsCRUDRepository.getTotalActiveAccountsCountSync(wallet2.walletId) } returns 2.some() // Act val actual = useCase.invokeSync() @@ -287,10 +288,10 @@ class IsAccountsModeEnabledUseCaseTest { coVerifyOrder { featureToggles.isFeatureEnabled accountsCRUDRepository.getUserWalletsSync() - accountsCRUDRepository.getTotalAccountsCountSync(wallet2.walletId) + accountsCRUDRepository.getTotalActiveAccountsCountSync(wallet2.walletId) } - coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(wallet1.walletId) } + coVerify(inverse = true) { accountsCRUDRepository.getTotalActiveAccountsCountSync(wallet1.walletId) } } } 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..b11098e47f 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,12 @@ 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.status.utils.CryptoCurrencyBalanceFetcher 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 +17,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 +25,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 +47,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( @@ -51,31 +71,50 @@ internal object AccountStatusUseCaseModule { @Provides @Singleton - fun provideSaveCryptoCurrenciesUseCase( + fun provideManageCryptoCurrenciesUseCase( singleAccountListSupplier: SingleAccountListSupplier, accountsCRUDRepository: AccountsCRUDRepository, currenciesRepository: CurrenciesRepository, derivationsRepository: DerivationsRepository, - multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - multiQuoteStatusFetcher: MultiQuoteStatusFetcher, - multiYieldBalanceFetcher: MultiYieldBalanceFetcher, + cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, stakingIdFactory: StakingIdFactory, networksCleaner: NetworksCleaner, stakingCleaner: StakingCleaner, + expressServiceFetcher: ExpressServiceFetcher, dispatchers: CoroutineDispatcherProvider, - ): SaveCryptoCurrenciesUseCase { - return SaveCryptoCurrenciesUseCase( + ): ManageCryptoCurrenciesUseCase { + return ManageCryptoCurrenciesUseCase( singleAccountListSupplier = singleAccountListSupplier, accountsCRUDRepository = accountsCRUDRepository, currenciesRepository = currenciesRepository, derivationsRepository = derivationsRepository, + cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, + stakingIdFactory = stakingIdFactory, + networksCleaner = networksCleaner, + stakingCleaner = stakingCleaner, + expressServiceFetcher = expressServiceFetcher, + parallelUpdatingScope = CoroutineScope(SupervisorJob() + dispatchers.default), + dispatchers = dispatchers, + ) + } + + @Provides + @Singleton + fun provideCryptoCurrencyBalanceFetcher( + accountsCRUDRepository: AccountsCRUDRepository, + multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteStatusFetcher: MultiQuoteStatusFetcher, + multiYieldBalanceFetcher: MultiYieldBalanceFetcher, + stakingIdFactory: StakingIdFactory, + dispatchers: CoroutineDispatcherProvider, + ): CryptoCurrencyBalanceFetcher { + return CryptoCurrencyBalanceFetcher( + accountsCRUDRepository = accountsCRUDRepository, multiNetworkStatusFetcher = multiNetworkStatusFetcher, multiQuoteStatusFetcher = multiQuoteStatusFetcher, multiYieldBalanceFetcher = multiYieldBalanceFetcher, stakingIdFactory = stakingIdFactory, - networksCleaner = networksCleaner, - stakingCleaner = stakingCleaner, - dispatchers = dispatchers, + parallelUpdatingScope = CoroutineScope(SupervisorJob() + dispatchers.default), ) } } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt index 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 77% 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..d37b6ac322 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 @@ -5,18 +5,18 @@ import arrow.core.raise.Raise import arrow.core.raise.catch import com.tangem.domain.account.producer.SingleAccountListProducer import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher 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 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.networks.utils.NetworksCleaner -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.repository.CurrenciesRepository import com.tangem.domain.wallets.derivations.DerivationsRepository @@ -28,30 +28,31 @@ import timber.log.Timber * Use case for saving crypto currencies to a specific account. * * @property singleAccountListSupplier Supplier to get account details. + * @property accountsCRUDRepository Repository for performing CRUD operations on accounts. * @property currenciesRepository Repository for managing currencies. * @property derivationsRepository Repository for deriving public keys. - * @property multiNetworkStatusFetcher Fetcher for updating network statuses. - * @property multiQuoteStatusFetcher Fetcher for updating quote statuses. - * @property multiYieldBalanceFetcher Fetcher for updating yield balances. + * @property cryptoCurrencyBalanceFetcher Fetcher for updating crypto currency balances. * @property stakingIdFactory Factory for creating staking IDs. * @property networksCleaner Cleaner for removing obsolete network data. * @property stakingCleaner Cleaner for removing obsolete staking data. + * @property expressServiceFetcher Fetcher for updating express service data. + * @property parallelUpdatingScope Coroutine scope for parallel updates. * @property dispatchers Coroutine dispatchers for managing threading. * [REDACTED_AUTHOR] */ @Suppress("LongParameterList") -class SaveCryptoCurrenciesUseCase( +class ManageCryptoCurrenciesUseCase( private val singleAccountListSupplier: SingleAccountListSupplier, private val accountsCRUDRepository: AccountsCRUDRepository, private val currenciesRepository: CurrenciesRepository, private val derivationsRepository: DerivationsRepository, - private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, - private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, - private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, 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 +86,19 @@ class SaveCryptoCurrenciesUseCase( derivePublicKeys(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) - val jobs = refreshBalances(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) + - clearMetadata(userWalletId = userWalletId, currencies = modifiedCurrencyList.removed) + parallelUpdatingScope.launch { + /* + * If only removal of currencies happened, we need to sync tokens. Otherwise, tokens will be synced + * when balances are refreshed for added currencies. + */ + if (modifiedCurrencyList.added.isEmpty() && modifiedCurrencyList.removed.isNotEmpty()) { + launch { accountsCRUDRepository.syncTokens(userWalletId) } + } - jobs.joinAll() + cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) + refreshExpress(userWalletId = userWalletId, currencies = modifiedCurrencyList.total) + clearMetadata(userWalletId = userWalletId, currencies = modifiedCurrencyList.removed) + } } } @@ -114,7 +124,14 @@ class SaveCryptoCurrenciesUseCase( val tokenToAdd = findToken(userWalletId, contractAddress, networkId) - refreshBalances(userWalletId = userWalletId, currencies = listOf(tokenToAdd)).joinAll() + val modifiedCurrencyList = account.cryptoCurrencies.modify(add = listOf(tokenToAdd)) + + saveAccount(account = account.copy(cryptoCurrencies = modifiedCurrencyList.total.toSet())) + + parallelUpdatingScope.launch { + cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currencies = listOf(tokenToAdd)) + refreshExpress(userWalletId = userWalletId, currencies = modifiedCurrencyList.total) + } tokenToAdd } @@ -131,7 +148,7 @@ class SaveCryptoCurrenciesUseCase( private fun Set.modify( add: List, - remove: List, + remove: List = emptyList(), ): ModifiedCurrencyList { val mutableCurrencies = this.toMutableList() val added = mutableListOf() @@ -164,9 +181,8 @@ class SaveCryptoCurrenciesUseCase( remove.groupByNetwork(valuePredicate = existingCurrenciesById::containsKey) .forEach { (network, currenciesById) -> - val coinTempId = TempID(network) - - if (currenciesById.containsKey(coinTempId)) { + val isCoinBeingRemoved = currenciesById.any { it.value is CryptoCurrency.Coin } + if (isCoinBeingRemoved) { val existingNetworkCurrenciesCount = mutableCurrencies.count { it.network == network } if (existingNetworkCurrenciesCount != currenciesById.size) { @@ -234,52 +250,27 @@ class SaveCryptoCurrenciesUseCase( ) } - private suspend fun refreshBalances(userWalletId: UserWalletId, currencies: List): List { - if (currencies.isEmpty()) return emptyList() + private suspend fun refreshExpress(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 { + 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 refreshNetworks(userWalletId: UserWalletId, currencies: List) { - multiNetworkStatusFetcher( - params = MultiNetworkStatusFetcher.Params( - userWalletId = userWalletId, - networks = currencies.mapTo(hashSetOf(), CryptoCurrency::network), - ), - ) + private suspend fun clearMetadata(userWalletId: UserWalletId, currencies: List) { + if (currencies.isEmpty()) return - accountsCRUDRepository.syncTokens(userWalletId) - } - - private suspend fun refreshYieldBalances(userWalletId: UserWalletId, currencies: List) { - val stakingIds = currencies.mapNotNullTo(hashSetOf()) { - stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() - } - - multiYieldBalanceFetcher( - params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = stakingIds), - ) - } - - private suspend fun refreshQuotes(currencies: List) { - multiQuoteStatusFetcher( - params = MultiQuoteStatusFetcher.Params( - currenciesIds = currencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, - appCurrencyId = null, - ), - ) - } - - private suspend fun clearMetadata(userWalletId: UserWalletId, currencies: List): List { - if (currencies.isEmpty()) return emptyList() - - return coroutineScope { + coroutineScope { listOf( launch { networksCleaner(userWalletId = userWalletId, currencies = currencies) }, launch { clearStaking(userWalletId = userWalletId, currencies = currencies) }, diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/RecoverCryptoPortfolioUseCase.kt similarity index 82% rename from domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt rename to domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/RecoverCryptoPortfolioUseCase.kt index 8e52598bb2..feebdbedd1 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/RecoverCryptoPortfolioUseCase.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.account.usecase +package com.tangem.domain.account.status.usecase import arrow.core.Either import arrow.core.getOrElse @@ -9,6 +9,7 @@ import arrow.core.raise.ensure import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.account.tokens.MainAccountTokensMigration import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId @@ -25,6 +26,7 @@ import com.tangem.domain.models.wallet.UserWalletId class RecoverCryptoPortfolioUseCase( private val crudRepository: AccountsCRUDRepository, private val mainAccountTokensMigration: MainAccountTokensMigration, + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, ) { /** @@ -53,6 +55,8 @@ class RecoverCryptoPortfolioUseCase( derivationIndex = recoveredAccount.derivationIndex, ) + refreshBalances(accountId = accountId) + recoveredAccount } @@ -93,6 +97,23 @@ class RecoverCryptoPortfolioUseCase( ) } + private suspend fun refreshBalances(accountId: AccountId): Either = either { + val currencies = catch( + block = { crudRepository.getAccountSync(accountId = accountId) }, + catch = { raise(Error.DataOperationFailed(cause = it)) }, + ) + .getOrElse { raise(Error.DataOperationFailed(message = "Account not found: $accountId")) } + .cryptoCurrencies + .toList() + + catch( + block = { + cryptoCurrencyBalanceFetcher(userWalletId = accountId.userWalletId, currencies = currencies) + }, + catch = { raise(Error.DataOperationFailed(cause = it)) }, + ) + } + /** * Represents possible errors that can occur during the add operation */ diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyBalanceFetcher.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyBalanceFetcher.kt new file mode 100644 index 0000000000..adde054406 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyBalanceFetcher.kt @@ -0,0 +1,124 @@ +package com.tangem.domain.account.status.utils + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.models.currency.CryptoCurrency +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.wallet.FetchingSource +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber + +/** + * Utility class responsible for fetching and refreshing the balances of various crypto currencies + * associated with a user's wallet. + * + * @property accountsCRUDRepository Repository for managing account data. + * @property multiNetworkStatusFetcher Fetcher for updating network statuses. + * @property multiQuoteStatusFetcher Fetcher for updating quote statuses. + * @property multiYieldBalanceFetcher Fetcher for updating yield balances. + * @property stakingIdFactory Factory for creating staking IDs. + * @property parallelUpdatingScope Coroutine scope for parallel balance updates. + * +[REDACTED_AUTHOR] + */ +class CryptoCurrencyBalanceFetcher( + private val accountsCRUDRepository: AccountsCRUDRepository, + private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, + private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, + private val stakingIdFactory: StakingIdFactory, + private val parallelUpdatingScope: CoroutineScope, +) { + + private val mutex = Mutex() + + operator fun invoke(userWalletId: UserWalletId, currencies: List) { + if (currencies.isEmpty()) return + + parallelUpdatingScope.launch { + mutex.withLock { + refreshBalances(userWalletId, currencies) + } + } + } + + private suspend fun refreshBalances(userWalletId: UserWalletId, currencies: List) { + coroutineScope { + val results = listOf( + async { + FetchingSource.NETWORK to refreshNetworks(userWalletId = userWalletId, currencies = currencies) + }, + async { + FetchingSource.STAKING to refreshYieldBalances(userWalletId = userWalletId, currencies = currencies) + }, + async { FetchingSource.QUOTE to refreshQuotes(currencies = currencies) }, + ) + .awaitAll() + + val errors = results.mapNotNull { (source, maybeResult) -> + val error = maybeResult.leftOrNull() ?: return@mapNotNull null + + source to error + } + + check(errors.isEmpty()) { + val message = "Failed to fetch next sources for $userWalletId:\n" + + errors.joinToString(separator = "\n") { "${it.first.name} – ${it.second}" } + + Timber.e(message) + + message + } + } + } + + private suspend fun refreshNetworks( + userWalletId: UserWalletId, + currencies: List, + ): Either = either { + val either = multiNetworkStatusFetcher( + params = MultiNetworkStatusFetcher.Params( + userWalletId = userWalletId, + networks = currencies.mapTo(hashSetOf(), CryptoCurrency::network), + ), + ) + + arrow.core.raise.catch( + block = { accountsCRUDRepository.syncTokens(userWalletId) }, + catch = { + Timber.e(it, "Failed to sync tokens for wallet: $userWalletId") + }, + ) + + return either + } + + private suspend fun refreshYieldBalances( + userWalletId: UserWalletId, + currencies: List, + ): Either { + val stakingIds = currencies.mapNotNullTo(hashSetOf()) { + stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() + } + + return multiYieldBalanceFetcher( + params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = stakingIds), + ) + } + + private suspend fun refreshQuotes(currencies: List): Either { + return multiQuoteStatusFetcher( + params = MultiQuoteStatusFetcher.Params( + currenciesIds = currencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, + appCurrencyId = null, + ), + ) + } +} \ No newline at end of file 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/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/RecoverCryptoPortfolioUseCaseTest.kt similarity index 75% rename from domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt rename to domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/RecoverCryptoPortfolioUseCaseTest.kt index 8a16fd034c..f6fe05e41d 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/RecoverCryptoPortfolioUseCaseTest.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.account.usecase +package com.tangem.domain.account.status.usecase import arrow.core.None import arrow.core.left @@ -8,17 +8,17 @@ import com.google.common.truth.Truth import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.status.usecase.RecoverCryptoPortfolioUseCase.Error.DataOperationFailed +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.account.tokens.MainAccountTokensMigration -import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase.Error -import com.tangem.domain.account.utils.createAccount -import com.tangem.domain.models.account.AccountId -import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.account.* import com.tangem.domain.models.wallet.UserWalletId import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance +import kotlin.random.Random /** [REDACTED_AUTHOR] @@ -28,14 +28,16 @@ class RecoverCryptoPortfolioUseCaseTest { private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true) private val mainAccountTokensMigration: MainAccountTokensMigration = mockk() + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher = mockk(relaxUnitFun = true) private val useCase = RecoverCryptoPortfolioUseCase( crudRepository = crudRepository, mainAccountTokensMigration = mainAccountTokensMigration, + cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, ) @BeforeEach fun resetMocks() { - clearMocks(crudRepository) + clearMocks(crudRepository, mainAccountTokensMigration, cryptoCurrencyBalanceFetcher) } @Test @@ -57,6 +59,7 @@ class RecoverCryptoPortfolioUseCaseTest { coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns archivedAccount.toOption() coEvery { mainAccountTokensMigration.migrate(userWalletId, account.derivationIndex) } returns Unit.right() + coEvery { crudRepository.getAccountSync(account.accountId) } returns account.toOption() // Act val actual = useCase(account.accountId) @@ -69,21 +72,23 @@ class RecoverCryptoPortfolioUseCaseTest { crudRepository.getAccountListSync(userWalletId) crudRepository.getArchivedAccountSync(account.accountId) crudRepository.saveAccounts(updatedAccountList) + crudRepository.getAccountSync(account.accountId) + cryptoCurrencyBalanceFetcher(userWalletId, account.cryptoCurrencies.toList()) } } @Test fun `invoke should return error if getAccounts returns None`() = runTest { // Arrange - val accountId = AccountId.forCryptoPortfolio( + val accountId = AccountId.Companion.forCryptoPortfolio( userWalletId = userWalletId, - derivationIndex = DerivationIndex.Main, + derivationIndex = DerivationIndex.Companion.Main, ) coEvery { crudRepository.getAccountListSync(userWalletId) } returns None // Act - val actual = useCase(accountId).leftOrNull() as Error.DataOperationFailed + val actual = useCase(accountId).leftOrNull() as DataOperationFailed // Assert val expected = IllegalStateException("Account list not found for wallet $userWalletId") @@ -100,9 +105,9 @@ class RecoverCryptoPortfolioUseCaseTest { @Test fun `invoke should return error if getAccounts throws exception`() = runTest { // Arrange - val accountId = AccountId.forCryptoPortfolio( + val accountId = AccountId.Companion.forCryptoPortfolio( userWalletId = userWalletId, - derivationIndex = DerivationIndex.Main, + derivationIndex = DerivationIndex.Companion.Main, ) val exception = IllegalStateException("Test error") @@ -112,7 +117,7 @@ class RecoverCryptoPortfolioUseCaseTest { val actual = useCase(accountId) // Assert - val expected = Error.DataOperationFailed(exception).left() + val expected = DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) coVerifySequence { crudRepository.getAccountListSync(userWalletId) } @@ -126,7 +131,7 @@ class RecoverCryptoPortfolioUseCaseTest { fun `invoke should return error if getArchivedAccount throws exception`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = AccountList.empty(userWalletId) + val accountList = AccountList.Companion.empty(userWalletId) val exception = IllegalStateException("Test error") coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() @@ -136,7 +141,7 @@ class RecoverCryptoPortfolioUseCaseTest { val actual = useCase(account.accountId) // Assert - val expected = Error.DataOperationFailed(exception).left() + val expected = DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) coVerifySequence { @@ -150,13 +155,13 @@ class RecoverCryptoPortfolioUseCaseTest { fun `invoke should return error if getArchivedAccount returns null`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = AccountList.empty(userWalletId) + val accountList = AccountList.Companion.empty(userWalletId) coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns None // Act - val actual = useCase(account.accountId).leftOrNull() as Error.DataOperationFailed + val actual = useCase(account.accountId).leftOrNull() as DataOperationFailed // Assert val expected = IllegalStateException("Account not found: ${account.accountId}") @@ -174,7 +179,7 @@ class RecoverCryptoPortfolioUseCaseTest { fun `invoke should return error if saveAccounts throws exception`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = AccountList.empty(userWalletId) + val accountList = AccountList.Companion.empty(userWalletId) val archivedAccount = ArchivedAccount( accountId = account.accountId, name = account.accountName, @@ -195,7 +200,7 @@ class RecoverCryptoPortfolioUseCaseTest { val actual = useCase(account.accountId) // Assert - val expected = Error.DataOperationFailed(exception).left() + val expected = DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) coVerifySequence { @@ -205,6 +210,23 @@ class RecoverCryptoPortfolioUseCaseTest { } } + private fun createAccount( + userWalletId: UserWalletId, + name: String = "Test Account", + icon: CryptoPortfolioIcon = CryptoPortfolioIcon.ofDefaultCustomAccount(), + derivationIndex: Int = Random.nextInt(1, 21), + ): Account.CryptoPortfolio { + val derivationIndex = DerivationIndex(derivationIndex).getOrNull()!! + + return Account.CryptoPortfolio( + accountId = AccountId.forCryptoPortfolio(userWalletId = userWalletId, derivationIndex = derivationIndex), + accountName = AccountName(name).getOrNull()!!, + icon = icon, + derivationIndex = derivationIndex, + cryptoCurrencies = emptySet(), + ) + } + private companion object { val userWalletId = UserWalletId("011") } 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/GetTokenMarketCryptoCurrency.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketCryptoCurrency.kt index 1cc4d6c0b8..224de4e906 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketCryptoCurrency.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketCryptoCurrency.kt @@ -1,6 +1,7 @@ package com.tangem.domain.markets import com.tangem.domain.markets.repositories.MarketsTokenRepository +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId @@ -11,11 +12,13 @@ class GetTokenMarketCryptoCurrency( userWalletId: UserWalletId, tokenMarketParams: TokenMarketParams, network: TokenMarketInfo.Network, + accountIndex: DerivationIndex? = null, ): CryptoCurrency? { return marketsTokenRepository.createCryptoCurrency( userWalletId = userWalletId, token = tokenMarketParams, network = network, + accountIndex = accountIndex, ) } } \ No newline at end of file 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/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt index cc7c2a67f4..d813fdcc17 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt @@ -1,6 +1,7 @@ package com.tangem.domain.markets.repositories import com.tangem.domain.markets.* +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.Flow @@ -45,6 +46,7 @@ interface MarketsTokenRepository { userWalletId: UserWalletId, token: TokenMarketParams, network: TokenMarketInfo.Network, + accountIndex: DerivationIndex? = null, ): CryptoCurrency? /** 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/account/AccountId.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt index 3133206dd3..bffd56a6c4 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt @@ -40,6 +40,10 @@ data class AccountId private constructor( private val sha256Digest: MessageDigest by lazy { MessageDigest.getInstance("SHA-256") } private val hexRegex = Regex("^[a-fA-F0-9]{64}$") + fun forMainCryptoPortfolio(userWalletId: UserWalletId): AccountId { + return forCryptoPortfolio(userWalletId = userWalletId, derivationIndex = DerivationIndex.Main) + } + /** * Creates a unique account identifier for a crypto portfolio * diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt index a955b53e54..b5a972d24c 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt @@ -95,6 +95,14 @@ data class Network( data object None : DerivationPath() { override val value: String? get() = null } + + fun copySealed(value: String): DerivationPath { + return when (this) { + is Card -> Card(value) + is Custom -> Custom(value) + None -> None + } + } } /** 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..d582816529 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) /** @@ -155,39 +149,6 @@ interface CurrenciesRepository { refresh: Boolean = false, ): List - /** - * Retrieves the list of cryptocurrencies within a multi-currency wallet. - * Returns previously loaded currencies or empty list - * - * @param userWalletId The unique identifier of the user wallet. - * @return A list of [CryptoCurrency]. - * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet - * ID provided. - */ - suspend fun getMultiCurrencyWalletCachedCurrenciesSync(userWalletId: UserWalletId): List - - /** - * Retrieves the cryptocurrency for a specific multi-currency user wallet. - * - * @param userWalletId The unique identifier of the user wallet. - * @param id The unique identifier of the cryptocurrency to be retrieved. - * @return The cryptocurrency associated with the user wallet and ID. - * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet - * ID provided. - */ - suspend fun getMultiCurrencyWalletCurrency(userWalletId: UserWalletId, id: CryptoCurrency.ID): CryptoCurrency - - /** - * Retrieves the cryptocurrency for a specific multi-currency user wallet. - * - * @param userWalletId The unique identifier of the user wallet. - * @param id The unique identifier of the cryptocurrency to be retrieved. - * @return The cryptocurrency associated with the user wallet and ID. - * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet - * ID provided. - */ - suspend fun getMultiCurrencyWalletCurrency(userWalletId: UserWalletId, id: String): CryptoCurrency - /** * Get the coin for a specific network. * diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/FetchingSource.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/FetchingSource.kt index efa9097358..a30ede50c9 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/FetchingSource.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/FetchingSource.kt @@ -5,7 +5,7 @@ package com.tangem.domain.tokens.wallet * [REDACTED_AUTHOR] */ -internal enum class FetchingSource { +enum class FetchingSource { NETWORK, QUOTE, STAKING, 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..23cb1211cb 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, @@ -70,10 +68,6 @@ internal class MockCurrenciesRepository( return tokens.first().getOrElse { e -> throw e } } - override suspend fun getMultiCurrencyWalletCachedCurrenciesSync(userWalletId: UserWalletId): List { - return tokens.first().getOrElse { e -> throw e } - } - override suspend fun getSingleCurrencyWalletPrimaryCurrency( userWalletId: UserWalletId, refresh: Boolean, @@ -99,25 +93,6 @@ internal class MockCurrenciesRepository( return tokens.map { it.getOrElse { e -> throw e } } } - override suspend fun getMultiCurrencyWalletCurrency( - userWalletId: UserWalletId, - id: CryptoCurrency.ID, - ): CryptoCurrency { - val token = token.getOrElse { e -> throw e } - - require(token.id == id) - - return token - } - - override suspend fun getMultiCurrencyWalletCurrency(userWalletId: UserWalletId, id: String): CryptoCurrency { - val token = token.getOrElse { e -> throw e } - - require(token.id.value == id) - - return token - } - override suspend fun getNetworkCoin( userWalletId: UserWalletId, networkId: Network.ID, 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/api/src/main/java/com/tangem/features/account/PortfolioFetcher.kt b/features/account/api/src/main/java/com/tangem/features/account/PortfolioFetcher.kt index f09cafe966..04f8f337a4 100644 --- a/features/account/api/src/main/java/com/tangem/features/account/PortfolioFetcher.kt +++ b/features/account/api/src/main/java/com/tangem/features/account/PortfolioFetcher.kt @@ -22,7 +22,12 @@ interface PortfolioFetcher { val appCurrency: AppCurrency, val isBalanceHidden: Boolean, val balances: Map, - ) + ) { + + val isSingleChoice: Boolean = balances.values + .map { it.accountsBalance.accountStatuses } + .flatten().size == 1 + } data class PortfolioBalance( val userWallet: UserWallet, diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt index 76c6acb6da..6ead9dcbc6 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt @@ -1,6 +1,5 @@ package com.tangem.features.account.archived -import com.tangem.common.ui.account.toUM import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.core.decompose.model.Model @@ -15,21 +14,20 @@ import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.ToastMessage import com.tangem.core.ui.utils.showErrorDialog import com.tangem.domain.account.models.AccountList -import com.tangem.domain.account.models.ArchivedAccount -import com.tangem.domain.account.usecase.ArchivedAccountList +import com.tangem.domain.account.status.usecase.RecoverCryptoPortfolioUseCase import com.tangem.domain.account.usecase.GetArchivedAccountsUseCase -import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase -import com.tangem.domain.core.lce.Lce import com.tangem.domain.models.account.AccountId import com.tangem.features.account.ArchivedAccountListComponent import com.tangem.features.account.archived.entity.AccountArchivedUM import com.tangem.features.account.archived.entity.AccountArchivedUMBuilder +import com.tangem.features.account.archived.entity.AccountArchivedUMBuilder.Companion.toggleProgress import com.tangem.features.account.createedit.error.AccountFeatureError import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject @@ -61,25 +59,32 @@ internal class ArchivedAccountListModel @Inject constructor( .conflate() .distinctUntilChanged() .onEach { lce -> - val newState = when (lce) { - is Lce.Content -> umBuilder.mapContent( - accounts = lce.content, - onCloseClick = onCloseClick, - confirmRecoverDialog = { confirmRecoverDialog(it) }, - ) - is Lce.Error -> umBuilder.mapError( - throwable = lce.error, - onCloseClick = onCloseClick, - getArchivedAccounts = { getArchivedAccounts() }, - ) - is Lce.Loading -> lce.partialContent?.let { content -> + val newState = lce.fold( + ifLoading = { content -> + content ?: return@fold null + umBuilder.mapContent( accounts = content, onCloseClick = onCloseClick, - confirmRecoverDialog = { confirmRecoverDialog(it) }, + onRecoverClick = { recoverCryptoPortfolio(accountId = it.accountId) }, ) - } - } + }, + ifContent = { content -> + umBuilder.mapContent( + accounts = content, + onCloseClick = onCloseClick, + onRecoverClick = { recoverCryptoPortfolio(accountId = it.accountId) }, + ) + }, + ifError = { error -> + umBuilder.mapError( + throwable = error, + onCloseClick = onCloseClick, + getArchivedAccounts = { getArchivedAccounts() }, + ) + }, + ) + newState?.let { _uiState.value = newState } } .flowOn(dispatchers.default) @@ -87,30 +92,13 @@ internal class ArchivedAccountListModel @Inject constructor( .saveIn(getArchivedAccountsJob) } - private fun confirmRecoverDialog(account: ArchivedAccount) { - val secondAction = EventMessageAction( - title = resourceReference(R.string.common_cancel), - onClick = {}, - ) - val firstAction = EventMessageAction( - title = resourceReference(R.string.account_archived_recover), - onClick = { recoverCryptoPortfolio(account.accountId) }, - ) - messageSender.send( - DialogMessage( - title = resourceReference(R.string.account_archived_recover_dialog_title), - message = resourceReference( - id = R.string.account_archived_recover_dialog_description, - formatArgs = wrappedList(account.name.toUM().value), - ), - firstActionBuilder = { firstAction }, - secondActionBuilder = { secondAction }, - ), - ) - } - - private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch(dispatchers.default) { - recoverCryptoPortfolioUseCase(accountId) + private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch { + _uiState.update { it.toggleProgress(accountId, isLoading = true) } + val result = withContext(dispatchers.default) { + recoverCryptoPortfolioUseCase(accountId) + } + _uiState.update { it.toggleProgress(accountId, isLoading = false) } + result .onLeft(::handleRecoverError) .onRight { showSuccessRecoverMessage() @@ -122,8 +110,22 @@ internal class ArchivedAccountListModel @Inject constructor( if (error is RecoverCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet && error.cause is AccountList.Error.ExceedsMaxAccountsCount ) { - // TODO("account") show alert that max accounts count reached - // https://www.figma.com/design/09KKG4ZVuFDZhj8WLv5rGJ/%F0%9F%9A%A7-App-experience?node-id=24765-180563&t=vk6TCy4MkYol1cPb-4 + val firstAction = EventMessageAction( + title = resourceReference(R.string.common_got_it), + onClick = { }, + ) + + messageSender.send( + DialogMessage( + title = resourceReference(R.string.account_recover_limit_dialog_title), + message = resourceReference( + id = R.string.account_recover_limit_dialog_description, + formatArgs = wrappedList(AccountList.MAX_ACCOUNTS_COUNT.toString()), + ), + firstActionBuilder = { firstAction }, + ), + ) + return } diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUM.kt index a29481e093..15c30689e1 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUM.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUM.kt @@ -24,5 +24,6 @@ internal data class ArchivedAccountUM( val accountIconUM: CryptoPortfolioIconUM, val tokensInfo: TextReference, val networksInfo: TextReference, + val isLoading: Boolean, val onClick: () -> Unit, ) \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUMBuilder.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUMBuilder.kt index 1b7b801cf3..207e1f1a4a 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUMBuilder.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/entity/AccountArchivedUMBuilder.kt @@ -6,6 +6,7 @@ import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.account.usecase.ArchivedAccountList +import com.tangem.domain.models.account.AccountId import kotlinx.collections.immutable.toImmutableList import timber.log.Timber import javax.inject.Inject @@ -15,19 +16,20 @@ internal class AccountArchivedUMBuilder @Inject constructor() { fun mapContent( accounts: ArchivedAccountList, onCloseClick: () -> Unit, - confirmRecoverDialog: (account: ArchivedAccount) -> Unit, + onRecoverClick: (account: ArchivedAccount) -> Unit, ) = AccountArchivedUM.Content( onCloseClick = onCloseClick, accounts = accounts - .map { account -> account.mapArchivedAccountUM(confirmRecoverDialog) } + .map { account -> account.mapArchivedAccountUM(onRecoverClick) } .toImmutableList(), ) - fun ArchivedAccount.mapArchivedAccountUM(confirmRecoverDialog: (account: ArchivedAccount) -> Unit) = + private fun ArchivedAccount.mapArchivedAccountUM(onRecoverClick: (account: ArchivedAccount) -> Unit) = ArchivedAccountUM( accountId = accountId.value, accountName = name.toUM().value, accountIconUM = icon.toUM(), + isLoading = false, tokensInfo = pluralReference( R.plurals.common_tokens_count, count = tokensCount, @@ -38,7 +40,7 @@ internal class AccountArchivedUMBuilder @Inject constructor() { count = networksCount, formatArgs = wrappedList(networksCount), ), - onClick = { confirmRecoverDialog(this) }, + onClick = { onRecoverClick(this) }, ) fun mapError( @@ -52,4 +54,25 @@ internal class AccountArchivedUMBuilder @Inject constructor() { onRetryClick = { getArchivedAccounts() }, ) } + + companion object { + + fun AccountArchivedUM.toggleProgress(accountId: AccountId, isLoading: Boolean): AccountArchivedUM { + return when (this) { + is AccountArchivedUM.Error, + is AccountArchivedUM.Loading, + -> this + + is AccountArchivedUM.Content -> copy( + accounts = accounts.map { accountUM -> + if (accountUM.accountId == accountId.value) { + accountUM.copy(isLoading = isLoading) + } else { + accountUM + } + }.toImmutableList(), + ) + } + } + } } \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt index fca08ec492..5614b931a9 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt @@ -126,7 +126,7 @@ private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Mod Row( modifier = modifier .fillMaxWidth() - .clickable(onClick = item.onClick) + .clickable(enabled = !item.isLoading, onClick = item.onClick) .padding(all = TangemTheme.dimens.spacing12), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), @@ -148,6 +148,7 @@ private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Mod SecondarySmallButton( config = SmallButtonConfig( text = resourceReference(R.string.account_archived_recover), + isLoading = item.isLoading, onClick = item.onClick, ), ) @@ -177,7 +178,7 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider { val oldName = params.account.accountName.toUM() - val isNewName = this.account.name != oldName - val isNewIcon = this.account.portfolioIcon != params.account.portfolioIcon + val isNewName = this.account.name.trim() != oldName + val isNewIcon = this.account.portfolioIcon != params.account.portfolioIcon.toUM() isValidName && (isNewName || isNewIcon) } } @@ -275,4 +275,9 @@ internal class AccountCreateEditModel @Inject constructor( ) messageSender.send(dialogMessage) } +} + +private fun AccountNameUM.trim(): AccountNameUM = when (this) { + is AccountNameUM.Custom -> this.toDomain().getOrNull()?.toUM() ?: this + AccountNameUM.DefaultMain -> this } \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/error/AccountFeatureError.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/error/AccountFeatureError.kt index dc12ce1e49..416a02cabb 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/error/AccountFeatureError.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/error/AccountFeatureError.kt @@ -1,9 +1,9 @@ package com.tangem.features.account.createedit.error import com.tangem.core.error.UniversalError +import com.tangem.domain.account.status.usecase.RecoverCryptoPortfolioUseCase import com.tangem.domain.account.usecase.AddCryptoPortfolioUseCase import com.tangem.domain.account.usecase.GetUnoccupiedAccountIndexUseCase -import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase import com.tangem.domain.account.usecase.UpdateCryptoPortfolioUseCase sealed interface AccountFeatureError : UniversalError { diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt index 2cf339e309..1dbce4ca26 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt @@ -12,6 +12,8 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.account.producer.SingleAccountProducer +import com.tangem.domain.account.supplier.SingleAccountSupplier import com.tangem.domain.account.usecase.ArchiveCryptoPortfolioUseCase import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.account.Account @@ -20,12 +22,13 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.account.AccountDetailsComponent import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.portfolioIcon import com.tangem.features.account.details.entity.AccountDetailsUM +import com.tangem.features.account.details.entity.AccountDetailsUM.ArchiveMode import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class AccountDetailsModel @Inject constructor( paramsContainer: ParamsContainer, @@ -33,22 +36,30 @@ internal class AccountDetailsModel @Inject constructor( private val router: Router, override val dispatchers: CoroutineDispatcherProvider, private val archiveCryptoPortfolioUseCase: ArchiveCryptoPortfolioUseCase, + singleAccountSupplier: SingleAccountSupplier, private val getUserWalletUseCase: GetUserWalletUseCase, ) : Model() { private val params = paramsContainer.require() val uiState: StateFlow get() = _uiState - private val _uiState: MutableStateFlow = MutableStateFlow(getInitialState()) + private val _uiState: MutableStateFlow = MutableStateFlow(buildUI(params.account)) + private val accountId = params.account.accountId - private fun onEditAccountClick() { - router.push(AppRoute.EditAccount(params.account)) + init { + singleAccountSupplier(SingleAccountProducer.Params(accountId)) + .onEach { account -> _uiState.update { buildUI(account) } } + .launchIn(modelScope) } - private fun onManageTokensClick() { + private fun onEditAccountClick(account: Account) { + router.push(AppRoute.EditAccount(account)) + } + + private fun onManageTokensClick(account: Account) { val route = AppRoute.ManageTokens( source = AppRoute.ManageTokens.Source.SETTINGS, - portfolioId = PortfolioId(params.account.accountId), + portfolioId = PortfolioId(account.accountId), ) router.push(route) } @@ -78,7 +89,12 @@ internal class AccountDetailsModel @Inject constructor( } private fun archiveCryptoPortfolio() = modelScope.launch { - archiveCryptoPortfolioUseCase(params.account.accountId) + _uiState.update { it.toggleProgress(true) } + archiveCryptoPortfolioUseCase(accountId) + .onLeft { error -> + failedArchiveDialog(error) + _uiState.update { it.toggleProgress(false) } + } .onRight { val message = resourceReference(R.string.account_archive_success_message) messageSender.send(ToastMessage(message = message)) @@ -86,26 +102,55 @@ internal class AccountDetailsModel @Inject constructor( } } - private fun getInitialState(): AccountDetailsUM { - val account = params.account + private fun failedArchiveDialog(error: ArchiveCryptoPortfolioUseCase.Error) { + // todo account referral case + val titleRes = when (error) { + is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountListRequirementsNotMet, + is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountNotFound, + is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountsNotCreated, + is ArchiveCryptoPortfolioUseCase.Error.DataOperationFailed, + -> R.string.common_something_went_wrong + } + val messageRes = when (error) { + is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountListRequirementsNotMet, + is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountNotFound, + is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountsNotCreated, + is ArchiveCryptoPortfolioUseCase.Error.DataOperationFailed, + -> R.string.account_could_not_archive + } + + val dialogMessage = DialogMessage( + title = resourceReference(titleRes), + message = resourceReference(messageRes), + ) + messageSender.send(dialogMessage) + } + + private fun buildUI(account: Account): AccountDetailsUM { val archiveMode = when (account) { is Account.CryptoPortfolio -> when (account.isMainAccount) { - true -> AccountDetailsUM.ArchiveMode.None - false -> AccountDetailsUM.ArchiveMode.Available( + true -> ArchiveMode.None + false -> ArchiveMode.Available( onArchiveAccountClick = ::onArchiveAccountClick, + isLoading = false, ) } } - val isMultiCurrency = getUserWalletUseCase(params.account.accountId.userWalletId) + val isMultiCurrency = getUserWalletUseCase(account.accountId.userWalletId) .getOrNull()?.isMultiCurrency ?: false return AccountDetailsUM( - accountName = params.account.accountName.toUM().value, - accountIcon = params.account.portfolioIcon.toUM(), + accountName = account.accountName.toUM().value, + accountIcon = account.portfolioIcon.toUM(), onCloseClick = { router.pop() }, - onAccountEditClick = ::onEditAccountClick, - onManageTokensClick = ::onManageTokensClick, + onAccountEditClick = { onEditAccountClick(account) }, + onManageTokensClick = { onManageTokensClick(account) }, archiveMode = archiveMode, isManageTokensAvailable = isMultiCurrency, ) } + + private fun AccountDetailsUM.toggleProgress(isLoading: Boolean): AccountDetailsUM { + val archiveMode = this.archiveMode as? ArchiveMode.Available ?: return this + return this.copy(archiveMode = archiveMode.copy(isLoading = isLoading)) + } } \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt index 27ad494553..9463b00a81 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt @@ -17,6 +17,7 @@ internal data class AccountDetailsUM( data object None : ArchiveMode data class Available( val onArchiveAccountClick: () -> Unit, + val isLoading: Boolean, ) : ArchiveMode } } \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt index e3708f495f..bbc332ae27 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -91,21 +92,33 @@ private fun ArchiveAccountRow(state: AccountDetailsUM.ArchiveMode.Available) { .fillMaxWidth() .clip(RoundedCornerShape(TangemTheme.dimens.radius12)) .background(TangemTheme.colors.background.primary) - .clickable(onClick = state.onArchiveAccountClick) + .clickable(enabled = !state.isLoading, onClick = state.onArchiveAccountClick) .padding(all = TangemTheme.dimens.spacing12), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { - Icon( - tint = TangemTheme.colors.icon.warning, - imageVector = ImageVector.vectorResource(id = R.drawable.ic_archive_24), - contentDescription = null, - ) - Text( - text = stringResourceSafe(R.string.account_details_archive), - color = TangemTheme.colors.text.warning, - style = TangemTheme.typography.subtitle1, - ) + if (state.isLoading) { + CircularProgressIndicator( + color = TangemTheme.colors.text.disabled, + modifier = Modifier.size(TangemTheme.dimens.size24), + ) + Text( + text = stringResourceSafe(R.string.account_details_archive), + color = TangemTheme.colors.text.disabled, + style = TangemTheme.typography.subtitle1, + ) + } else { + Icon( + tint = TangemTheme.colors.icon.warning, + imageVector = ImageVector.vectorResource(id = R.drawable.ic_archive_24), + contentDescription = null, + ) + Text( + text = stringResourceSafe(R.string.account_details_archive), + color = TangemTheme.colors.text.warning, + style = TangemTheme.typography.subtitle1, + ) + } } } @@ -174,6 +187,12 @@ private fun WcConnectionsContentPreview(@PreviewParameter(PreviewStateProvider:: } } +private val archiveModeAvailable + get() = AccountDetailsUM.ArchiveMode.Available( + onArchiveAccountClick = {}, + isLoading = false, + ) + private class PreviewStateProvider : CollectionPreviewParameterProvider( buildList { val accountName = "Main" @@ -182,16 +201,13 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider(emptyState()) init { + val selectedAccountState = selectorController.selectedAccount + .stateIn(modelScope, started = SharingStarted.Eagerly, initialValue = null) + combine( flow = isAccountsModeEnabledUseCase(), flow2 = balanceFetcher.data, flow3 = walletImageFetcher.allWallets(ArtworkSize.SMALL), flow4 = selectorController.isEnabled, - transform = { isAccountsMode, portfolioData, artworks, isEnabled -> - val uiList = buildUiList(isAccountsMode, portfolioData, artworks, isEnabled) + flow5 = selectedAccountState, + transform = { isAccountsMode, portfolioData, artworks, isEnabled, selectedAccount -> + val uiList = buildUiList(isAccountsMode, portfolioData, artworks, isEnabled, selectedAccount) val title = when (isAccountsMode) { true -> resourceReference(R.string.common_choose_account) false -> resourceReference(R.string.common_choose_wallet) @@ -68,15 +73,17 @@ internal class PortfolioSelectorModel @Inject constructor( portfolioData: PortfolioFetcher.Data, artworks: Map, isEnabled: (UserWallet, AccountStatus) -> Boolean, + selectedAccount: AccountId?, ): List = when (isAccountsMode) { - true -> buildAccountsList(portfolioData, artworks, isEnabled) - false -> buildWalletList(portfolioData, artworks, isEnabled) + true -> buildAccountsList(portfolioData, artworks, isEnabled, selectedAccount) + false -> buildWalletList(portfolioData, artworks, isEnabled, selectedAccount) } private fun buildWalletList( portfolioData: PortfolioFetcher.Data, artworks: Map, isEnabled: (UserWallet, AccountStatus) -> Boolean, + selectedAccount: AccountId?, ): List = buildList { val appCurrency = portfolioData.appCurrency val isBalanceHidden = portfolioData.isBalanceHidden @@ -94,12 +101,14 @@ internal class PortfolioSelectorModel @Inject constructor( isAuthMode = false, ).convert(wallet) if (walletItemUM.isEnabled) { - val isEnabledByFeature = isEnabled(wallet, portfolio.accountsBalance.mainAccount) + val mainAccount = portfolio.accountsBalance.mainAccount + val isEnabledByFeature = isEnabled(wallet, mainAccount) val finalWalletItemUM = if (isEnabledByFeature) walletItemUM else walletItemUM.copy(isEnabled = false) - add(PortfolioSelectorItemUM.Portfolio(finalWalletItemUM)) + val isSelected = mainAccount.isSelected(selectedAccount) + add(PortfolioSelectorItemUM.Portfolio(finalWalletItemUM, isSelected)) } else { - lockedWallets.add(PortfolioSelectorItemUM.Portfolio(walletItemUM)) + lockedWallets.add(PortfolioSelectorItemUM.Portfolio(walletItemUM, isSelected = false)) } } if (lockedWallets.isNotEmpty()) { @@ -116,6 +125,7 @@ internal class PortfolioSelectorModel @Inject constructor( portfolioData: PortfolioFetcher.Data, artworks: Map, isEnabled: (UserWallet, AccountStatus) -> Boolean, + selectedAccount: AccountId?, ): List = buildList { val appCurrency = portfolioData.appCurrency val isBalanceHidden = portfolioData.isBalanceHidden @@ -133,7 +143,7 @@ internal class PortfolioSelectorModel @Inject constructor( isAuthMode = false, ).convert(wallet) if (!walletItemUM.isEnabled) { - lockedWallets.add(PortfolioSelectorItemUM.Portfolio(walletItemUM)) + lockedWallets.add(PortfolioSelectorItemUM.Portfolio(walletItemUM, false)) return@forEach } @@ -156,7 +166,8 @@ internal class PortfolioSelectorModel @Inject constructor( isEnabled = isEnabledByFeature, isBalanceHidden = isBalanceHidden, ).convert(account) - add(PortfolioSelectorItemUM.Portfolio(accountItemUM)) + val isSelected = accountStatus.isSelected(selectedAccount) + add(PortfolioSelectorItemUM.Portfolio(accountItemUM, isSelected)) } } if (lockedWallets.isNotEmpty()) { @@ -169,6 +180,8 @@ internal class PortfolioSelectorModel @Inject constructor( } } + private fun AccountStatus.isSelected(selectedId: AccountId?) = this.account.accountId == selectedId + private fun emptyState() = PortfolioSelectorUM( items = persistentListOf(), title = TextReference.EMPTY, 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/account/impl/src/main/java/com/tangem/features/account/selector/entity/PortfolioSelectorUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/PortfolioSelectorUM.kt index 2fb4321185..ab0f8ec525 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/PortfolioSelectorUM.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/PortfolioSelectorUM.kt @@ -21,6 +21,7 @@ sealed interface PortfolioSelectorItemUM { data class Portfolio( val item: UserWalletItemUM, + val isSelected: Boolean, ) : PortfolioSelectorItemUM { override val id: String = item.id.stringValue } diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt index 918832e89b..396694b443 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt @@ -1,7 +1,9 @@ package com.tangem.features.account.selector.ui import android.content.res.Configuration +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth @@ -19,6 +21,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp import com.tangem.common.ui.account.AccountIconPreviewData import com.tangem.common.ui.userwallet.UserWalletItemRow import com.tangem.common.ui.userwallet.state.UserWalletItemUM @@ -68,14 +71,21 @@ internal fun PortfolioSelectorContent( ) } + val portfolioShape = RoundedCornerShape(TangemTheme.dimens.radius14) + val border = BorderStroke( + width = 1.dp, + color = TangemTheme.colors.text.accent, + ) + when (item) { is PortfolioSelectorItemUM.Portfolio -> UserWalletItemRow( state = item.item, modifier = offsetModifier .fillMaxWidth() .heightIn(min = TangemTheme.dimens.size68) - .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .clip(portfolioShape) .background(TangemTheme.colors.background.action) + .conditional(item.isSelected) { border(border, portfolioShape) } .clickable(enabled = item.item.isEnabled, onClick = item.item.onClick) .padding(all = TangemTheme.dimens.spacing12) .conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) }, @@ -158,16 +168,16 @@ internal object PortfolioSelectorPreviewData { name = stringReference("Tangem 2.0"), ).let(::add) accountItem - .let { PortfolioSelectorItemUM.Portfolio(it) } + .let { PortfolioSelectorItemUM.Portfolio(it, false) } .let(::add) lockedAccountItem - .let { PortfolioSelectorItemUM.Portfolio(it) } + .let { PortfolioSelectorItemUM.Portfolio(it, false) } .let(::add) PortfolioSelectorItemUM.GroupTitle( id = UUID.randomUUID().toString(), name = stringReference("Tangem White"), ).let(::add) - accountItem.let { PortfolioSelectorItemUM.Portfolio(it) } + accountItem.let { PortfolioSelectorItemUM.Portfolio(it, true) } .let(::add) } @@ -177,26 +187,26 @@ internal object PortfolioSelectorPreviewData { id = UUID.randomUUID().toString(), name = resourceReference(R.string.common_locked_wallets), ).let(::add) - add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem)) - add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem)) + add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false)) + add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false)) } val walletList get() = buildList { - add(PortfolioSelectorItemUM.Portfolio(walletItem)) - add(PortfolioSelectorItemUM.Portfolio(walletItem)) + add(PortfolioSelectorItemUM.Portfolio(walletItem, false)) + add(PortfolioSelectorItemUM.Portfolio(walletItem, true)) } val lockedWalletList get() = buildList { - add(PortfolioSelectorItemUM.Portfolio(walletItem)) + add(PortfolioSelectorItemUM.Portfolio(walletItem, true)) val title = PortfolioSelectorItemUM.GroupTitle( id = UUID.randomUUID().toString(), name = resourceReference(R.string.common_locked_wallets), ) add(title) - add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem)) - add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem)) + add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false)) + add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false)) } } 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/CreateWalletBackupComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/CreateWalletBackupComponent.kt index 84d8828d8f..4c21e1dfd8 100644 --- a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/CreateWalletBackupComponent.kt +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/CreateWalletBackupComponent.kt @@ -8,6 +8,7 @@ interface CreateWalletBackupComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, + val isUpgradeFlow: Boolean, ) interface Factory : ComponentFactory 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/api/src/main/kotlin/com/tangem/features/hotwallet/WalletHardwareBackupComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/WalletHardwareBackupComponent.kt new file mode 100644 index 0000000000..e6f3f9996e --- /dev/null +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/WalletHardwareBackupComponent.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 WalletHardwareBackupComponent : 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..c8506fe4f2 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() @@ -109,6 +120,8 @@ internal class AddExistingWalletModel @Inject constructor( override fun onContinueClick(userWalletId: UserWalletId) { stackNavigation.replaceAll(AddExistingWalletRoute.SetAccessCode(userWalletId)) } + + override fun onUpgradeClick(userWalletId: UserWalletId) = Unit } inner class AccessCodeModelCallbacks : AccessCodeComponent.ModelCallbacks { diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/routing/AddExistingWalletChildFactory.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/routing/AddExistingWalletChildFactory.kt index 729d2a693d..5c0660cf08 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/routing/AddExistingWalletChildFactory.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/entry/routing/AddExistingWalletChildFactory.kt @@ -34,6 +34,7 @@ internal class AddExistingWalletChildFactory @Inject constructor( params = ManualBackupCompletedComponent.Params( userWalletId = route.userWalletId, callbacks = model.manualBackupCompletedComponentModelCallbacks, + isUpgradeFlow = false, ), ) is AddExistingWalletRoute.SetAccessCode -> accessCodeComponentFactory.create( 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..0e1da6c079 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,10 +3,13 @@ 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.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.model.ParamsContainer import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.navigation.popTo import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.hotwallet.CreateWalletBackupComponent import com.tangem.features.hotwallet.createwalletbackup.routing.CreateWalletBackupRoute @@ -23,6 +26,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 +40,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() @@ -54,7 +67,7 @@ internal class CreateWalletBackupModel @Inject constructor( } fun onManualBackupChecked() { - stackNavigation.push(CreateWalletBackupRoute.BackupCompleted) + stackNavigation.push(CreateWalletBackupRoute.BackupCompleted(isUpgradeFlow = params.isUpgradeFlow)) } fun onManualBackupCompleted() { @@ -83,5 +96,14 @@ internal class CreateWalletBackupModel @Inject constructor( override fun onContinueClick(userWalletId: UserWalletId) { onManualBackupCompleted() } + + override fun onUpgradeClick(userWalletId: UserWalletId) { + router.popTo() + router.push( + AppRoute.UpgradeWallet( + userWalletId = userWalletId, + ), + ) + } } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupChildFactory.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupChildFactory.kt index 44d740af6c..4bbcc396c6 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupChildFactory.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupChildFactory.kt @@ -16,31 +16,32 @@ internal class CreateWalletBackupChildFactory @Inject constructor() { childContext: AppComponentContext, model: CreateWalletBackupModel, ): ComposableContentComponent = when (route) { - CreateWalletBackupRoute.RecoveryPhraseStart -> ManualBackupStartComponent( + is CreateWalletBackupRoute.RecoveryPhraseStart -> ManualBackupStartComponent( context = childContext, params = ManualBackupStartComponent.Params( callbacks = model.manualBackupStartModelCallbacks, ), ) - CreateWalletBackupRoute.RecoveryPhrase -> ManualBackupPhraseComponent( + is CreateWalletBackupRoute.RecoveryPhrase -> ManualBackupPhraseComponent( context = childContext, params = ManualBackupPhraseComponent.Params( userWalletId = model.params.userWalletId, callbacks = model.manualBackupPhraseModelCallbacks, ), ) - CreateWalletBackupRoute.ConfirmBackup -> ManualBackupCheckComponent( + is CreateWalletBackupRoute.ConfirmBackup -> ManualBackupCheckComponent( context = childContext, params = ManualBackupCheckComponent.Params( userWalletId = model.params.userWalletId, callbacks = model.manualBackupCheckModelCallbacks, ), ) - CreateWalletBackupRoute.BackupCompleted -> ManualBackupCompletedComponent( + is CreateWalletBackupRoute.BackupCompleted -> ManualBackupCompletedComponent( context = childContext, params = ManualBackupCompletedComponent.Params( userWalletId = model.params.userWalletId, callbacks = model.manualBackupCompletedModelCallbacks, + isUpgradeFlow = route.isUpgradeFlow, ), ) } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupRoute.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupRoute.kt index f063a1f796..f210f5b8e7 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupRoute.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createwalletbackup/routing/CreateWalletBackupRoute.kt @@ -15,5 +15,7 @@ internal sealed interface CreateWalletBackupRoute { data object ConfirmBackup : CreateWalletBackupRoute @Serializable - data object BackupCompleted : CreateWalletBackupRoute + data class BackupCompleted( + val isUpgradeFlow: Boolean, + ) : CreateWalletBackupRoute } \ No newline at end of file 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/completed/ManualBackupCompletedComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/ManualBackupCompletedComponent.kt index bd40e6bb1a..b3fbee23df 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/ManualBackupCompletedComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/ManualBackupCompletedComponent.kt @@ -29,10 +29,12 @@ internal class ManualBackupCompletedComponent @AssistedInject constructor( interface ModelCallbacks { fun onContinueClick(userWalletId: UserWalletId) + fun onUpgradeClick(userWalletId: UserWalletId) } data class Params( val userWalletId: UserWalletId, val callbacks: ModelCallbacks, + val isUpgradeFlow: Boolean, ) } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/ManualBackupCompletedModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/ManualBackupCompletedModel.kt index 9abbb6caff..67ec256e30 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/ManualBackupCompletedModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/ManualBackupCompletedModel.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class ManualBackupCompletedModel @Inject constructor( paramsContainer: ParamsContainer, @@ -20,7 +21,14 @@ internal class ManualBackupCompletedModel @Inject constructor( internal val uiState: StateFlow field = MutableStateFlow( ManualBackupCompletedUM( - onContinueClick = { params.callbacks.onContinueClick(params.userWalletId) }, + onContinueClick = { + if (params.isUpgradeFlow) { + params.callbacks.onUpgradeClick(params.userWalletId) + } else { + params.callbacks.onContinueClick(params.userWalletId) + } + }, + isLoading = false, ), ) } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/entity/ManualBackupCompletedUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/entity/ManualBackupCompletedUM.kt index d45f370259..d647fa285c 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/entity/ManualBackupCompletedUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/entity/ManualBackupCompletedUM.kt @@ -2,4 +2,5 @@ package com.tangem.features.hotwallet.manualbackup.completed.entity internal data class ManualBackupCompletedUM( val onContinueClick: () -> Unit, + val isLoading: Boolean, ) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/ui/ManualBackupCompletedContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/ui/ManualBackupCompletedContent.kt index 77535d8e94..b3b0da92e4 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/ui/ManualBackupCompletedContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/completed/ui/ManualBackupCompletedContent.kt @@ -65,8 +65,6 @@ internal fun ManualBackupCompletedContent(state: ManualBackupCompletedUM, modifi PrimaryButton( modifier = Modifier.fillMaxWidth(), text = stringResourceSafe(R.string.common_continue), - showProgress = false, - enabled = true, onClick = state.onContinueClick, ) } @@ -75,11 +73,26 @@ internal fun ManualBackupCompletedContent(state: ManualBackupCompletedUM, modifi @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun PreviewManualBackupCompletedContent() { +private fun PreviewManualBackupCompletedContentRegular() { TangemThemePreview { ManualBackupCompletedContent( state = ManualBackupCompletedUM( - onContinueClick = {}, + isLoading = false, + onContinueClick = { }, + ), + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewManualBackupCompletedContentUpgrade() { + TangemThemePreview { + ManualBackupCompletedContent( + state = ManualBackupCompletedUM( + isLoading = false, + onContinueClick = { }, ), ) } 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/UpgradeWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt index 00ca37071d..3544180c6b 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt @@ -53,14 +53,14 @@ internal class UpgradeWalletModel @Inject constructor( ) : Model() { private val params = paramsContainer.require() - private val _uiState = MutableStateFlow( - UpgradeWalletUM( - onBackClick = { router.pop() }, - onBuyTangemWalletClick = ::onBuyTangemWalletClick, - onScanDeviceClick = ::onScanDeviceClick, - ), - ) - internal val uiState: StateFlow = _uiState + internal val uiState: StateFlow + field = MutableStateFlow( + UpgradeWalletUM( + onBackClick = { router.pop() }, + onBuyTangemWalletClick = ::onBuyTangemWalletClick, + onContinueClick = ::onContinueClick, + ), + ) override fun onDestroy() { clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId) @@ -73,7 +73,7 @@ internal class UpgradeWalletModel @Inject constructor( } } - private fun onScanDeviceClick() { + private fun onContinueClick() { scanCard() } @@ -100,7 +100,7 @@ internal class UpgradeWalletModel @Inject constructor( } private fun setLoading(isLoading: Boolean) { - _uiState.update { it.copy(isLoading = isLoading) } + uiState.update { it.copy(isLoading = isLoading) } } private fun showCardVerificationFailedDialog(error: TangemError) { diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt index ff2ae172d9..c4b8dfdb51 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt @@ -3,6 +3,6 @@ package com.tangem.features.hotwallet.upgradewallet.entity internal data class UpgradeWalletUM( val onBackClick: () -> Unit, val onBuyTangemWalletClick: () -> Unit, - val onScanDeviceClick: () -> Unit, + val onContinueClick: () -> Unit, val isLoading: Boolean = false, ) \ No newline at end of file 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..515d01c482 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 @@ -3,7 +3,9 @@ package com.tangem.features.hotwallet.upgradewallet.ui import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.material3.* +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.graphics.Color @@ -16,6 +18,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 @@ -102,44 +105,11 @@ internal fun UpgradeWalletContent(state: UpgradeWalletUM, modifier: Modifier = M onClick = state.onBuyTangemWalletClick, ) PrimaryButtonIconEnd( - modifier = Modifier - .fillMaxWidth(), - text = stringResourceSafe(R.string.hw_upgrade_scan_device), - onClick = state.onScanDeviceClick, + modifier = Modifier.fillMaxWidth(), iconResId = R.drawable.ic_tangem_24, - ) - } - } -} - -@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, + text = stringResourceSafe(R.string.hw_upgrade_start_action), + onClick = state.onContinueClick, + showProgress = state.isLoading, ) } } @@ -148,13 +118,28 @@ private fun FeatureBlock(title: String, description: String, iconRes: Int, modif @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun PreviewUpgradeWalletContent() { +private fun PreviewUpgradeWalletContentBackup() { TangemThemePreview { UpgradeWalletContent( state = UpgradeWalletUM( onBackClick = {}, onBuyTangemWalletClick = {}, - onScanDeviceClick = {}, + onContinueClick = {}, + ), + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewUpgradeWalletContentScan() { + TangemThemePreview { + UpgradeWalletContent( + state = UpgradeWalletUM( + onBackClick = {}, + onBuyTangemWalletClick = {}, + onContinueClick = {}, ), ) } 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..eb31a235f9 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() @@ -136,6 +147,8 @@ internal class WalletActivationModel @Inject constructor( override fun onContinueClick(userWalletId: UserWalletId) { stackNavigation.push(WalletActivationRoute.SetAccessCode) } + + override fun onUpgradeClick(userWalletId: UserWalletId) = Unit } inner class AccessCodeModelCallbacks : AccessCodeComponent.ModelCallbacks { diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/routing/WalletActivationChildFactory.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/routing/WalletActivationChildFactory.kt index 77684cecdb..6cdbce4153 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/routing/WalletActivationChildFactory.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletactivation/entry/routing/WalletActivationChildFactory.kt @@ -50,6 +50,7 @@ internal class WalletActivationChildFactory @Inject constructor( params = ManualBackupCompletedComponent.Params( userWalletId = model.params.userWalletId, callbacks = model.manualBackupCompletedModelCallbacks, + isUpgradeFlow = false, ), ) is WalletActivationRoute.SetAccessCode -> accessCodeComponentFactory.create( diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt index 457edf278a..dbb699dda9 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt @@ -7,6 +7,7 @@ internal data class WalletBackupUM( val recoveryPhraseOption: LabelUM?, val googleDriveOption: LabelUM?, val googleDriveStatus: BackupStatus, + val onBuyClick: () -> Unit, val onRecoveryPhraseClick: () -> Unit, val onGoogleDriveClick: () -> Unit, val onHardwareWalletClick: () -> Unit, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt index 19bc899913..5b5c884ea0 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt @@ -1,16 +1,18 @@ package com.tangem.features.hotwallet.walletbackup.model +import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router +import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.R import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase -import com.tangem.common.routing.AppRoute import com.tangem.domain.wallets.usecase.UnlockHotWalletContextualUseCase import com.tangem.features.hotwallet.WalletBackupComponent import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus @@ -21,12 +23,15 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class WalletBackupModel @Inject constructor( paramsContainer: ParamsContainer, private val getUserWalletUseCase: GetUserWalletUseCase, private val unlockHotWalletContextualUseCase: UnlockHotWalletContextualUseCase, override val dispatchers: CoroutineDispatcherProvider, + private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, + private val urlOpener: UrlOpener, private val router: Router, ) : Model() { @@ -45,6 +50,7 @@ internal class WalletBackupModel @Inject constructor( style = LabelStyle.REGULAR, ), googleDriveStatus = BackupStatus.ComingSoon, + onBuyClick = ::onBuyClick, onRecoveryPhraseClick = ::onRecoveryPhraseClick, onGoogleDriveClick = { }, onHardwareWalletClick = ::onHardwareWalletClick, @@ -95,6 +101,12 @@ internal class WalletBackupModel @Inject constructor( backedUp = userWallet.backedUp, ) + private fun onBuyClick() { + modelScope.launch { + generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) } + } + } + private fun onRecoveryPhraseClick() { if (uiState.value.backedUp) { getUserWalletUseCase.invoke(params.userWalletId) @@ -113,7 +125,12 @@ internal class WalletBackupModel @Inject constructor( }, ) } else { - router.push(AppRoute.CreateWalletBackup(params.userWalletId)) + router.push( + AppRoute.CreateWalletBackup( + userWalletId = params.userWalletId, + isUpgradeFlow = false, + ), + ) } } @@ -132,6 +149,6 @@ internal class WalletBackupModel @Inject constructor( } private fun onHardwareWalletClick() { - router.push(AppRoute.UpgradeWallet(params.userWalletId)) + router.push(AppRoute.WalletHardwareBackup(params.userWalletId)) } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt index aa68dc3e7d..101304c7f6 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt @@ -1,35 +1,43 @@ package com.tangem.features.hotwallet.walletbackup.ui import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign 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.appbar.AppBarWithBackButton -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.walletbackup.entity.BackupStatus -import com.tangem.features.hotwallet.walletbackup.entity.WalletBackupUM -import com.tangem.features.hotwallet.common.ui.OptionBlock import com.tangem.core.ui.R +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.components.label.Label import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.components.rows.NetworkTitle +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.stringResourceSafe +import com.tangem.core.ui.res.ForceDarkTheme +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.hotwallet.common.ui.OptionBlock +import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus +import com.tangem.features.hotwallet.walletbackup.entity.WalletBackupUM -@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongMethod") @Composable internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Modifier) { Column( @@ -46,11 +54,40 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp), + .verticalScroll(rememberScrollState()) + .padding( + start = 16.dp, + top = 12.dp, + end = 16.dp, + ), ) { + Banner(state) + OptionBlock( modifier = Modifier + .fillMaxWidth() .padding(top = 8.dp), + title = stringResourceSafe(R.string.hw_backup_hardware_title), + description = stringResourceSafe(R.string.hw_backup_hardware_description), + badge = null, + onClick = state.onHardwareWalletClick, + enabled = true, + backgroundColor = TangemTheme.colors.background.primary, + ) + NetworkTitle( + modifier = Modifier + .padding(top = 8.dp), + title = { + Text( + modifier = Modifier, + text = stringResourceSafe(R.string.onboarding_create_wallet_options_button_options), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + }, + ) + OptionBlock( + modifier = Modifier, title = stringResourceSafe(R.string.hw_backup_seed_title), description = stringResourceSafe(R.string.hw_backup_seed_description), badge = { @@ -60,7 +97,6 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod enabled = true, backgroundColor = TangemTheme.colors.background.primary, ) - OptionBlock( modifier = Modifier .padding(top = 8.dp), @@ -73,30 +109,125 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod enabled = state.googleDriveStatus != BackupStatus.ComingSoon, backgroundColor = TangemTheme.colors.background.primary, ) - - NetworkTitle( - title = { - Text( - modifier = Modifier, - text = stringResourceSafe(R.string.express_provider_recommended), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) - }, - ) - OptionBlock( - modifier = Modifier.fillMaxWidth(), - title = stringResourceSafe(R.string.hw_backup_hardware_title), - description = stringResourceSafe(R.string.hw_backup_hardware_description), - badge = null, - onClick = state.onHardwareWalletClick, - enabled = true, - backgroundColor = TangemTheme.colors.background.primary, - ) + Spacer(modifier = Modifier.size(16.dp)) } } } +@Suppress("LongMethod") +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Banner(state: WalletBackupUM, modifier: Modifier = Modifier) { + ForceDarkTheme { + Column( + modifier = modifier + .background( + color = TangemTheme.colors.background.primary, + shape = RoundedCornerShape(16.dp), + ), + ) { + Column( + modifier = Modifier + .padding( + start = 12.dp, + top = 20.dp, + end = 12.dp, + ), + ) { + Text( + modifier = Modifier + .fillMaxWidth(), + text = "Tangem Wallet", + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + top = 4.dp, + ), + text = "Keeps your crypto safe and offline. Slim as a credit card, safer than a bank vault.", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 24.dp, + top = 16.dp, + end = 24.dp, + ), + horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_class), + ) + FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_delivery), + ) + FeatureItem( + iconResId = R.drawable.ic_sparkles_16, + text = resourceReference(R.string.welcome_create_wallet_feature_use), + ) + } + + Box( + modifier = Modifier + .padding( + start = 8.dp, + top = 12.dp, + end = 8.dp, + bottom = 20.dp, + ), + ) { + Image( + painter = painterResource(id = R.drawable.img_tangem_cards_vertical), + contentDescription = null, + ) + SecondaryButton( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + text = stringResourceSafe(R.string.details_buy_wallet), + onClick = state.onBuyClick, + ) + } + } + } + } +} + +@Composable +private fun FeatureItem(@DrawableRes iconResId: Int, text: TextReference) { + Row( + modifier = Modifier + .wrapContentWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(iconResId), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + Text( + text = text.resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.secondary, + ) + } +} + @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -119,6 +250,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider, + val onBackClick: () -> Unit, + val onBuyClick: () -> Unit, +) { + data class Block( + val title: TextReference, + val titleLabel: LabelUM?, + val description: TextReference, + val onClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/wallethardwarebackup/model/WalletHardwareBackupModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/wallethardwarebackup/model/WalletHardwareBackupModel.kt new file mode 100644 index 0000000000..b3499d68fd --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/wallethardwarebackup/model/WalletHardwareBackupModel.kt @@ -0,0 +1,143 @@ +package com.tangem.features.hotwallet.wallethardwarebackup.model + +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.navigation.url.UrlOpener +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 +import com.tangem.core.ui.components.bottomsheets.message.icon +import com.tangem.core.ui.components.bottomsheets.message.infoBlock +import com.tangem.core.ui.components.bottomsheets.message.onClick +import com.tangem.core.ui.components.bottomsheets.message.primaryButton +import com.tangem.core.ui.components.label.entity.LabelStyle +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.bottomSheetMessage +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.hotwallet.WalletHardwareBackupComponent +import com.tangem.features.hotwallet.impl.R +import com.tangem.features.hotwallet.wallethardwarebackup.entity.WalletHardwareBackupUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +@ModelScoped +internal class WalletHardwareBackupModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, + private val urlOpener: UrlOpener, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val messageSender: UiMessageSender, +) : Model() { + + private val params = paramsContainer.require() + + // TODO actualize strings [REDACTED_TASK_KEY] + private val makeBackupAtFirstAlertBS + get() = bottomSheetMessage { + infoBlock { + icon(R.drawable.ic_passcode_lock_32) { + type = MessageBottomSheetUMV2.Icon.Type.Accent + backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint + } + title = stringReference("Finish Backup First") + body = stringReference("To upgrade your wallet to hardware, back it up first.") + } + primaryButton { + text = resourceReference(R.string.hw_backup_need_action) + onClick { + router.push( + AppRoute.CreateWalletBackup( + userWalletId = params.userWalletId, + isUpgradeFlow = true, + ), + ) + closeBs() + } + } + } + + // TODO actualize strings [REDACTED_TASK_KEY] + internal val uiState: StateFlow + field = MutableStateFlow( + WalletHardwareBackupUM( + onBackClick = { router.pop() }, + blocks = persistentListOf( + WalletHardwareBackupUM.Block( + title = stringReference("Create new wallet"), + titleLabel = LabelUM( + text = resourceReference(R.string.common_recommended), + style = LabelStyle.ACCENT, + ), + description = stringReference( + "Create a new secure wallet and transfer your funds for extra protection.", + ), + onClick = ::onCreateNewWalletClick, + ), + WalletHardwareBackupUM.Block( + title = stringReference("Upgrade current wallet"), + titleLabel = null, + description = stringReference("Move your current wallet into Tangem Wallet."), + onClick = ::onUpgradeCurrentWalletClick, + ), + ), + onBuyClick = ::onBuyClick, + ), + ) + + init { + showPurchaseBlockWithDelay() + } + + private fun showPurchaseBlockWithDelay() { + modelScope.launch { + delay(SHOW_PURCHASE_BLOCK_DELAY) + uiState.update { it.copy(showPurchaseBlock = true) } + } + } + + private fun onCreateNewWalletClick() { + router.push(AppRoute.CreateHardwareWallet) + } + + private fun onUpgradeCurrentWalletClick() { + val userWallet = getUserWalletUseCase.invoke(params.userWalletId) + .getOrElse { error("Cannot find user wallet with id: ${params.userWalletId.stringValue}") } + if (userWallet is UserWallet.Hot) { + if (!userWallet.backedUp) { + messageSender.send(makeBackupAtFirstAlertBS) + } else { + router.push( + AppRoute.UpgradeWallet( + userWalletId = params.userWalletId, + ), + ) + } + } + } + + private fun onBuyClick() { + modelScope.launch { + generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) } + } + } + + companion object { + private const val SHOW_PURCHASE_BLOCK_DELAY = 3000L + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/wallethardwarebackup/ui/WalletHardwareBackupContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/wallethardwarebackup/ui/WalletHardwareBackupContent.kt new file mode 100644 index 0000000000..9635f67b45 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/wallethardwarebackup/ui/WalletHardwareBackupContent.kt @@ -0,0 +1,162 @@ +package com.tangem.features.hotwallet.wallethardwarebackup.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +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.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.buttons.common.TangemButtonSize +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.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.hotwallet.common.ui.OptionBlock +import com.tangem.features.hotwallet.impl.R +import com.tangem.features.hotwallet.wallethardwarebackup.entity.WalletHardwareBackupUM +import kotlinx.collections.immutable.persistentListOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun WalletHardwareBackupContent(state: WalletHardwareBackupUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background(TangemTheme.colors.background.secondary) + .fillMaxSize() + .systemBarsPadding(), + ) { + TopAppBar( + modifier = Modifier + .statusBarsPadding(), + colors = TopAppBarDefaults.topAppBarColors( + containerColor = TangemTheme.colors.background.secondary, + ), + navigationIcon = { + IconButton(onClick = state.onBackClick) { + Icon( + painter = painterResource(R.drawable.ic_back_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + } + }, + title = { + Text( + text = stringResourceSafe(R.string.hw_backup_hardware_title), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + Column( + modifier = Modifier + .weight(1f) + .padding( + start = 16.dp, + top = 4.dp, + end = 16.dp, + ), + ) { + state.blocks.forEach { block -> + OptionBlock( + modifier = Modifier + .padding(top = 8.dp), + title = block.title.resolveReference(), + description = block.description.resolveReference(), + badge = block.titleLabel?.let { + { Label(it) } + }, + enabled = true, + backgroundColor = TangemTheme.colors.background.primary, + onClick = block.onClick, + ) + } + } + AnimatedVisibility(state.showPurchaseBlock) { + PurchaseBlock( + onBuyClick = state.onBuyClick, + ) + } + } +} + +@Composable +private fun PurchaseBlock(onBuyClick: () -> Unit, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .padding( + horizontal = 20.dp, + vertical = 16.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + text = stringResourceSafe(R.string.wallet_add_hardware_purchase), + style = TangemTheme.typography.button, + color = TangemTheme.colors.text.primary1, + ) + + SecondaryButton( + text = stringResourceSafe(R.string.wallet_import_buy_title), + onClick = onBuyClick, + size = TangemButtonSize.RoundedAction, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewWalletHardwareBackupContent() { + TangemThemePreview { + WalletHardwareBackupContent( + state = WalletHardwareBackupUM( + onBackClick = { }, + blocks = persistentListOf( + WalletHardwareBackupUM.Block( + title = stringReference("Create new wallet"), + titleLabel = LabelUM( + text = resourceReference(R.string.common_recommended), + style = LabelStyle.ACCENT, + ), + description = stringReference( + "Create a new secure wallet and transfer your funds for extra protection.", + ), + onClick = { }, + ), + WalletHardwareBackupUM.Block( + title = stringReference("Upgrade current wallet"), + titleLabel = null, + description = stringReference("Move your current wallet into Tangem Wallet."), + onClick = { }, + ), + ), + showPurchaseBlock = true, + onBuyClick = { }, + ), + ) + } +} \ No newline at end of file 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..1e2b301f45 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,52 @@ 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.account.AccountId +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase -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 = AccountId.forCryptoPortfolio( + userWalletId = mode.userWalletId, + derivationIndex = DerivationIndex.Main, + ), + 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 +58,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 +79,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..a1b8a6c745 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddToPortfolioModel.kt @@ -0,0 +1,319 @@ +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.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.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, + account = selectedPortfolio.account, + ) ?: return@transform null, + selectedNetwork = selectedNetwork, + availableMoreNetwork = !selectedPortfolio.account.isSingleNetwork, + ) + }, + ) + .filterNotNull() + + private suspend fun createCryptoCurrency( + userWallet: UserWallet, + network: TokenMarketInfo.Network, + account: AvailableToAddAccount, + ): CryptoCurrency? = getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = addToPortfolioManager.token, + network = network, + accountIndex = (account.account as? AccountStatus.CryptoPortfolio)?.account?.derivationIndex, + ) + + 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..6f43d5f90e 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() { @@ -66,13 +66,11 @@ internal class AddTokenModel @Inject constructor( val blockchainNames = listOf(selectedNetwork.selectedNetwork) .mapNotNull { BlockchainUtils.getNetworkInfo(it.networkId)?.name } analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames)) + val cryptoCurrency = selectedNetwork.cryptoCurrency val accountId = selectedPortfolio.account.account.account.accountId - saveCryptoCurrenciesUseCase( - accountId = accountId, - add = listOf(cryptoCurrency), - remove = listOf(), - ) + manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency) + val status = getAccountCurrencyStatusUseCase.invokeSync( userWalletId = accountId.userWalletId, currencyId = cryptoCurrency.id, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt index d0823406f3..c1963a4d9d 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/AddTokenUiBuilder.kt @@ -1,6 +1,7 @@ package com.tangem.features.markets.portfolio.add.impl.model import com.tangem.common.ui.account.CryptoPortfolioIconUM +import com.tangem.common.ui.account.PortfolioSelectUM import com.tangem.common.ui.account.toUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.ParamsContainer @@ -33,7 +34,7 @@ internal class AddTokenUiBuilder @Inject constructor( ) } - private fun createPortfolio(selectedPortfolio: SelectedPortfolio): AddTokenUM.Portfolio { + private fun createPortfolio(selectedPortfolio: SelectedPortfolio): PortfolioSelectUM { val accountIcon: CryptoPortfolioIconUM? val portfolioName: TextReference when (selectedPortfolio.isAccountMode) { @@ -49,10 +50,11 @@ internal class AddTokenUiBuilder @Inject constructor( } } } - return AddTokenUM.Portfolio( - accountIconUM = accountIcon, + return PortfolioSelectUM( + icon = accountIcon, name = portfolioName, - editable = selectedPortfolio.availableMorePortfolio, + isAccountMode = selectedPortfolio.isAccountMode, + isMultiChoice = selectedPortfolio.availableMorePortfolio, onClick = { params.callbacks.onChangePortfolioClick() }, ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/ChooseNetworkModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/ChooseNetworkModel.kt 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/AddTokenContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/AddTokenContent.kt index 02464553bd..0694bd1a5d 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/AddTokenContent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/AddTokenContent.kt @@ -22,14 +22,14 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import com.tangem.common.ui.account.AccountIcon import com.tangem.common.ui.account.AccountIconPreviewData +import com.tangem.common.ui.account.PortfolioSelectRow +import com.tangem.common.ui.account.PortfolioSelectUM import com.tangem.common.ui.account.toUM import com.tangem.core.ui.components.PrimaryButtonIconEnd import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH16 import com.tangem.core.ui.components.SpacerW12 -import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.token.TokenItem @@ -63,12 +63,11 @@ internal fun AddTokenContent(state: AddTokenUM, modifier: Modifier = Modifier) { SpacerH(TangemTheme.dimens.spacing14) Column( - modifier = Modifier.background( - color = TangemTheme.colors.background.action, - shape = RoundedCornerShape(TangemTheme.dimens.radius14), - ), + modifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.action), ) { - PortfolioRow(state.portfolio) + PortfolioSelectRow(state.portfolio) HorizontalDivider( modifier = Modifier.padding(horizontal = 12.dp), thickness = TangemTheme.dimens.size0_5, @@ -86,50 +85,6 @@ internal fun AddTokenContent(state: AddTokenUM, modifier: Modifier = Modifier) { } } -@Composable -private fun PortfolioRow(state: AddTokenUM.Portfolio, modifier: Modifier = Modifier) { - Row( - modifier = modifier - .clickable(enabled = state.editable, onClick = state.onClick) - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val leftText = if (state.isAccountMode) R.string.account_details_title else R.string.wc_common_wallet - Text( - modifier = Modifier.weight(1f), - text = stringResourceSafe(leftText), - style = TangemTheme.typography.body1, - color = TangemTheme.colors.text.primary1, - ) - SpacerW12() - if (state.accountIconUM != null) { - AccountIcon( - name = state.name, - icon = state.accountIconUM, - size = AccountIconSize.Small, - ) - } - Text( - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 4.dp), - text = state.name.resolveReference(), - style = TangemTheme.typography.body1, - color = TangemTheme.colors.text.primary1, - ) - if (state.editable) { - Icon( - modifier = Modifier - .size(width = 18.dp, height = 24.dp) - .testTag(WalletConnectBottomSheetTestTags.NETWORKS_SELECTOR_ICON), - painter = painterResource(id = R.drawable.ic_select_18_24), - contentDescription = null, - tint = TangemTheme.colors.icon.informative, - ) - } - } -} - @Composable private fun NetworkRow(state: AddTokenUM.Network, modifier: Modifier = Modifier) { Row( @@ -240,17 +195,19 @@ private class PreviewProvider : PreviewParameterProvider { ) val account - get() = AddTokenUM.Portfolio( - accountIconUM = AccountIconPreviewData.randomAccountIcon(), + get() = PortfolioSelectUM( + icon = AccountIconPreviewData.randomAccountIcon(), name = AccountName.DefaultMain.toUM().value, - editable = true, + isAccountMode = true, + isMultiChoice = true, onClick = {}, ) val wallet - get() = AddTokenUM.Portfolio( - accountIconUM = null, - name = stringReference("Wallet"), - editable = true, + get() = PortfolioSelectUM( + icon = null, + name = stringReference("Wallet Name"), + isMultiChoice = false, + isAccountMode = false, onClick = {}, ) @@ -271,7 +228,7 @@ private class PreviewProvider : PreviewParameterProvider { AddTokenUM( tokenToAdd = tokenState, network = networkUM.copy(editable = false), - portfolio = wallet.copy(editable = false), + portfolio = wallet.copy(isMultiChoice = false), button = button.copy( isEnabled = true, isTangemIconVisible = true, 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/add/impl/ui/state/AddTokenUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/AddTokenUM.kt index 7ab163ffb2..4ca5d4b673 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/AddTokenUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/AddTokenUM.kt @@ -1,24 +1,16 @@ package com.tangem.features.markets.portfolio.add.impl.ui.state import androidx.annotation.DrawableRes -import com.tangem.common.ui.account.CryptoPortfolioIconUM +import com.tangem.common.ui.account.PortfolioSelectUM import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.TextReference data class AddTokenUM( val tokenToAdd: TokenItemState, val network: Network, - val portfolio: Portfolio, + val portfolio: PortfolioSelectUM, val button: Button, ) { - data class Portfolio( - val accountIconUM: CryptoPortfolioIconUM?, - val name: TextReference, - val editable: Boolean, - val onClick: () -> Unit, - ) { - val isAccountMode get() = accountIconUM != null - } data class Network( @DrawableRes val icon: Int, 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/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt index 5b13ba643c..a25fa8ec5c 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt @@ -4,14 +4,17 @@ import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM import com.tangem.features.onramp.tokenlist.entity.TokenListUM import com.tangem.features.onramp.tokenlist.entity.TokenListUMData import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.plus import kotlinx.collections.immutable.toPersistentList internal class UpdateAccountTokenListTransformer( @@ -46,7 +49,12 @@ internal class UpdateAccountTokenListTransformer( ) } else { TokenListUMData.TokenList( - tokensList = accountList.flatMap { (_, currencyList) -> + tokensList = persistentListOf( + TokensListItemUM.GroupTitle( + id = "available_tokens_title", + text = resourceReference(R.string.exchange_tokens_available_tokens_header), + ), + ) + accountList.flatMap { (_, currencyList) -> currencyList.asSequence().map { (isAvailable, status) -> if (isAvailable) { availableConverter.convert(status) diff --git a/features/referral/data/src/main/java/com/tangem/feature/referral/data/ReferralRepositoryImpl.kt b/features/referral/data/src/main/java/com/tangem/feature/referral/data/ReferralRepositoryImpl.kt index e748365489..6db3d31efa 100644 --- a/features/referral/data/src/main/java/com/tangem/feature/referral/data/ReferralRepositoryImpl.kt +++ b/features/referral/data/src/main/java/com/tangem/feature/referral/data/ReferralRepositoryImpl.kt @@ -9,6 +9,7 @@ import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.StartReferralBody import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.referral.converters.ReferralConverter @@ -20,7 +21,6 @@ import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject -@Suppress("LongParameterList") internal class ReferralRepositoryImpl @Inject constructor( private val referralApi: TangemTechApi, private val referralConverter: ReferralConverter, @@ -74,7 +74,11 @@ internal class ReferralRepositoryImpl @Inject constructor( } } - override suspend fun getCryptoCurrency(userWalletId: UserWalletId, tokenData: TokenData): CryptoCurrency? { + override suspend fun getCryptoCurrency( + userWalletId: UserWalletId, + tokenData: TokenData, + accountIndex: DerivationIndex?, + ): CryptoCurrency? { val userWallet = withContext(coroutineDispatcher.io) { userWalletsStore.getSyncOrNull(userWalletId) ?: error("Wallet $userWalletId not found") } @@ -97,12 +101,14 @@ internal class ReferralRepositoryImpl @Inject constructor( blockchain = blockchain, extraDerivationPath = null, userWallet = userWallet, + accountIndex = accountIndex, ) } else { cryptoCurrencyFactory.createCoin( blockchain = blockchain, extraDerivationPath = null, userWallet = userWallet, + accountIndex = accountIndex, ) } } 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/ReferralInteractor.kt b/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralInteractor.kt index a673964cf8..67af26dc2a 100644 --- a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralInteractor.kt +++ b/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralInteractor.kt @@ -1,6 +1,7 @@ package com.tangem.feature.referral.domain import com.tangem.domain.models.PortfolioId +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.referral.domain.models.ReferralData @@ -12,5 +13,9 @@ interface ReferralInteractor { suspend fun startReferral(portfolioId: PortfolioId): ReferralData - suspend fun getCryptoCurrency(userWalletId: UserWalletId, tokenData: TokenData): CryptoCurrency? + suspend fun getCryptoCurrency( + userWalletId: UserWalletId, + tokenData: TokenData, + accountIndex: DerivationIndex?, + ): CryptoCurrency? } \ No newline at end of file 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..78d092c2da 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,7 +2,11 @@ package com.tangem.feature.referral.domain import arrow.core.getOrElse import com.tangem.common.core.TangemSdkError +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.models.PortfolioId +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase @@ -21,6 +25,8 @@ internal class ReferralInteractorImpl( private val derivePublicKeysUseCase: DerivePublicKeysUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, + private val singleAccountSupplier: SingleAccountSupplier, ) : ReferralInteractor { private val tokensForReferral = mutableListOf() @@ -42,30 +48,47 @@ internal class ReferralInteractorImpl( error("Failed to get user wallet $userWalletId: $it") } - val cryptoCurrency = repository.getCryptoCurrency(userWalletId = userWallet.walletId, tokenData = tokenData) + val accountIndex = when (portfolioId) { + is PortfolioId.Account -> { + val account = singleAccountSupplier.getSyncOrNull( + params = SingleAccountProducer.Params(accountId = portfolioId.accountId), + ) + ?: error("Account not found: ${portfolioId.accountId}") + + account.derivationIndex + } + is PortfolioId.Wallet -> null + } + + val cryptoCurrency = getCryptoCurrency( + userWalletId = portfolioId.userWalletId, + tokenData = tokenData, + accountIndex = accountIndex, + ) ?: 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() + } - val publicAddress = when (portfolioId) { - is PortfolioId.Account -> TODO("account") - is PortfolioId.Wallet -> userWalletManager.getWalletAddress( - networkId = tokenData.networkId, - derivationPath = cryptoCurrency.network.derivationPath.value, - ) + addCryptoCurrenciesUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + ) + } } + .onLeft(Timber::e) + + val publicAddress = userWalletManager.getWalletAddress( + networkId = tokenData.networkId, + derivationPath = cryptoCurrency.network.derivationPath.value, + ) return repository.startReferral( walletId = userWalletManager.getWalletId(), @@ -75,8 +98,17 @@ internal class ReferralInteractorImpl( ) } - override suspend fun getCryptoCurrency(userWalletId: UserWalletId, tokenData: TokenData): CryptoCurrency? = - repository.getCryptoCurrency(userWalletId = userWalletId, tokenData = tokenData) + override suspend fun getCryptoCurrency( + userWalletId: UserWalletId, + tokenData: TokenData, + accountIndex: DerivationIndex?, + ): CryptoCurrency? { + return repository.getCryptoCurrency( + userWalletId = userWalletId, + tokenData = tokenData, + accountIndex = accountIndex, + ) + } private fun saveReferralTokens(tokens: List) { tokensForReferral.clear() diff --git a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralRepository.kt b/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralRepository.kt index a206ad34eb..02171d3191 100644 --- a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralRepository.kt +++ b/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralRepository.kt @@ -1,5 +1,6 @@ package com.tangem.feature.referral.domain +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.referral.domain.models.ReferralData @@ -16,5 +17,10 @@ interface ReferralRepository { /** Starts user referral program */ suspend fun startReferral(walletId: String, networkId: String, tokenId: String, address: String): ReferralData - suspend fun getCryptoCurrency(userWalletId: UserWalletId, tokenData: TokenData): CryptoCurrency? + /** Returns [CryptoCurrency] by [tokenData] for specific [userWalletId] and optional [accountIndex] */ + suspend fun getCryptoCurrency( + userWalletId: UserWalletId, + tokenData: TokenData, + accountIndex: DerivationIndex?, + ): CryptoCurrency? } \ No newline at end of file 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..09a1b77423 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,11 @@ 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.account.supplier.SingleAccountSupplier 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 +27,8 @@ class ReferralDomainModule { derivePublicKeysUseCase: DerivePublicKeysUseCase, getUserWalletUseCase: GetUserWalletUseCase, addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, + manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, + singleAccountSupplier: SingleAccountSupplier, ): ReferralInteractor { return ReferralInteractorImpl( repository = referralRepository, @@ -32,6 +36,8 @@ class ReferralDomainModule { derivePublicKeysUseCase = derivePublicKeysUseCase, getUserWalletUseCase = getUserWalletUseCase, addCryptoCurrenciesUseCase = addCryptoCurrenciesUseCase, + manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase, + singleAccountSupplier = singleAccountSupplier, ) } } \ No newline at end of file diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/DefaultReferralComponent.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/DefaultReferralComponent.kt index da7d10bcf7..d85d5291f1 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/DefaultReferralComponent.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/DefaultReferralComponent.kt @@ -17,6 +17,7 @@ import com.tangem.features.account.PortfolioSelectorComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.serialization.builtins.serializer class DefaultReferralComponent @AssistedInject constructor( private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, @@ -27,7 +28,7 @@ class DefaultReferralComponent @AssistedInject constructor( private val model: ReferralModel = getOrCreateModel(params) private val bottomSheetSlot = childSlot( source = model.bottomSheetNavigation, - serializer = null, + serializer = Unit.serializer(), handleBackButton = false, childFactory = { configuration, context -> bottomSheetChild(context) }, ) diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/model/AccountAwardConverter.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/model/AccountAwardConverter.kt index cd04093e59..e5f6db7819 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/model/AccountAwardConverter.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/model/AccountAwardConverter.kt @@ -1,5 +1,6 @@ package com.tangem.feature.referral.model +import com.tangem.common.ui.account.PortfolioSelectUM import com.tangem.common.ui.account.toUM import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.getFormattedCryptoAmount import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.getFormattedFiatAmount @@ -14,8 +15,10 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.feature.referral.models.ReferralStateHolder.AccountAward import com.tangem.utils.converter.Converter +@Suppress("LongParameterList") internal class AccountAwardConverter( private val isBalanceHidden: Boolean, + private val isSingleAccount: Boolean, private val appCurrency: AppCurrency, private val awardCryptoCurrency: CryptoCurrency, private val accountAwardToken: CryptoCurrencyStatus?, @@ -56,11 +59,15 @@ internal class AccountAwardConverter( } return AccountAward( - accountName = cryptoPortfolio.account.accountName.toUM().value, - accountIcon = cryptoPortfolio.account.icon.toUM(), - onAccountClick = onAccountClick, isBalanceHidden = isBalanceHidden, tokenState = tokenState, + accountSelectUM = PortfolioSelectUM( + icon = cryptoPortfolio.account.icon.toUM(), + name = cryptoPortfolio.account.accountName.toUM().value, + isAccountMode = true, + onClick = onAccountClick, + isMultiChoice = !isSingleAccount, + ), ) } } \ No newline at end of file diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt index 5fdfcef5ff..4564ce9ded 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -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 @@ -23,7 +22,6 @@ import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase -import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.models.PortfolioId @@ -87,8 +85,8 @@ internal class ReferralModel @Inject constructor( private set val portfolioSelectorCallback = object : PortfolioSelectorComponent.BottomSheetCallback { - override val onDismiss: () -> Unit = { bottomSheetNavigation::dismiss } - override val onBack: () -> Unit = { bottomSheetNavigation::dismiss } + override val onDismiss: () -> Unit = { bottomSheetNavigation.dismiss() } + override val onBack: () -> Unit = { bottomSheetNavigation.dismiss() } } init { @@ -117,39 +115,45 @@ internal class ReferralModel @Inject constructor( } private fun combineAccountUI(referralData: ReferralData): Flow = combine( - flow = portfolioSelectorController.selectedAccountWithData(portfolioFetcher), - flow2 = getBalanceHidingSettingsFlow(), - flow3 = appCurrencyFlow(), - ) { a, b, c -> Triple(a, b, c) }.mapLatest { triple -> - val (_, selectedAccount) = triple.first ?: return@mapLatest null - val isBalanceHidden = triple.second - val appCurrency: AppCurrency = triple.third - val awardCryptoCurrency = referralInteractor.getCryptoCurrency( - userWalletId = params.userWalletId, - tokenData = referralData.getToken(), - ) ?: return@mapLatest null + flow = portfolioSelectorController.selectedAccountWithData(portfolioFetcher) + .onEach { bottomSheetNavigation.dismiss() }, + flow2 = getBalanceHidingSettingsUseCase.isBalanceHidden(), + flow3 = getSelectedAppCurrencyUseCase.invokeOrDefault(), + flow4 = portfolioFetcher.data, + ) { pair, isBalanceHidden, appCurrency, portfolios -> + val selectedAccount = pair?.second ?: return@combine null + val cryptoPortfolio = when (selectedAccount) { is AccountStatus.CryptoPortfolio -> selectedAccount } + + val awardCryptoCurrency = referralInteractor.getCryptoCurrency( + userWalletId = params.userWalletId, + tokenData = referralData.getToken(), + accountIndex = cryptoPortfolio.account.derivationIndex, + ) ?: return@combine null + val accountAwardToken = cryptoPortfolio.tokenList.flattenCurrencies() .find { it.currency.id == awardCryptoCurrency.id } - return@mapLatest AccountAwardConverter( + + return@combine AccountAwardConverter( isBalanceHidden = isBalanceHidden, awardCryptoCurrency = awardCryptoCurrency, accountAwardToken = accountAwardToken, cryptoPortfolio = cryptoPortfolio, appCurrency = appCurrency, + isSingleAccount = portfolios.isSingleChoice, onAccountClick = { bottomSheetNavigation.activate(Unit) }, ).convert(Unit) } private fun createInitiallyUiState() = ReferralStateHolder( - headerState = ReferralStateHolder.HeaderState( + headerState = HeaderState( onBackClicked = appRouter::pop, ), referralInfoState = ReferralInfoState.Loading, errorSnackbar = null, - analytics = ReferralStateHolder.Analytics( + analytics = Analytics( onAgreementClicked = ::onAgreementClicked, onCopyClicked = ::onCopyClicked, onShareClicked = ::onShareClicked, @@ -277,18 +281,6 @@ internal class ReferralModel @Inject constructor( } } - private fun getBalanceHidingSettingsFlow(): Flow { - return getBalanceHidingSettingsUseCase() - .map { it.isBalanceHidden } - .distinctUntilChanged() - } - - private fun appCurrencyFlow(): Flow { - return getSelectedAppCurrencyUseCase() - .map { it.getOrElse { AppCurrency.Default } } - .distinctUntilChanged() - } - @Suppress("MagicNumber") private fun ReferralInfo.getAddressValue(): String { check(address.length > 5) { "Invalid address" } diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/models/ReferralStateHolder.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/models/ReferralStateHolder.kt index 616e7715ab..9f0a32f146 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/models/ReferralStateHolder.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/models/ReferralStateHolder.kt @@ -1,9 +1,8 @@ package com.tangem.feature.referral.models import androidx.annotation.DrawableRes -import com.tangem.common.ui.account.CryptoPortfolioIconUM +import com.tangem.common.ui.account.PortfolioSelectUM import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.core.ui.extensions.TextReference import com.tangem.feature.referral.domain.models.ExpectedAwards internal data class ReferralStateHolder( @@ -64,8 +63,6 @@ internal data class ReferralStateHolder( data class AccountAward( val tokenState: TokenItemState, val isBalanceHidden: Boolean, - val accountName: TextReference, - val accountIcon: CryptoPortfolioIconUM, - val onAccountClick: () -> Unit, + val accountSelectUM: PortfolioSelectUM, ) } \ No newline at end of file diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ParticipateBottomBlock.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ParticipateBottomBlock.kt index 12ebf8162c..0165c19809 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ParticipateBottomBlock.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ParticipateBottomBlock.kt @@ -10,25 +10,33 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow +import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountIcon import com.tangem.core.res.getStringSafe import com.tangem.core.ui.components.PrimaryButtonIconStart +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerW12 +import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.rows.RoundableCornersRow import com.tangem.core.ui.extensions.pluralStringResourceSafe +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.feature.referral.domain.models.ExpectedAward import com.tangem.feature.referral.domain.models.ExpectedAwards +import com.tangem.feature.referral.models.ReferralStateHolder import com.tangem.feature.referral.presentation.R import kotlinx.coroutines.launch @@ -43,6 +51,7 @@ internal fun ParticipateBottomBlock( onAgreementClick: () -> Unit, onCopyClick: () -> Unit, onShareClick: (String) -> Unit, + accountAward: ReferralStateHolder.AccountAward?, ) { Column( modifier = Modifier @@ -53,7 +62,7 @@ internal fun ParticipateBottomBlock( .padding(horizontal = TangemTheme.dimens.spacing16), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), ) { - PersonalCodeCard(code = code) + PersonalCodeCard(code = code, accountAward = accountAward) AdditionalButtons( code = code, shareLink = shareLink, @@ -242,19 +251,13 @@ private fun ExtraItems(extraItems: List, overallItemsLastIndex: I } @Composable -private fun PersonalCodeCard(code: String) { +private fun PersonalCodeCard(code: String, accountAward: ReferralStateHolder.AccountAward?) { Column( modifier = Modifier - .shadow( - elevation = TangemTheme.dimens.elevation2, - shape = RoundedCornerShape(TangemTheme.dimens.radius12), - ) - .background( - color = TangemTheme.colors.background.secondary, - shape = RoundedCornerShape(TangemTheme.dimens.radius12), - ) + .clip(RoundedCornerShape(TangemTheme.dimens.radius12)) + .background(color = TangemTheme.colors.background.primary) .fillMaxWidth() - .padding(vertical = TangemTheme.dimens.spacing12), + .padding(top = TangemTheme.dimens.spacing12), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), ) { @@ -270,6 +273,53 @@ private fun PersonalCodeCard(code: String) { maxLines = 1, style = TangemTheme.typography.h2, ) + + if (accountAward != null) { + AwardAccount(accountAward) + } else { + SpacerH12() + } + } +} + +@Composable +private fun AwardAccount(accountAward: ReferralStateHolder.AccountAward) { + Column { + HorizontalDivider( + thickness = 0.5.dp, + color = TangemTheme.colors.stroke.primary, + modifier = Modifier.padding(horizontal = 12.dp), + ) + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResourceSafe(R.string.account_for_rewards), + style = TangemTheme.typography.body1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = TangemTheme.colors.text.secondary, + ) + SpacerW12() + val icon = accountAward.accountSelectUM.icon + if (icon != null) { + AccountIcon( + name = accountAward.accountSelectUM.name, + icon = icon, + size = AccountIconSize.Small, + ) + } + Text( + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 4.dp), + text = accountAward.accountSelectUM.name.resolveReference(), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + } } } @@ -351,10 +401,11 @@ private fun ParticipateBottomBlockPreview( code = data.code, shareLink = data.shareLink, expectedAwards = data.expectedAwards, - onAgreementClick = data.onAgreementClick, snackbarHostState = SnackbarHostState(), + onAgreementClick = data.onAgreementClick, onCopyClick = data.onCopyClick, onShareClick = data.onShareClick, + accountAward = data.accountAward, ) } } @@ -410,12 +461,18 @@ private class ParticipateBottomBlockDataProvider : CollectionPreviewParameterPro purchasedWalletCount = 0, expectedAwards = null, ), + ParticipateBottomBlockData( + purchasedWalletCount = 0, + expectedAwards = null, + accountAward = accountReward, + ), ), ) private data class ParticipateBottomBlockData( val purchasedWalletCount: Int, val expectedAwards: ExpectedAwards?, + val accountAward: ReferralStateHolder.AccountAward? = null, val code: String = "x4JDK", val shareLink: String = "", val onAgreementClick: () -> Unit = {}, diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ReferralScreen.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ReferralScreen.kt index 3c1f14d3fc..76efe5d729 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ReferralScreen.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/ui/ReferralScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -26,14 +27,15 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.tangem.common.ui.account.AccountIcon import com.tangem.common.ui.account.AccountIconPreviewData +import com.tangem.common.ui.account.PortfolioSelectRow +import com.tangem.common.ui.account.PortfolioSelectUM +import com.tangem.common.ui.account.toUM import com.tangem.core.res.getStringSafe import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.SpacerH16 import com.tangem.core.ui.components.SpacerH24 import com.tangem.core.ui.components.SpacerH32 -import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.snackbar.CopiedTextSnackbar @@ -41,13 +43,13 @@ import com.tangem.core.ui.components.snackbar.TangemSnackbar import com.tangem.core.ui.components.token.TokenItem 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.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.ReferralProgramScreenTestTags +import com.tangem.domain.models.account.AccountName import com.tangem.feature.referral.domain.models.ExpectedAward import com.tangem.feature.referral.domain.models.ExpectedAwards import com.tangem.feature.referral.models.DemoModeException @@ -183,6 +185,7 @@ private fun ReferralInfo( code = state.code, shareLink = state.shareLink, expectedAwards = state.expectedAwards, + accountAward = state.accountAward, snackbarHostState = snackbarHostState, onAgreementClick = onAgreementClick, onCopyClick = stateHolder.analytics.onCopyClicked, @@ -390,10 +393,8 @@ private fun NonParticipateAccountAwardBlock(accountAward: AccountAward) { Column( modifier = Modifier .padding(horizontal = TangemTheme.dimens.spacing16) - .background( - color = TangemTheme.colors.background.primary, - shape = RoundedCornerShape(TangemTheme.dimens.spacing16), - ), + .clip(RoundedCornerShape(TangemTheme.dimens.spacing16)) + .background(color = TangemTheme.colors.background.primary), ) { Text( modifier = Modifier.padding( @@ -418,44 +419,7 @@ private fun NonParticipateAccountAwardBlock(accountAward: AccountAward) { color = TangemTheme.colors.stroke.primary, modifier = Modifier.padding(horizontal = 12.dp), ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(TangemTheme.dimens.spacing12), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.weight(1f), - text = stringResourceSafe(R.string.account_details_title), - style = TangemTheme.typography.body1, - color = TangemTheme.colors.text.primary1, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - AccountIcon( - name = accountAward.accountName, - icon = accountAward.accountIcon, - size = AccountIconSize.Small, - ) - - Text( - modifier = Modifier.padding(horizontal = 4.dp), - text = accountAward.accountName.resolveReference(), - style = TangemTheme.typography.body1, - color = TangemTheme.colors.text.primary1, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - Icon( - modifier = Modifier.size(width = 18.dp, height = 24.dp), - painter = painterResource(id = R.drawable.ic_select_18_24), - contentDescription = null, - tint = TangemTheme.colors.icon.informative, - ) - } + PortfolioSelectRow(accountAward.accountSelectUM) } } @@ -516,6 +480,7 @@ private fun Preview_ReferralScreen_Participant_With_Referrals() { code = "x4JdK", shareLink = "", url = "", + accountAward = accountReward, expectedAwards = ExpectedAwards( numberOfWallets = 5, expectedAwards = listOf( @@ -572,6 +537,41 @@ private fun Preview_ReferralScreen_NonParticipant() { } } +internal val account = PortfolioSelectUM( + icon = AccountIconPreviewData.randomAccountIcon(), + name = AccountName.DefaultMain.toUM().value, + isAccountMode = true, + isMultiChoice = true, + onClick = {}, +) + +internal val accountReward + get() = AccountAward( + accountSelectUM = account, + isBalanceHidden = false, + tokenState = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = R.drawable.img_tron_22, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + titleState = TokenItemState.TitleState.Content( + text = stringReference(value = "Tether"), + ), + fiatAmountState = FiatAmountState.Content( + text = "129,65 $", + ), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "129,65 USDT"), + subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")), + onItemClick = null, + onItemLongClick = null, + ), + ) + @Preview(widthDp = 360, showBackground = true) @Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -587,33 +587,7 @@ private fun Preview_ReferralScreen_NonParticipantAccount() { url = "", onParticipateClicked = {}, participateButtonIcon = R.drawable.ic_tangem_24, - accountAward = AccountAward( - onAccountClick = {}, - accountIcon = AccountIconPreviewData.randomAccountIcon(), - accountName = stringReference("Main Account"), - isBalanceHidden = false, - tokenState = TokenItemState.Content( - id = UUID.randomUUID().toString(), - iconState = CurrencyIconState.TokenIcon( - url = null, - topBadgeIconResId = R.drawable.img_tron_22, - fallbackTint = TangemColorPalette.Black, - fallbackBackground = TangemColorPalette.Meadow, - isGrayscale = false, - shouldShowCustomBadge = false, - ), - titleState = TokenItemState.TitleState.Content( - text = stringReference(value = "Tether"), - ), - fiatAmountState = FiatAmountState.Content( - text = "129,65 $", - ), - subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "129,65 USDT"), - subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")), - onItemClick = {}, - onItemLongClick = {}, - ), - ), + accountAward = accountReward, ), errorSnackbar = null, analytics = Analytics( diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/subcomponents/destination/entity/DestinationRecipientListUM.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/subcomponents/destination/entity/DestinationRecipientListUM.kt index a6c22f118b..ff6a22684a 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/subcomponents/destination/entity/DestinationRecipientListUM.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/subcomponents/destination/entity/DestinationRecipientListUM.kt @@ -2,7 +2,9 @@ package com.tangem.features.send.v2.api.subcomponents.destination.entity import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountTitleUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId @@ -11,6 +13,7 @@ data class DestinationRecipientListUM( val id: String, val title: TextReference = TextReference.Companion.EMPTY, val subtitle: TextReference = TextReference.Companion.EMPTY, + val accountTitleUM: AccountTitleUM.Account? = null, val timestamp: TextReference? = null, val subtitleEndOffset: Int = 0, @DrawableRes val subtitleIconRes: Int? = null, @@ -18,5 +21,6 @@ data class DestinationRecipientListUM( val isLoading: Boolean = false, val userWalletId: UserWalletId? = null, val network: Network? = null, + val accountId: AccountId? = null, val address: String? = null, ) \ No newline at end of file diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/subcomponents/destination/entity/DestinationUM.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/subcomponents/destination/entity/DestinationUM.kt index 8a0a3d2976..f84dae8eec 100644 --- a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/subcomponents/destination/entity/DestinationUM.kt +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/subcomponents/destination/entity/DestinationUM.kt @@ -1,7 +1,6 @@ package com.tangem.features.send.v2.api.subcomponents.destination.entity import androidx.compose.runtime.Immutable -import com.tangem.common.ui.account.AccountTitleUM import kotlinx.collections.immutable.ImmutableList @Immutable @@ -16,10 +15,10 @@ sealed class DestinationUM { val recent: ImmutableList, val wallets: ImmutableList, val networkName: String, - val accountTitleUM: AccountTitleUM?, val isValidating: Boolean = false, val isInitialized: Boolean = false, val isRecentHidden: Boolean, + val isAccountsMode: Boolean? = null, ) : DestinationUM() data class Empty( 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..0fb799cf17 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 @@ -20,6 +20,8 @@ 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.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase @@ -31,6 +33,7 @@ import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.settings.NeverShowTapHelpUseCase import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase +import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase @@ -105,6 +108,9 @@ internal class SendConfirmModel @Inject constructor( private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val sendAmountReduceTrigger: SendAmountReduceTrigger, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, + private val currenciesRepository: CurrenciesRepository, sendBalanceUpdaterFactory: SendBalanceUpdater.Factory, ) : Model(), SendConfirmClickIntents, FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback { @@ -429,13 +435,20 @@ 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 accountId = receivingUserWallet.accountId ?: return@launch + + val tokenToAdd = currenciesRepository.createTokenCurrency(cryptoCurrency, network) + manageCryptoCurrenciesUseCase(accountId = accountId, add = tokenToAdd) + } else { + withContext(NonCancellable) { + addCryptoCurrenciesUseCase( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + network = network, + ) + } + }.onLeft(Timber::e) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt index f3271ad09f..7926083e2e 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt @@ -180,8 +180,8 @@ internal class SendAmountModel @Inject constructor( private fun initialState( cryptoCurrencyStatus: CryptoCurrencyStatus, - @Suppress("UnusedParameter") account: Account.CryptoPortfolio?, - @Suppress("UnusedParameter") isAccountsMode: Boolean, + account: Account.CryptoPortfolio?, + isAccountsMode: Boolean, ) { if (uiState.value is AmountState.Empty && userWallet != null) { val isOnlyOneWallet = getWalletsUseCase.invokeSync().size == 1 diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index dd7319a481..80f53e3613 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -12,8 +12,9 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles -import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.models.account.Account import com.tangem.domain.models.network.CryptoCurrencyAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isLocked @@ -68,9 +69,9 @@ internal class SendDestinationModel @Inject constructor( private val listenToQrScanningUseCase: ListenToQrScanningUseCase, private val parseQrCodeUseCase: ParseQrCodeUseCase, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, - private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, private val analyticsEventHandler: AnalyticsEventHandler, private val accountsFeatureToggles: AccountsFeatureToggles, + private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier, ) : Model(), SendDestinationClickIntents { private val params: SendDestinationComponentParams = paramsContainer.require() @@ -189,8 +190,14 @@ internal class SendDestinationModel @Inject constructor( private fun getWalletsAndRecent() { combine( - flow = getWalletsUseCase().conflate().map { - waitForDelay(RECENT_LOAD_DELAY) { it.toAvailableWallets() } + flow = if (accountsFeatureToggles.isFeatureEnabled) { + getAddedAddresses() + } else { + getWalletsUseCase().conflate().map { + waitForDelay(RECENT_LOAD_DELAY) { + it.toAvailableWallets() + } + } }, flow2 = getFixedTxHistoryItemsUseCase( userWalletId = userWalletId, @@ -200,12 +207,7 @@ internal class SendDestinationModel @Inject constructor( waitForDelay(RECENT_LOAD_DELAY) { it } }.conflate(), flow3 = isAccountsModeEnabledUseCase().distinctUntilChanged(), - flow4 = if (accountsFeatureToggles.isFeatureEnabled) { - getAccountCurrencyStatusUseCase(userWalletId, cryptoCurrency).distinctUntilChanged() - } else { - flowOf(null) - }, - ) { destinationWalletList, txHistoryList, isAccountsMode, accountCurrencyStatus -> + ) { destinationWalletList, txHistoryList, isAccountsMode -> val isSelfSendAvailable = isSelfSendAvailableUseCase.invokeSync( userWalletId = userWalletId, network = cryptoCurrency.network, @@ -217,7 +219,6 @@ internal class SendDestinationModel @Inject constructor( isSelfSendAvailable = isSelfSendAvailable, destinationWalletList = destinationWalletList, txHistoryList = txHistoryList, - account = accountCurrencyStatus?.account, isAccountsMode = isAccountsMode, ), ) @@ -266,6 +267,46 @@ internal class SendDestinationModel @Inject constructor( } } + private fun getAddedAddresses(): Flow> { + return combine( + flow = getWalletsUseCase().conflate(), + flow2 = multiAccountStatusListSupplier(Unit).conflate(), + ) { wallets, accountList -> + val cryptoCurrencyNetwork = cryptoCurrency.network + + coroutineScope { + accountList.mapNotNull { accountStatusList -> + val wallet = + wallets.filterNot { it.isLocked }.firstOrNull { it.walletId == accountStatusList.userWalletId } + ?: return@mapNotNull null + + async { + accountStatusList.accountStatuses.map { accountStatus -> + async { + accountStatus.flattenCurrencies() + .filter { it.currency.network.rawId == cryptoCurrencyNetwork.rawId } + .mapNotNull { cryptoCurrencyStatus -> + val address = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value + ?: return@mapNotNull null + + async { + DestinationWalletUM( + name = wallet.name, + address = address, + cryptoCurrency = cryptoCurrencyStatus.currency, + userWalletId = wallet.walletId, + account = accountStatus.account as? Account.CryptoPortfolio, + ) + } + }.awaitAll() + } + }.awaitAll().flatten() + } + }.awaitAll().flatten() + } + }.flowOn(dispatchers.default) + } + private fun validate(address: String, memo: String?, type: EnterAddressSource? = null) { modelScope.launch { _uiState.update(SendDestinationValidationStartedTransformer) @@ -291,7 +332,12 @@ internal class SendDestinationModel @Inject constructor( ), ) } - _uiState.update(SendDestinationValidationResultTransformer(addressValidationResult, memoValidationResult)) + _uiState.update( + SendDestinationValidationResultTransformer( + addressValidationResult, + memoValidationResult, + ), + ) autoNextFromRecipient(type, addressValidationResult.isRight(), memoValidationResult.isRight()) }.saveIn(validationJobHolder) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/converters/SendRecipientWalletListConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/converters/SendRecipientWalletListConverter.kt index 67b3584c57..1e43755cbc 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/converters/SendRecipientWalletListConverter.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/converters/SendRecipientWalletListConverter.kt @@ -1,12 +1,15 @@ package com.tangem.features.send.v2.subcomponents.destination.model.converters +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.toUM import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationRecipientListUM import com.tangem.features.send.v2.subcomponents.destination.model.transformers.WALLET_DEFAULT_COUNT import com.tangem.features.send.v2.subcomponents.destination.model.transformers.WALLET_KEY_TAG import com.tangem.features.send.v2.subcomponents.destination.model.transformers.emptyListState -import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationRecipientListUM import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationWalletUM +import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.toPersistentList @@ -14,6 +17,7 @@ import kotlinx.collections.immutable.toPersistentList internal class SendRecipientWalletListConverter( private val senderAddress: String?, private val isSelfSendAvailable: Boolean, + private val isAccountsMode: Boolean, ) : Converter, PersistentList> { override fun convert(value: List): PersistentList { @@ -46,13 +50,24 @@ internal class SendRecipientWalletListConverter( wallet.name } + val account = wallet.account DestinationRecipientListUM( id = "${WALLET_KEY_TAG}${walletsCounter++}", title = stringReference(wallet.address), subtitle = stringReference(name), + accountTitleUM = if (account != null && isAccountsMode) { + AccountTitleUM.Account( + name = account.accountName.toUM().value, + icon = account.icon.toUM(), + prefixText = stringReference(StringsSigns.DOT), + ) + } else { + null + }, address = wallet.address, userWalletId = wallet.userWalletId, network = wallet.cryptoCurrency.network, + accountId = account?.accountId, ) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt index f9ad07bde8..151c0d6900 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt @@ -54,12 +54,12 @@ internal class SendDestinationInitialStateTransformer( isValuePasted = false, ) }, - accountTitleUM = null, wallets = loadingListState(WALLET_KEY_TAG, WALLET_DEFAULT_COUNT), recent = loadingListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT), networkName = cryptoCurrency.network.name, isValidating = false, isRecentHidden = false, + isAccountsMode = null, ) } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationRecentListTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationRecentListTransformer.kt index 51b11db3df..7dcd652b78 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationRecentListTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationRecentListTransformer.kt @@ -1,17 +1,11 @@ package com.tangem.features.send.v2.subcomponents.destination.model.transformers -import com.tangem.common.ui.account.AccountTitleUM -import com.tangem.common.ui.account.toUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.TxInfo import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.v2.subcomponents.destination.model.converters.SendRecipientHistoryListConverter import com.tangem.features.send.v2.subcomponents.destination.model.converters.SendRecipientWalletListConverter import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationWalletUM -import com.tangem.utils.StringsSigns import com.tangem.utils.transformer.Transformer @Suppress("LongParameterList") @@ -21,25 +15,17 @@ internal class SendDestinationRecentListTransformer( private val isSelfSendAvailable: Boolean, private val destinationWalletList: List, private val txHistoryList: List, - private val account: Account.CryptoPortfolio?, private val isAccountsMode: Boolean, ) : Transformer { override fun transform(prevState: DestinationUM): DestinationUM { val state = prevState as? DestinationUM.Content ?: return prevState return state.copy( - accountTitleUM = if (account != null && isAccountsMode) { - AccountTitleUM.Account( - name = account.accountName.toUM().value, - icon = account.icon.toUM(), - prefixText = stringReference(StringsSigns.DOT), - ) - } else { - AccountTitleUM.Text(TextReference.EMPTY) - }, + isAccountsMode = isAccountsMode, wallets = SendRecipientWalletListConverter( senderAddress = senderAddress, isSelfSendAvailable = isSelfSendAvailable, + isAccountsMode = isAccountsMode, ).convert(destinationWalletList), recent = SendRecipientHistoryListConverter( cryptoCurrency = cryptoCurrency, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt index 54989a861c..134fbad1fb 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt @@ -144,7 +144,6 @@ private class DestinationBlockPreviewProvider : PreviewParameterProvider diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt index 4b68b252ac..14cdeedde4 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt @@ -21,7 +21,6 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import com.tangem.common.ui.account.AccountTitleUM import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.containers.FooterContainer @@ -69,18 +68,18 @@ internal fun SendDestinationContent( onMemoChange = clickIntents::onRecipientMemoValueChange, ) listHeaderItem( - titleRes = when (state.accountTitleUM) { - is AccountTitleUM.Account -> R.string.common_accounts - else -> R.string.send_recipient_wallets_title + titleRes = if (state.isAccountsMode == true) { + R.string.common_accounts + } else { + R.string.send_recipient_wallets_title }, - isLoading = state.accountTitleUM == null, + isLoading = state.isAccountsMode == null, isVisible = wallets.isNotEmpty() && wallets.first().isVisible && !state.isRecentHidden, isFirst = true, ) listItem( list = wallets, isLast = recipients.any { !it.isVisible }, - accountTitleUM = state.accountTitleUM, isBalanceHidden = false, isRecentHidden = state.isRecentHidden, onClick = { title -> @@ -92,7 +91,7 @@ internal fun SendDestinationContent( ) listHeaderItem( titleRes = R.string.send_recent_transactions, - isLoading = state.accountTitleUM == null, + isLoading = state.isAccountsMode == null, isVisible = recipients.isNotEmpty() && recipients.first().isVisible && !state.isRecentHidden, isFirst = wallets.any { !it.isVisible }, ) @@ -245,7 +244,6 @@ private fun LazyListScope.listItem( isBalanceHidden: Boolean, isRecentHidden: Boolean, onClick: (String) -> Unit, - accountTitleUM: AccountTitleUM? = null, ) { items( count = list.size, @@ -259,7 +257,7 @@ private fun LazyListScope.listItem( ListItemWithIcon( title = title, subtitle = item.subtitle.orMaskWithStars(isBalanceHidden).resolveReference(), - accountTitleUM = accountTitleUM, + accountTitleUM = item.accountTitleUM, info = item.timestamp?.resolveReference(), subtitleEndOffset = item.subtitleEndOffset, subtitleIconRes = item.subtitleIconRes, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationWalletUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationWalletUM.kt index fa8afba239..f9f611393b 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationWalletUM.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationWalletUM.kt @@ -1,6 +1,7 @@ package com.tangem.features.send.v2.subcomponents.destination.ui.state import androidx.compose.runtime.Immutable +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId @@ -11,6 +12,7 @@ import com.tangem.domain.models.wallet.UserWalletId * @property userWalletId wallet id * @property address blockchain address * @property cryptoCurrency selected crypto currency + * @property account associated account (if in accounts mode) */ @Immutable data class DestinationWalletUM( @@ -18,4 +20,5 @@ data class DestinationWalletUM( val userWalletId: UserWalletId, val address: String, val cryptoCurrency: CryptoCurrency, + val account: Account.CryptoPortfolio? = null, ) \ No newline at end of file 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 3c8aa515c4..968dff50ae 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/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt index 98710745ed..c948e842c3 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt @@ -310,7 +310,7 @@ private fun SendWithSwapSuccessContent_Preview() { isValidating = false, isInitialized = false, isRecentHidden = false, - accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.send_recipient_wallets_title)), + isAccountsMode = false, ), feeSelectorUM = FeeSelectorUM.Content( fees = TransactionFee.Single( diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index 8adde9bd53..b74d66f7e2 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -36,6 +36,8 @@ dependencies { implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) implementation(projects.domain.express.models) + implementation(projects.domain.account) + implementation(projects.domain.account.status) implementation(projects.features.swap.domain.api) implementation(projects.features.swap.domain.models) diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index e8e10f7165..7c76557067 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -1,6 +1,7 @@ package com.tangem.feature.swap.domain.models.ui import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.feature.swap.domain.models.ExpressDataError @@ -79,6 +80,7 @@ data class TokenSwapInfo( val tokenAmount: SwapAmount, val amountFiat: BigDecimal, val cryptoCurrencyStatus: CryptoCurrencyStatus, + val account: Account.CryptoPortfolio?, ) data class RequestApproveStateData( diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt index 6e4041dee5..f67c4a5801 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt @@ -1,5 +1,7 @@ package com.tangem.feature.swap.domain.models.ui +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.domain.CryptoCurrencySwapInfo import com.tangem.feature.swap.domain.models.domain.SwapProvider @@ -9,12 +11,11 @@ data class TokensDataStateExpress( val allProviders: List, ) { companion object { - val EMPTY = - TokensDataStateExpress( - fromGroup = CurrenciesGroup(emptyList(), emptyList(), false), - toGroup = CurrenciesGroup(emptyList(), emptyList(), false), - allProviders = emptyList(), - ) + val EMPTY = TokensDataStateExpress( + fromGroup = CurrenciesGroup(emptyList(), emptyList(), emptyList(), false), + toGroup = CurrenciesGroup(emptyList(), emptyList(), emptyList(), false), + allProviders = emptyList(), + ) } } @@ -29,5 +30,18 @@ fun TokensDataStateExpress.getGroupWithReverse(isReverseFromTo: Boolean): Curren data class CurrenciesGroup( val available: List, val unavailable: List, + val accountCurrencyList: List, val isAfterSearch: Boolean, +) + +data class AccountSwapAvailability( + val account: Account.CryptoPortfolio, + val currencyList: List, +) + +data class AccountSwapCurrency( + val isAvailable: Boolean, + val account: Account.CryptoPortfolio, + val cryptoCurrencyStatus: CryptoCurrencyStatus, + val providers: List, ) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt index b71ed02569..a9764ab530 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt @@ -3,8 +3,10 @@ package com.tangem.feature.swap.domain import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress import com.tangem.feature.swap.domain.models.ui.getGroupWithReverse +import com.tangem.utils.extensions.orZero import java.math.BigDecimal internal class DefaultInitialToCurrencyResolver( @@ -27,10 +29,40 @@ internal class DefaultInitialToCurrencyResolver( } } + override suspend fun tryGetFromCacheV2( + userWallet: UserWallet, + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + isReverseFromTo: Boolean, + ): AccountSwapCurrency? { + val id = swapTransactionRepository.getLastSwappedCryptoCurrencyId(userWallet.walletId) ?: return null + + return if (id != initialCryptoCurrency.id.value) { + val group = state.getGroupWithReverse(isReverseFromTo) + + group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) -> + currencyList.find { it.isAvailable && it.cryptoCurrencyStatus.currency.id.value == id } + } + } else { + null + } + } + override fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): CryptoCurrencyStatus? { val group = state.getGroupWithReverse(isReverseFromTo) return group.available.maxByOrNull { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO }?.currencyStatus } + + override fun tryGetWithMaxAmountV2(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency? { + val group = state.getGroupWithReverse(isReverseFromTo) + return group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) -> + currencyList.maxByOrNull { swapAccountCurrency -> + swapAccountCurrency.cryptoCurrencyStatus.value.fiatAmount + .takeIf { swapAccountCurrency.isAvailable } + .orZero() + } + } + } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt index c62a113adc..7759a61e98 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt @@ -3,6 +3,7 @@ package com.tangem.feature.swap.domain import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress interface InitialToCurrencyResolver { @@ -15,4 +16,13 @@ interface InitialToCurrencyResolver { ): CryptoCurrencyStatus? fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): CryptoCurrencyStatus? + + suspend fun tryGetFromCacheV2( + userWallet: UserWallet, + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + isReverseFromTo: Boolean, + ): AccountSwapCurrency? + + fun tryGetWithMaxAmountV2(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency? } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 97a8cfc718..73d1b16834 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -1,6 +1,7 @@ package com.tangem.feature.swap.domain import com.tangem.domain.express.models.ExpressOperationType +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId @@ -29,16 +30,23 @@ interface SwapInteractor { * Find best quote for given tokens to swap * under the hood calls different methods to receive data, depends on permission for given token * - * @param fromToken [Currency] from which want to swap - * @param toToken [Currency] that receive after swap - * @param amountToSwap amount you want to swap - * @param selectedFee selected fee to swap + * @param fromToken token from which want to swap + * @param fromAccount account from which swap will be made + * @param toToken token that receive after swap + * @param toAccount account to which receive token after swap + * @param providers list of providers to find quote + * @param amountToSwap amount you want to swap + * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) + * @param selectedFee selected fee to swap * @return */ + @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun findBestQuote( fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, @@ -90,6 +98,19 @@ interface SwapInteractor { isReverseFromTo: Boolean, ): CryptoCurrencyStatus? + /** + * Returns initial currency to swap as AccountSwapCurrency + * + * @param initialCryptoCurrency initial currency selected to swap + * @param state current tokens data state + * @param isReverseFromTo flag indicating the direction of the swap + */ + suspend fun getInitialCurrencyToSwapV2( + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + isReverseFromTo: Boolean, + ): AccountSwapCurrency? + fun getNativeToken(networkId: String): CryptoCurrency interface Factory { diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index bbebf6f219..3310348e4f 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -16,12 +16,16 @@ import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.express.models.ExpressOperationType +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -87,6 +91,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase, private val amountFormatter: AmountFormatter, private val rampStateManager: RampStateManager, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val accountsFeatureToggles: AccountsFeatureToggles, @Assisted private val userWalletId: UserWalletId, ) : SwapInteractor { @@ -102,7 +108,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( } override suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { - val walletCurrencyStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWalletId) + return if (accountsFeatureToggles.isFeatureEnabled) { + getAccountCurrencyTokensDataState(currency) + } else { + getCurrencyTokensDataState(currency) + } + } + + private suspend fun getCurrencyTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { + val walletCurrencyStatuses = getMultiCryptoCurrencyStatusUseCase + .invokeMultiWalletSync(userWalletId) .getOrElse { emptyList() } val walletCurrencyStatusesExceptInitial = walletCurrencyStatuses @@ -146,6 +161,65 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } + private suspend fun getAccountCurrencyTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { + val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull( + SingleAccountStatusListProducer.Params(userWalletId), + )?.accountStatuses.orEmpty() + + val walletAccountCurrencyStatusesExceptInitial = walletAccountCurrencyStatuses + .mapNotNull { accountStatus -> + val filteredCurrencies = accountStatus.flattenCurrencies().filter { + val currencyFilter = it.currency.network.backendId != currency.network.backendId || + it.currency.getContractAddress() != currency.getContractAddress() + + val statusFilter = + it.value is CryptoCurrencyStatus.Loaded || it.value is CryptoCurrencyStatus.NoAccount + val notCustomTokenFilter = !it.currency.isCustom + + statusFilter && currencyFilter && notCustomTokenFilter + } + + if (filteredCurrencies.isNotEmpty()) { + accountStatus.account to filteredCurrencies + } else { + null + } + }.toMap() + + if (walletAccountCurrencyStatusesExceptInitial.isEmpty()) { + return TokensDataStateExpress.EMPTY + } + + val pairsLeast = getPairs( + userWallet = userWallet, + initialCurrency = LeastTokenInfo( + contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", + network = currency.network.backendId, + ), + currenciesList = walletAccountCurrencyStatusesExceptInitial.flatMap { accountStatus -> + accountStatus.value.map { it.currency } + }, + ) + + return TokensDataStateExpress( + fromGroup = getToCurrenciesGroupV2( + currency = currency, + leastPairs = pairsLeast.pairs, + cryptoCurrenciesList = walletAccountCurrencyStatusesExceptInitial, + tokenInfoForFilter = { it.to }, + tokenInfoForAvailable = { it.from }, + ), + toGroup = getToCurrenciesGroupV2( + currency = currency, + leastPairs = pairsLeast.pairs, + cryptoCurrenciesList = walletAccountCurrencyStatusesExceptInitial, + tokenInfoForFilter = { it.from }, + tokenInfoForAvailable = { it.to }, + ), + allProviders = pairsLeast.allProviders, + ) + } + private suspend fun getToCurrenciesGroup( currency: CryptoCurrency, leastPairs: List, @@ -174,6 +248,49 @@ internal class SwapInteractorImpl @AssistedInject constructor( return CurrenciesGroup( available = availableCryptoCurrencies, unavailable = unavailableCryptoCurrencies.map { CryptoCurrencySwapInfo(it, emptyList()) }, + accountCurrencyList = emptyList(), + isAfterSearch = false, + ) + } + + private suspend fun getToCurrenciesGroupV2( + currency: CryptoCurrency, + leastPairs: List, + cryptoCurrenciesList: Map>, + tokenInfoForFilter: (SwapPairLeast) -> LeastTokenInfo, + tokenInfoForAvailable: (SwapPairLeast) -> LeastTokenInfo, + ): CurrenciesGroup { + val filteredPairs = leastPairs.filter { + tokenInfoForFilter(it).contractAddress == currency.getContractAddress() && + tokenInfoForFilter(it).network == currency.network.backendId + } + + val accountCurrencyList = cryptoCurrenciesList.mapNotNull { (account, currencyStatusList) -> + val account = account as? Account.CryptoPortfolio ?: return@mapNotNull null + + AccountSwapAvailability( + account = account, + currencyList = currencyStatusList.map { currencyStatus -> + val providers = findProvidersForPair( + cryptoCurrencyStatuses = currencyStatus, + swapPairsLeastList = filteredPairs, + tokenInfoForAvailable = tokenInfoForAvailable, + ) + val isUnavailable = providers.isNullOrEmpty() + AccountSwapCurrency( + isAvailable = !isUnavailable, + account = account, + cryptoCurrencyStatus = currencyStatus, + providers = providers.orEmpty(), + ) + }, + ) + } + + return CurrenciesGroup( + available = emptyList(), + unavailable = emptyList(), + accountCurrencyList = accountCurrencyList, isAfterSearch = false, ) } @@ -257,7 +374,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( override suspend fun findBestQuote( fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, @@ -288,7 +407,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( manageDexSolana( networkId = networkId, fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, provider = provider, selectedFee = selectedFee, amount = amount, @@ -299,7 +420,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( manageDex( networkId = networkId, fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, provider = provider, selectedFee = selectedFee, amount = amount, @@ -312,7 +435,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( manageCex( networkId = networkId, fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, provider = provider, amount = amount, reduceBalanceBy = reduceBalanceBy, @@ -327,7 +452,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageDex( networkId: String, fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, provider: SwapProvider, selectedFee: FeeType, amount: SwapAmount, @@ -338,6 +465,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( return provider to produceDexSwapDataError( error = ExpressDataError.DexActiveSupplyError, fromToken = fromToken, + fromAccount = fromAccount, amount = amount, ) } @@ -374,7 +502,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, networkId = networkId, fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, amount = amount, selectedFee = selectedFee, expressOperationType = expressOperationType, @@ -385,7 +515,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( quoteDataModel = maybeQuotes, amount = amount, fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, networkId = networkId, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, @@ -400,7 +532,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageDexSolana( networkId: String, fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, provider: SwapProvider, selectedFee: FeeType, amount: SwapAmount, @@ -425,7 +559,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, networkId = networkId, fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, amount = amount, selectedFee = selectedFee, expressOperationType = expressOperationType, @@ -436,7 +572,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( quoteDataModel = maybeQuotes, amount = amount, fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, networkId = networkId, isAllowedToSpend = true, isBalanceWithoutFeeEnough = false, @@ -451,7 +589,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageCex( networkId: String, fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, provider: SwapProvider, amount: SwapAmount, reduceBalanceBy: BigDecimal, @@ -463,7 +603,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( amount = amount, reduceBalanceBy = reduceBalanceBy, fromTokenStatus = fromToken, + fromAccount = fromAccount, toTokenStatus = toToken, + toAccount = toAccount, isAllowedToSpend = true, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, provider = provider, @@ -976,6 +1118,17 @@ internal class SwapInteractorImpl @AssistedInject constructor( ?: group.available.firstOrNull()?.currencyStatus } + override suspend fun getInitialCurrencyToSwapV2( + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + isReverseFromTo: Boolean, + ): AccountSwapCurrency? { + val group = state.getGroupWithReverse(isReverseFromTo) + return initialToCurrencyResolver.tryGetFromCacheV2(userWallet, initialCryptoCurrency, state, isReverseFromTo) + ?: initialToCurrencyResolver.tryGetWithMaxAmountV2(state, isReverseFromTo) + ?: group.accountCurrencyList.firstNotNullOfOrNull { it.currencyList.firstOrNull { it.isAvailable } } + } + override fun getNativeToken(networkId: String): CryptoCurrency { return repository.getNativeTokenForNetwork(networkId) } @@ -1020,7 +1173,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( amount: SwapAmount, reduceBalanceBy: BigDecimal, fromTokenStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toTokenStatus: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, provider: SwapProvider, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, @@ -1073,7 +1228,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( quoteDataModel = quotes, amount = amount, fromToken = fromTokenStatus, + fromAccount = fromAccount, toToken = toTokenStatus, + toAccount = toAccount, networkId = networkId, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, @@ -1091,7 +1248,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( quoteDataModel: Either, amount: SwapAmount, fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, networkId: String, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, @@ -1105,7 +1264,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( val swapState = updateBalances( networkId = networkId, fromTokenStatus = fromToken, + fromAccount = fromAccount, toTokenStatus = toToken, + toAccount = toAccount, fromTokenAmount = amount, toTokenAmount = quoteModel.toTokenAmount, swapData = null, @@ -1133,6 +1294,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( val state = updatePermissionState( networkId = networkId, fromTokenStatus = fromToken, + fromAccount = fromAccount, swapAmount = amount, quotesLoadedState = swapState, isAllowedToSpend = isAllowedToSpend, @@ -1174,6 +1336,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ifLeft = { error -> createSwapErrorWith( fromToken = fromToken, + fromAccount = fromAccount, amount = amount, includeFeeInAmount = includeFeeInAmount, expressDataError = error, @@ -1184,6 +1347,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun createSwapErrorWith( fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, amount: SwapAmount, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, @@ -1194,6 +1358,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( amountFiat = rates[fromToken.currency.id]?.multiply(amount.value) ?: BigDecimal.ZERO, cryptoCurrencyStatus = fromToken, + account = fromAccount, ) return SwapState.SwapError(fromTokenSwapInfo, expressDataError, includeFeeInAmount) } @@ -1307,7 +1472,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider: SwapProvider, networkId: String, fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, amount: SwapAmount, selectedFee: FeeType, expressOperationType: ExpressOperationType, @@ -1345,6 +1512,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( return produceDexSwapDataError( error = ExpressDataError.TooLargeSolanaTransactionError, fromToken = fromToken, + fromAccount = fromAccount, amount = amount, ) } @@ -1384,7 +1552,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( val swapState = updateBalances( networkId = networkId, fromTokenStatus = fromToken, + fromAccount = fromAccount, toTokenStatus = toToken, + toAccount = toAccount, fromTokenAmount = amount, toTokenAmount = swapData.toTokenAmount, swapData = swapData, @@ -1413,6 +1583,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( produceDexSwapDataError( error = error, fromToken = fromToken, + fromAccount = fromAccount, amount = amount, ) }, @@ -1422,6 +1593,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun produceDexSwapDataError( error: ExpressDataError, fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, amount: SwapAmount, ): SwapState.SwapError { val rates = getQuotes(fromToken.currency.id) @@ -1430,6 +1602,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( amountFiat = rates[fromToken.currency.id]?.multiply(amount.value) ?: BigDecimal.ZERO, cryptoCurrencyStatus = fromToken, + account = fromAccount, ) return SwapState.SwapError( fromTokenSwapInfo, @@ -1498,7 +1671,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider: SwapProvider, networkId: String, fromTokenStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toTokenStatus: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, fromTokenAmount: SwapAmount, toTokenAmount: SwapAmount, swapData: SwapDataModel?, @@ -1511,6 +1686,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( return SwapState.QuotesLoadedState( fromTokenInfo = TokenSwapInfo( tokenAmount = fromTokenAmount, + account = fromAccount, cryptoCurrencyStatus = fromTokenStatus, amountFiat = rates[fromToken.id]?.multiply(fromTokenAmount.value) ?: BigDecimal.ZERO, @@ -1518,6 +1694,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( toTokenInfo = TokenSwapInfo( tokenAmount = toTokenAmount, cryptoCurrencyStatus = toTokenStatus, + account = toAccount, amountFiat = rates[toToken.id]?.multiply(toTokenAmount.value) ?: BigDecimal.ZERO, ), @@ -1564,6 +1741,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun updatePermissionState( networkId: String, fromTokenStatus: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, swapAmount: SwapAmount, quotesLoadedState: SwapState.QuotesLoadedState, spenderAddress: String?, @@ -1620,6 +1798,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( // it's impossible next steps without fee return createSwapErrorWith( fromToken = fromTokenStatus, + fromAccount = fromAccount, amount = swapAmount, includeFeeInAmount = IncludeFeeInAmount.Excluded, expressDataError = ExpressDataError.UnknownError, diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index c87d68c2df..4d5d733b5e 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -44,6 +44,8 @@ dependencies { implementation(projects.domain.txhistory) implementation(projects.domain.txhistory.models) implementation(projects.domain.express.models) + implementation(projects.domain.account) + implementation(projects.domain.account.status) /** Feature modules */ implementation(projects.features.swap.domain) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt index 9d3b8ab6ac..c4318d5226 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt @@ -12,13 +12,14 @@ import com.tangem.feature.swap.domain.models.domain.CryptoCurrencySwapInfo import com.tangem.feature.swap.models.CurrenciesGroupWithFromCurrency import com.tangem.feature.swap.models.SwapSelectTokenStateHolder import com.tangem.feature.swap.models.TokenBalanceData +import com.tangem.feature.swap.models.TokenListUMData import com.tangem.feature.swap.models.TokenToSelectState import com.tangem.feature.swap.presentation.R import com.tangem.utils.Provider import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toImmutableList -class TokensDataConverter( +internal class TokensDataConverter( private val onSearchEntered: (String) -> Unit, private val onTokenSelected: (String) -> Unit, private val isBalanceHiddenProvider: Provider, @@ -53,6 +54,7 @@ class TokensDataConverter( } } .toImmutableList(), + tokensListData = TokenListUMData.EmptyList, onSearchEntered = onSearchEntered, onTokenSelected = onTokenSelected, afterSearch = group.isAfterSearch, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index a8bb23deaf..760015eb46 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -20,6 +20,10 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.InputNumberFormatter +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +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 @@ -29,6 +33,7 @@ import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -105,6 +110,9 @@ internal class SwapModel @Inject constructor( swapInteractorFactory: SwapInteractor.Factory, private val urlOpener: UrlOpener, router: AppRouter, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, ) : Model() { private val params = paramsContainer.require() @@ -158,6 +166,10 @@ internal class SwapModel @Inject constructor( private var swapRouter: SwapRouter = SwapRouter(router = router) private var userCountry: UserCountry? = null + private lateinit var fromAccountCurrencyStatus: AccountCryptoCurrencyStatus + private var toAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null + private var isAccountsMode: Boolean = false + private val isUserResolvableError: (SwapState) -> Boolean = { it is SwapState.SwapError && ( @@ -183,19 +195,48 @@ internal class SwapModel @Inject constructor( } modelScope.launch(dispatchers.io) { - val fromStatus = - getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWalletId, initialCurrencyFrom.id) - .getOrNull() - val toStatus = initialCurrencyTo?.let { - getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWalletId, it.id).getOrNull() - } + if (accountsFeatureToggles.isFeatureEnabled) { + isAccountsMode = isAccountsModeEnabledUseCase.invokeSync() - if (fromStatus == null) { - uiState = stateBuilder.addAlert(uiState = uiState, onDismiss = swapRouter::back) + val fromAccountStatus = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = userWalletId, + currency = initialCurrencyFrom, + ).getOrNull() + val toAccountStatus = initialCurrencyTo?.let { + getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = userWalletId, + currency = it, + ).getOrNull() + } + + if (fromAccountStatus == null) { + uiState = stateBuilder.addAlert(uiState = uiState, onDismiss = swapRouter::back) + } else { + fromAccountCurrencyStatus = fromAccountStatus + toAccountCurrencyStatus = toAccountStatus + initialFromStatus = fromAccountStatus.status + initialToStatus = toAccountStatus?.status + initTokens(isInitiallyReversed) + } } else { - initialFromStatus = fromStatus - initialToStatus = toStatus - initTokens(isInitiallyReversed) + val fromStatus = getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWalletId = userWalletId, + cryptoCurrencyId = initialCurrencyFrom.id, + ).getOrNull() + val toStatus = initialCurrencyTo?.let { + getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWalletId = userWalletId, + cryptoCurrencyId = it.id, + ).getOrNull() + } + + if (fromStatus == null) { + uiState = stateBuilder.addAlert(uiState = uiState, onDismiss = swapRouter::back) + } else { + initialFromStatus = fromStatus + initialToStatus = toStatus + initTokens(isInitiallyReversed) + } } } @@ -225,7 +266,10 @@ internal class SwapModel @Inject constructor( private fun sendSelectTokenScreenOpenedEvent() { val isAnyAvailableTokensTo = dataState.tokensDataState?.toGroup?.available?.isNotEmpty() == true val isAnyAvailableTokensFrom = dataState.tokensDataState?.fromGroup?.available?.isNotEmpty() == true - val isAnyAvailableTokens = isAnyAvailableTokensTo || isAnyAvailableTokensFrom + val isAnyAvailableAccountTokensTo = !dataState.tokensDataState?.toGroup?.accountCurrencyList.isNullOrEmpty() + val isAnyAvailableAccountTokensFrom = !dataState.tokensDataState?.fromGroup?.accountCurrencyList.isNullOrEmpty() + val isAnyAvailableTokens = isAnyAvailableTokensTo || isAnyAvailableTokensFrom || + isAnyAvailableAccountTokensTo || isAnyAvailableAccountTokensFrom analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(availableTokens = isAnyAvailableTokens)) } @@ -235,15 +279,32 @@ internal class SwapModel @Inject constructor( swapInteractor.getTokensDataState(initialCurrencyFrom) }.onSuccess { state -> updateTokensState(state) - val selectedCurrency = initialToStatus ?: swapInteractor.getInitialCurrencyToSwap( - initialCryptoCurrency = initialCurrencyFrom, - state = state, - isReverseFromTo = isReverseFromTo, - ) + + val (selectedCurrency, selectedAccount) = if (accountsFeatureToggles.isFeatureEnabled) { + val selectedAccountCurrency = toAccountCurrencyStatus ?: swapInteractor.getInitialCurrencyToSwapV2( + initialCryptoCurrency = initialCurrencyFrom, + state = state, + isReverseFromTo = isReverseFromTo, + )?.let { + AccountCryptoCurrencyStatus( + account = it.account, + status = it.cryptoCurrencyStatus, + ) + } + selectedAccountCurrency?.status to selectedAccountCurrency?.account + } else { + val selectedCurrency = initialToStatus ?: swapInteractor.getInitialCurrencyToSwap( + initialCryptoCurrency = initialCurrencyFrom, + state = state, + isReverseFromTo = isReverseFromTo, + ) + selectedCurrency to null + } applyInitialTokenChoice( state = state, selectedCurrency = selectedCurrency, + selectedAccount = selectedAccount, isReverseFromTo = isReverseFromTo, ) @@ -268,6 +329,7 @@ internal class SwapModel @Inject constructor( applyInitialTokenChoice( state = TokensDataStateExpress.EMPTY, selectedCurrency = null, + selectedAccount = null, isReverseFromTo = isReverseFromTo, ) @@ -304,6 +366,7 @@ internal class SwapModel @Inject constructor( private fun applyInitialTokenChoice( state: TokensDataStateExpress, selectedCurrency: CryptoCurrencyStatus?, + selectedAccount: Account.CryptoPortfolio?, isReverseFromTo: Boolean, ) { // exceptional case @@ -321,14 +384,27 @@ internal class SwapModel @Inject constructor( } else { initialFromStatus to selectedCurrency } + val (fromAccount, toAccount) = if (accountsFeatureToggles.isFeatureEnabled) { + if (isOrderReversed) { + selectedAccount to fromAccountCurrencyStatus.account + } else { + fromAccountCurrencyStatus.account to selectedAccount + } + } else { + null to null + } dataState = dataState.copy( fromCryptoCurrency = fromCurrencyStatus, + fromAccount = fromAccount, toCryptoCurrency = toCurrencyStatus, + toAccount = toAccount, tokensDataState = state, ) startLoadingQuotes( fromToken = fromCurrencyStatus, + fromAccount = fromAccount, toToken = toCurrencyStatus, + toAccount = toAccount, amount = lastAmount.value, reduceBalanceBy = lastReducedBalanceBy.value, toProvidersList = findSwapProviders(fromCurrencyStatus, toCurrencyStatus), @@ -337,16 +413,28 @@ internal class SwapModel @Inject constructor( private fun updateTokensState(tokenDataState: TokensDataStateExpress) { val tokensDataState = if (isOrderReversed) tokenDataState.fromGroup else tokenDataState.toGroup - uiState = stateBuilder.addTokensToState( - uiState = uiState, - tokensDataState = tokensDataState, - fromToken = dataState.fromCryptoCurrency?.currency ?: initialCurrencyFrom, - ) + + uiState = if (accountsFeatureToggles.isFeatureEnabled) { + // stateBuilder.addTokensToStateV2( + // uiState = uiState, + // tokensDataState = tokensDataState, + // isAccountsMode = isAccountsMode, + // ) + TODO() + } else { + stateBuilder.addTokensToState( + uiState = uiState, + tokensDataState = tokensDataState, + fromToken = dataState.fromCryptoCurrency?.currency ?: initialCurrencyFrom, + ) + } } private fun startLoadingQuotes( fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, @@ -355,17 +443,19 @@ internal class SwapModel @Inject constructor( singleTaskScheduler.cancelTask() if (!isSilent) { uiState = stateBuilder.createQuotesLoadingState( - uiState, - fromToken.currency, - toToken.currency, - initialCurrencyFrom.id.value, + uiStateHolder = uiState, + fromToken = fromToken.currency, + toToken = toToken.currency, + mainTokenId = initialCurrencyFrom.id.value, ) } singleTaskScheduler.scheduleTask( modelScope, loadQuotesTask( fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, amount = amount, reduceBalanceBy = reduceBalanceBy, toProvidersList = toProvidersList, @@ -380,7 +470,9 @@ internal class SwapModel @Inject constructor( if (fromCurrency != null && toCurrency != null && amount != null) { startLoadingQuotes( fromToken = fromCurrency, + fromAccount = dataState.fromAccount, toToken = toCurrency, + toAccount = dataState.toAccount, amount = amount, isSilent = isSilent, reduceBalanceBy = dataState.reduceBalanceBy, @@ -391,7 +483,9 @@ internal class SwapModel @Inject constructor( private fun loadQuotesTask( fromToken: CryptoCurrencyStatus, + fromAccount: Account.CryptoPortfolio?, toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, @@ -409,7 +503,9 @@ internal class SwapModel @Inject constructor( ) swapInteractor.findBestQuote( fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, providers = toProvidersList, amountToSwap = amount, reduceBalanceBy = reduceBalanceBy, @@ -774,6 +870,7 @@ internal class SwapModel @Inject constructor( } else { tokenDataState.toGroup } + val available = group.available.filter { it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) || it.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true) @@ -782,11 +879,28 @@ internal class SwapModel @Inject constructor( it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) || it.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true) } + val accountCurrencyList = group.accountCurrencyList.mapNotNull { accountSwapAvailability -> + val filteredCurrencies = accountSwapAvailability.currencyList.filter { accountSwapCurrency -> + val currency = accountSwapCurrency.cryptoCurrencyStatus.currency + currency.name.contains(searchQuery, ignoreCase = true) || + currency.symbol.contains(searchQuery, ignoreCase = true) + } + + if (filteredCurrencies.isEmpty()) { + return@mapNotNull null + } + + accountSwapAvailability.copy( + currencyList = filteredCurrencies, + ) + } + val filteredTokenDataState = if (isOrderReversed) { tokenDataState.copy( fromGroup = tokenDataState.fromGroup.copy( available = available, unavailable = unavailable, + accountCurrencyList = accountCurrencyList, isAfterSearch = true, ), ) @@ -795,6 +909,7 @@ internal class SwapModel @Inject constructor( toGroup = tokenDataState.toGroup.copy( available = available, unavailable = unavailable, + accountCurrencyList = accountCurrencyList, isAfterSearch = true, ), ) @@ -805,25 +920,26 @@ internal class SwapModel @Inject constructor( private fun onTokenSelect(id: String) { val tokens = dataState.tokensDataState ?: return - val foundToken = if (isOrderReversed) { - tokens.fromGroup.available.firstOrNull { - it.currencyStatus.currency.id.value == id - } - } else { - tokens.toGroup.available.firstOrNull { - it.currencyStatus.currency.id.value == id - } - } - foundToken?.currencyStatus?.currency?.symbol?.let { + val (foundToken, foundAccount) = getSelectedTokenAndAccount(tokens, id) + + foundToken?.currency?.symbol?.let { analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(tokenChosen = true, token = it)) } if (foundToken != null) { val fromToken: CryptoCurrencyStatus + val fromAccount: Account.CryptoPortfolio? val toToken: CryptoCurrencyStatus + val toAccount: Account.CryptoPortfolio? if (isOrderReversed) { - fromToken = foundToken.currencyStatus + fromToken = foundToken + fromAccount = foundAccount toToken = initialFromStatus + toAccount = if (accountsFeatureToggles.isFeatureEnabled) { + fromAccountCurrencyStatus.account + } else { + null + } val newToken = fromToken.currency as? CryptoCurrency.Coin if (newToken != null) { @@ -835,7 +951,13 @@ internal class SwapModel @Inject constructor( } } else { fromToken = initialFromStatus - toToken = foundToken.currencyStatus + fromAccount = if (accountsFeatureToggles.isFeatureEnabled) { + fromAccountCurrencyStatus.account + } else { + null + } + toToken = foundToken + toAccount = foundAccount val newToken = toToken.currency as? CryptoCurrency.Coin if (newToken != null) { @@ -853,12 +975,16 @@ internal class SwapModel @Inject constructor( dataState = dataState.copy( fromCryptoCurrency = fromToken, + fromAccount = fromAccount, toCryptoCurrency = toToken, + toAccount = toAccount, selectedProvider = null, ) startLoadingQuotes( fromToken = fromToken, + fromAccount = fromAccount, toToken = toToken, + toAccount = toAccount, amount = lastAmount.value, reduceBalanceBy = lastReducedBalanceBy.value, toProvidersList = findSwapProviders(fromToken, toToken), @@ -868,6 +994,28 @@ internal class SwapModel @Inject constructor( } } + private fun getSelectedTokenAndAccount( + tokens: TokensDataStateExpress, + id: String, + ): Pair { + return if (accountsFeatureToggles.isFeatureEnabled) { + val accountCryptoCurrencyStatus = if (isOrderReversed) { + tokens.fromGroup + } else { + tokens.toGroup + }.accountCurrencyList.firstNotNullOfOrNull { + it.currencyList.firstOrNull { it.cryptoCurrencyStatus.currency.id.value == id } + } + accountCryptoCurrencyStatus?.cryptoCurrencyStatus to accountCryptoCurrencyStatus?.account + } else { + if (isOrderReversed) { + tokens.fromGroup + } else { + tokens.toGroup + }.available.firstOrNull { it.currencyStatus.currency.id.value == id }?.currencyStatus to null + } + } + private fun subscribeToCoinBalanceUpdates( userWalletId: UserWalletId, coin: CryptoCurrency.Coin, @@ -875,34 +1023,65 @@ internal class SwapModel @Inject constructor( ) { Timber.d("Subscribe to ${coin.id} balance updates") - getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( - userWalletId = userWalletId, - currencyId = coin.id, - isSingleWalletWithTokens = false, - ) - .mapNotNull { (it as? Either.Right)?.value } - .distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes - .onEach { - Timber.d("${coin.id} balance is ${it.value.amount}") + if (accountsFeatureToggles.isFeatureEnabled) { + getAccountCurrencyStatusUseCase( + userWalletId = userWalletId, + currency = coin, + ).distinctUntilChanged { old, new -> old.status.value.amount == new.status.value.amount } // Check only balance changes + .onEach { (account, currencyStatus) -> + Timber.d("${coin.id} balance is ${currencyStatus.value.amount}") - dataState = dataState.copy( - feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = it, - ).getOrNull() ?: it, - ) + dataState = dataState.copy( + feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = currencyStatus, + ).getOrNull() ?: currencyStatus, + ) - uiState = if (isFromCurrency) { - dataState = dataState.copy(fromCryptoCurrency = it) - stateBuilder.updateSendCurrencyBalance(uiState, it) - } else { - dataState = dataState.copy(toCryptoCurrency = it) - stateBuilder.updateReceiveCurrencyBalance(uiState, it) + uiState = if (isFromCurrency) { + dataState = dataState.copy( + fromCryptoCurrency = currencyStatus, + fromAccount = account, + ) + stateBuilder.updateSendCurrencyBalance(uiState, currencyStatus) + } else { + dataState = dataState.copy( + toCryptoCurrency = currencyStatus, + toAccount = account, + ) + stateBuilder.updateReceiveCurrencyBalance(uiState, currencyStatus) + } + + startLoadingQuotesFromLastState(isSilent = true) } + } else { + getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet( + userWalletId = userWalletId, + currencyId = coin.id, + isSingleWalletWithTokens = false, + ).mapNotNull { (it as? Either.Right)?.value } + .distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes + .onEach { + Timber.d("${coin.id} balance is ${it.value.amount}") - startLoadingQuotesFromLastState(isSilent = true) - } - .flowOn(dispatchers.main) + dataState = dataState.copy( + feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = it, + ).getOrNull() ?: it, + ) + + uiState = if (isFromCurrency) { + dataState = dataState.copy(fromCryptoCurrency = it) + stateBuilder.updateSendCurrencyBalance(uiState, it) + } else { + dataState = dataState.copy(toCryptoCurrency = it) + stateBuilder.updateReceiveCurrencyBalance(uiState, it) + } + + startLoadingQuotesFromLastState(isSilent = true) + } + }.flowOn(dispatchers.main) .launchIn(modelScope) .saveIn(if (isFromCurrency) fromTokenBalanceJobHolder else toTokenBalanceJobHolder) } @@ -910,14 +1089,18 @@ internal class SwapModel @Inject constructor( private fun onChangeCardsClicked() { modelScope.launch { val newFromToken = dataState.toCryptoCurrency + val newFromAccount = dataState.toAccount val newToToken = dataState.fromCryptoCurrency + val newToAccount = dataState.fromAccount if (newFromToken != null && newToToken != null) { isAmountChangedByUser = true dataState = dataState.copy( fromCryptoCurrency = newFromToken, + fromAccount = newFromAccount, toCryptoCurrency = newToToken, + toAccount = newToAccount, ) isOrderReversed = !isOrderReversed dataState.tokensDataState?.let { @@ -940,7 +1123,9 @@ internal class SwapModel @Inject constructor( ) startLoadingQuotes( fromToken = newFromToken, + fromAccount = newFromAccount, toToken = newToToken, + toAccount = newToAccount, amount = lastAmount.value, reduceBalanceBy = lastReducedBalanceBy.value, toProvidersList = findSwapProviders(newFromToken, newToToken), @@ -982,7 +1167,9 @@ internal class SwapModel @Inject constructor( amountDebouncer.debounce(modelScope, DEBOUNCE_AMOUNT_DELAY, forceUpdate = forceQuotesUpdate) { startLoadingQuotes( fromToken = fromToken, + fromAccount = dataState.fromAccount, toToken = toToken, + toAccount = dataState.toAccount, amount = lastAmount.value, reduceBalanceBy = lastReducedBalanceBy.value, toProvidersList = findSwapProviders(fromToken, toToken), @@ -1267,7 +1454,14 @@ internal class SwapModel @Inject constructor( toToken.currency.id.value } - return groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value }?.providers.orEmpty() + return if (accountsFeatureToggles.isFeatureEnabled) { + groupToFind.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) -> + currencyList.find { idToFind == it.cryptoCurrencyStatus.currency.id.value && it.isAvailable } + }?.providers + } else { + groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value } + ?.providers + }.orEmpty() } private fun Map.getLastLoadedSuccessStates(): SuccessLoadedSwapData { @@ -1293,9 +1487,11 @@ internal class SwapModel @Inject constructor( val chosen = if (isOrderReversed) from else to - return currenciesGroup.available - .map { it.currencyStatus.currency } - .contains(chosen.currency) + return if (accountsFeatureToggles.isFeatureEnabled) { + currenciesGroup.accountCurrencyList.flatMap { it.currencyList.map { it.cryptoCurrencyStatus } } + } else { + currenciesGroup.available.map { it.currencyStatus } + }.map { it.currency }.contains(chosen.currency) } private fun sendPermissionApproveClickedEvent() { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt index 604203a8f7..d18df192c1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.model +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.domain.SwapDataModel import com.tangem.feature.swap.domain.models.domain.SwapProvider @@ -14,6 +15,8 @@ data class SwapProcessDataState( val fromCryptoCurrency: CryptoCurrencyStatus? = null, val toCryptoCurrency: CryptoCurrencyStatus? = null, val feePaidCryptoCurrency: CryptoCurrencyStatus? = null, + val fromAccount: Account.CryptoPortfolio? = null, + val toAccount: Account.CryptoPortfolio? = null, // Amount from input val amount: String? = null, val reduceBalanceBy: BigDecimal = BigDecimal.ZERO, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt index d99e967096..63fc478fc5 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt @@ -1,18 +1,21 @@ package com.tangem.feature.swap.models import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf -data class SwapSelectTokenStateHolder( +internal data class SwapSelectTokenStateHolder( val availableTokens: ImmutableList, val unavailableTokens: ImmutableList, + val tokensListData: TokenListUMData, val afterSearch: Boolean, val onSearchEntered: (String) -> Unit, val onTokenSelected: (String) -> Unit, ) -sealed class TokenToSelectState { +internal sealed class TokenToSelectState { data class Title(val title: TextReference) : TokenToSelectState() @@ -26,8 +29,25 @@ sealed class TokenToSelectState { ) : TokenToSelectState() } -data class TokenBalanceData( +internal data class TokenBalanceData( val amount: String?, val amountEquivalent: String?, val isBalanceHidden: Boolean, -) \ No newline at end of file +) + +internal sealed interface TokenListUMData { + + val tokensList: ImmutableList + + data class AccountList( + override val tokensList: ImmutableList, + ) : TokenListUMData + + data class TokenList( + override val tokensList: ImmutableList, + ) : TokenListUMData + + data object EmptyList : TokenListUMData { + override val tokensList: ImmutableList = persistentListOf() + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt index b03970cc63..a21eb7613a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt @@ -14,32 +14,39 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.SpacerW2 import com.tangem.core.ui.components.appbar.ExpandableSearchView import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.tokenlist.PortfolioListItem +import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem +import com.tangem.core.ui.components.tokenlist.TokenListItem +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.decorations.roundedShapeItemDecoration -import com.tangem.core.ui.extensions.orMaskWithStars -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.BuyTokenScreenTestTags +import com.tangem.core.ui.utils.lazyListItemPosition import com.tangem.feature.swap.models.SwapSelectTokenStateHolder import com.tangem.feature.swap.models.TokenBalanceData +import com.tangem.feature.swap.models.TokenListUMData import com.tangem.feature.swap.models.TokenToSelectState import com.tangem.feature.swap.presentation.R import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @Composable -fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) { +internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) { BackHandler(onBack = onBack) Scaffold( @@ -49,10 +56,12 @@ fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) content = { padding -> val modifier = Modifier.padding(padding) when { - state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && state.afterSearch -> { + state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && + state.tokensListData.tokensList.isEmpty() && state.afterSearch -> { TokensNotFound(modifier) } - state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && !state.afterSearch -> { + state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && + state.tokensListData.tokensList.isEmpty() && !state.afterSearch -> { EmptyTokensList(modifier) } else -> { @@ -133,6 +142,22 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = .imePadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { + when (val list = state.tokensListData) { + is TokenListUMData.AccountList -> list.tokensList.forEach { item -> + portfolioTokensList( + portfolio = item, + isBalanceHidden = false, // state.isBalanceHidden, + ) + } + is TokenListUMData.TokenList -> { + tokensList( + items = list.tokensList, + isBalanceHidden = false, // state.isBalanceHidden, + ) + } + TokenListUMData.EmptyList -> Unit + } + tokensToSelectItems(state.availableTokens, state.onTokenSelected) item { SpacerH12() } @@ -143,6 +168,86 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = } } +private fun LazyListScope.tokensList(items: ImmutableList, isBalanceHidden: Boolean) { + itemsIndexed( + items = items, + key = { _, item -> item.id }, + contentType = { _, item -> item::class.java }, + itemContent = { index, item -> + TokenListItem( + state = item, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = items.lastIndex, + backgroundColor = TangemTheme.colors.background.primary, + ) + .testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) + .semantics { lazyListItemPosition = index }, + ) + }, + ) +} + +internal fun LazyListScope.portfolioTokensList(portfolio: TokensListItemUM.Portfolio, isBalanceHidden: Boolean) { + val tokens = portfolio.tokens + val isExpanded = portfolio.isExpanded + + portfolioItem( + portfolio = portfolio, + modifier = Modifier.padding(top = 8.dp), + isBalanceHidden = isBalanceHidden, + ) + if (!isExpanded) return + itemsIndexed( + items = tokens, + key = { _, item -> item.id }, + contentType = { _, item -> item::class.java }, + itemContent = { tokenIndex, token -> + val indexWithHeader = tokenIndex.inc() + PortfolioTokensListItem( + state = token, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .animateItem() + .roundedShapeItemDecoration( + currentIndex = indexWithHeader, + lastIndex = tokens.lastIndex.inc(), + backgroundColor = TangemTheme.colors.background.primary, + ) + .conditional(tokenIndex == tokens.lastIndex) { + Modifier.padding(bottom = 8.dp) + }, + ) + }, + ) +} + +private fun LazyListScope.portfolioItem( + portfolio: TokensListItemUM.Portfolio, + modifier: Modifier, + isBalanceHidden: Boolean, +) { + item( + key = "account-${portfolio.id}", + contentType = "account", + ) { + PortfolioListItem( + state = portfolio, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .animateItem() + .roundedShapeItemDecoration( + currentIndex = 0, + lastIndex = portfolio.tokens.lastIndex.inc(), + backgroundColor = TangemTheme.colors.background.primary, + ) + .then(modifier), + ) + } +} + private fun LazyListScope.tokensToSelectItems( items: ImmutableList, onTokenClick: (String) -> Unit, @@ -311,6 +416,7 @@ private fun TokenScreenPreview() { state = SwapSelectTokenStateHolder( availableTokens = listOf(title, token, token, token).toImmutableList(), unavailableTokens = listOf(title, token, token, token).toImmutableList(), + tokensListData = TokenListUMData.EmptyList, afterSearch = false, onSearchEntered = {}, onTokenSelected = {}, 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/component/preview/PreviewWalletSettingsComponent.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt index 53d1a92331..ad1e46ea60 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt @@ -5,7 +5,6 @@ import androidx.compose.ui.Modifier import com.tangem.common.ui.account.AccountIconPreviewData import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.common.ui.userwallet.state.UserWalletItemUM.ImageState -import com.tangem.core.analytics.DummyAnalyticsEventHandler import com.tangem.core.decompose.navigation.DummyRouter import com.tangem.core.ui.components.block.model.BlockUM import com.tangem.core.ui.extensions.resourceReference @@ -45,7 +44,6 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { popBack = {}, items = ItemsBuilder( router = DummyRouter(), - analyticsEventHandler = DummyAnalyticsEventHandler(), ).buildItems( userWallet = UserWallet.Hot( walletId = UserWalletId("011"), @@ -62,9 +60,9 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { forgetWallet = {}, onLinkMoreCardsClick = {}, onReferralClick = {}, + onManageTokensClick = {}, isManageTokensAvailable = true, isNotificationsEnabled = true, - isNotificationsFeatureEnabled = true, onCheckedNotificationsChanged = {}, onNotificationsDescriptionClick = {}, isNotificationsPermissionGranted = false, diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt index fe8eafb722..c6d8ddb1a3 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt @@ -73,7 +73,7 @@ internal sealed class WalletSettingsAccountsUM : WalletSettingsItemUM() { data class Footer( override val id: String, val addAccount: AddAccountUM, - val archivedAccounts: BlockUM, + val archivedAccounts: BlockUM?, val showDescription: Boolean, val description: TextReference, ) : WalletSettingsAccountsUM() { 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..340945e326 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 @@ -6,6 +6,7 @@ import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRoute.ManageTokens.Source import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam.OnOffState.Off import com.tangem.core.analytics.models.AnalyticsParam.OnOffState.On @@ -18,6 +19,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 @@ -25,6 +27,8 @@ import com.tangem.core.ui.message.bottomSheetMessage import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.models.PortfolioId +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet @@ -37,11 +41,7 @@ import com.tangem.domain.wallets.usecase.* import com.tangem.feature.walletsettings.analytics.Settings import com.tangem.feature.walletsettings.analytics.WalletSettingsAnalyticEvents import com.tangem.feature.walletsettings.component.WalletSettingsComponent -import com.tangem.feature.walletsettings.entity.DialogConfig -import com.tangem.feature.walletsettings.entity.NetworksAvailableForNotificationBSConfig -import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM -import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM -import com.tangem.feature.walletsettings.entity.WalletSettingsUM +import com.tangem.feature.walletsettings.entity.* import com.tangem.feature.walletsettings.impl.R import com.tangem.feature.walletsettings.utils.AccountItemsDelegate import com.tangem.feature.walletsettings.utils.ItemsBuilder @@ -51,11 +51,13 @@ import com.tangem.hot.sdk.model.HotWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject +@OptIn(ExperimentalCoroutinesApi::class) @Suppress("LongParameterList", "LargeClass") @ModelScoped internal class WalletSettingsModel @Inject constructor( @@ -111,22 +113,31 @@ internal class WalletSettingsModel @Inject constructor( title = resourceReference(R.string.hw_backup_need_title) body = resourceReference(R.string.hw_backup_need_description) } - secondaryButton { + primaryButton { text = resourceReference(R.string.hw_backup_need_action) onClick { - router.push(AppRoute.CreateWalletBackup(params.userWalletId)) + router.push( + AppRoute.CreateWalletBackup( + userWalletId = params.userWalletId, + isUpgradeFlow = false, + ), + ) closeBs() } } } init { + getUserWalletUseCase.invoke(params.userWalletId).onRight { + analyticsContextProxy.addContext(it) + } + fun combineUI(wallet: UserWallet) = combine( - getWalletNFTEnabledUseCase.invoke(params.userWalletId), - getWalletNotificationsEnabledUseCase(params.userWalletId), - isUpgradeWalletNotificationEnabledUseCase(params.userWalletId), - walletCardItemDelegate.cardItemFlow(wallet), - accountItemsDelegate.loadAccount(), + flow = getWalletNFTEnabledUseCase.invoke(params.userWalletId), + flow2 = getWalletNotificationsEnabledUseCase(params.userWalletId), + flow3 = isUpgradeWalletNotificationEnabledUseCase(params.userWalletId), + flow4 = walletCardItemDelegate.cardItemFlow(wallet), + flow5 = accountItemsDelegate.loadAccount(), ) { nftEnabled, notificationsEnabled, isUpgradeNotificationEnabled, cardItem, accountList -> val isWalletBackedUp = when (wallet) { is UserWallet.Hot -> wallet.backedUp @@ -139,7 +150,6 @@ internal class WalletSettingsModel @Inject constructor( cardItem = cardItem, isNFTEnabled = nftEnabled, isNotificationsEnabled = notificationsEnabled, - isNotificationsFeatureEnabled = true, isNotificationsPermissionGranted = isNotificationsPermissionGranted(), isUpgradeNotificationEnabled = isUpgradeNotificationEnabled, accountList = accountList, @@ -155,6 +165,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( @@ -169,7 +184,6 @@ internal class WalletSettingsModel @Inject constructor( userWallet: UserWallet, cardItem: WalletSettingsItemUM.CardBlock, isNFTEnabled: Boolean, - isNotificationsFeatureEnabled: Boolean, isNotificationsEnabled: Boolean, isNotificationsPermissionGranted: Boolean, isUpgradeNotificationEnabled: Boolean, @@ -191,29 +205,16 @@ internal class WalletSettingsModel @Inject constructor( is UserWallet.Cold -> userWallet.scanResponse.card.backupStatus == CardDTO.BackupStatus.NoBackup is UserWallet.Hot -> false }, - isManageTokensAvailable = !accountsFeatureEnabled && isMultiCurrency, + isManageTokensAvailable = if (accountsFeatureEnabled) { + isMultiCurrency && accountList.count { it is WalletSettingsAccountsUM.Account } == 0 + } else { + isMultiCurrency + }, isNFTFeatureEnabled = isMultiCurrency, 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) { @@ -222,8 +223,22 @@ internal class WalletSettingsModel @Inject constructor( } }, onReferralClick = { onReferralClick(userWallet) }, + onManageTokensClick = { + analyticsEventHandler.send(Settings.ButtonManageTokens) + router.push( + AppRoute.ManageTokens( + source = Source.SETTINGS, + portfolioId = if (accountsFeatureEnabled) { + PortfolioId( + accountId = AccountId.forMainCryptoPortfolio(userWalletId = userWallet.walletId), + ) + } else { + PortfolioId(userWalletId = userWallet.walletId) + }, + ), + ) + }, isNotificationsEnabled = isNotificationsEnabled, - isNotificationsFeatureEnabled = isNotificationsFeatureEnabled, isNotificationsPermissionGranted = isNotificationsPermissionGranted, onCheckedNotificationsChanged = ::onCheckedNotificationsChange, onNotificationsDescriptionClick = ::onNotificationsDescriptionClick, @@ -361,7 +376,11 @@ internal class WalletSettingsModel @Inject constructor( private fun onUpgradeWalletClick() { unlockWalletIfNeedAndProceed { - router.push(AppRoute.UpgradeWallet(params.userWalletId)) + router.push( + AppRoute.UpgradeWallet( + userWalletId = params.userWalletId, + ), + ) } } @@ -394,4 +413,85 @@ 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( + userWalletId = userWallet.walletId, + isUpgradeFlow = false, + ), + ) + closeBs() + } + } + } + } + } + } + + messageSender.send(message) + } } \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt index 7a01f6760f..4e33a73d35 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt @@ -1,6 +1,7 @@ package com.tangem.feature.walletsettings.ui import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -325,17 +326,25 @@ private fun AccountsFooter(model: WalletSettingsAccountsUM.Footer, modifier: Mod ), ) { AddAccountRow(model.addAccount) - HorizontalDivider( - thickness = TangemTheme.dimens.size0_5, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = TangemTheme.dimens.spacing12), - color = TangemTheme.colors.stroke.primary, - ) - BlockItem( - modifier = Modifier.fillMaxWidth(), - model = model.archivedAccounts, - ) + + AnimatedVisibility(visible = model.archivedAccounts != null) { + model.archivedAccounts ?: return@AnimatedVisibility + + Column { + HorizontalDivider( + thickness = TangemTheme.dimens.size0_5, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing12), + color = TangemTheme.colors.stroke.primary, + ) + + BlockItem( + modifier = Modifier.fillMaxWidth(), + model = model.archivedAccounts, + ) + } + } } if (!model.showDescription) return SpacerH8() diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt index ed1a0254c5..2c0cd637d0 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt @@ -16,6 +16,7 @@ import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -40,6 +41,7 @@ internal class AccountItemsDelegate @Inject constructor( private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val accountsFeatureToggles: AccountsFeatureToggles, ) { val userWalletId = paramsContainer.require().userWalletId @@ -51,8 +53,9 @@ internal class AccountItemsDelegate @Inject constructor( flow = singleAccountStatusListSupplier(params), flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(), flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(), - ) { accountStatusList, appCurrency, isBalanceHidden -> - buildUiList(accountStatusList, appCurrency, isBalanceHidden) + flow4 = isAccountsModeEnabledUseCase(), + ) { accountStatusList, appCurrency, isBalanceHidden, isAccountsMode -> + buildUiList(accountStatusList, appCurrency, isBalanceHidden, isAccountsMode) } } @@ -60,6 +63,7 @@ internal class AccountItemsDelegate @Inject constructor( accountStatusList: AccountStatusList, appCurrency: AppCurrency, isBalanceHidden: Boolean, + isAccountsMode: Boolean, ): List = buildList { fun AccountStatus.CryptoPortfolio.mapCryptoPortfolio(): WalletSettingsAccountsUM { val accountItemUM = AccountPortfolioItemUMConverter( @@ -81,10 +85,14 @@ internal class AccountItemsDelegate @Inject constructor( text = resourceReference(R.string.common_accounts), ).let(::add) - addAll(accounts.map(::mapAccount)) + if (isAccountsMode) { + addAll(accounts.map(::mapAccount)) + } val addAccountEnabled = accounts.size < AccountList.MAX_ACCOUNTS_COUNT val showDescription = accounts.size > 1 + val isArchivedAccountsEnabled = accountStatusList.accountStatuses.size != accountStatusList.totalAccounts + WalletSettingsAccountsUM.Footer( id = "accounts_footer", addAccount = AddAccountUM( @@ -94,11 +102,15 @@ internal class AccountItemsDelegate @Inject constructor( if (addAccountEnabled) openAddAccount(userWalletId) else canNotAddAccountDialog() }, ), - archivedAccounts = BlockUM( - text = resourceReference(R.string.account_archived_accounts), - iconRes = R.drawable.ic_archive_24, - onClick = { openArchivedAccounts(userWalletId) }, - ), + archivedAccounts = if (isArchivedAccountsEnabled) { + BlockUM( + text = resourceReference(R.string.account_archived_accounts), + iconRes = R.drawable.ic_archive_24, + onClick = { openArchivedAccounts(userWalletId) }, + ) + } else { + null + }, showDescription = showDescription, description = resourceReference(R.string.account_reorder_description), ).let(::add) diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt index 8375ca44c6..1b9204152a 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt @@ -1,17 +1,13 @@ package com.tangem.feature.walletsettings.utils import com.tangem.common.routing.AppRoute -import com.tangem.common.routing.AppRoute.ManageTokens.Source -import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.components.block.model.BlockUM import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.wallet.UserWallet -import com.tangem.feature.walletsettings.analytics.Settings import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.impl.R @@ -24,7 +20,6 @@ import javax.inject.Inject @ModelScoped internal class ItemsBuilder @Inject constructor( private val router: Router, - private val analyticsEventHandler: AnalyticsEventHandler, ) { @Suppress("LongParameterList") @@ -38,7 +33,6 @@ internal class ItemsBuilder @Inject constructor( isNFTFeatureEnabled: Boolean, isNFTEnabled: Boolean, onCheckedNFTChange: (Boolean) -> Unit, - isNotificationsFeatureEnabled: Boolean, isNotificationsEnabled: Boolean, isNotificationsPermissionGranted: Boolean, onCheckedNotificationsChanged: (Boolean) -> Unit, @@ -46,6 +40,7 @@ internal class ItemsBuilder @Inject constructor( forgetWallet: () -> Unit, onLinkMoreCardsClick: () -> Unit, onReferralClick: () -> Unit, + onManageTokensClick: () -> Unit, onAccessCodeClick: () -> Unit, walletUpgradeDismissed: Boolean, onUpgradeWalletClick: () -> Unit, @@ -70,11 +65,11 @@ internal class ItemsBuilder @Inject constructor( isManageTokensAvailable = isManageTokensAvailable, onLinkMoreCardsClick = onLinkMoreCardsClick, onReferralClick = onReferralClick, + onManageTokensClick = onManageTokensClick, ), ) .addAll( buildNotificationItems( - isNotificationsFeatureEnabled = isNotificationsFeatureEnabled, isNotificationsPermissionGranted = isNotificationsPermissionGranted, isNotificationsEnabled = isNotificationsEnabled, onCheckedNotificationsChanged = onCheckedNotificationsChanged, @@ -103,13 +98,11 @@ internal class ItemsBuilder @Inject constructor( } private fun buildNotificationItems( - isNotificationsFeatureEnabled: Boolean, isNotificationsPermissionGranted: Boolean, isNotificationsEnabled: Boolean, onCheckedNotificationsChanged: (Boolean) -> Unit, onNotificationsDescriptionClick: () -> Unit, ): List { - if (!isNotificationsFeatureEnabled) return emptyList() return buildList { if (!isNotificationsPermissionGranted) { add(buildNotificationsPermissionItem()) @@ -179,6 +172,7 @@ internal class ItemsBuilder @Inject constructor( isManageTokensAvailable: Boolean, onLinkMoreCardsClick: () -> Unit, onReferralClick: () -> Unit, + onManageTokensClick: () -> Unit, ) = WalletSettingsItemUM.WithItems( id = "card", description = resourceReference(R.string.settings_card_settings_footer), @@ -208,10 +202,7 @@ internal class ItemsBuilder @Inject constructor( BlockUM( text = resourceReference(R.string.add_tokens_title), iconRes = R.drawable.ic_tether_24, - onClick = { - analyticsEventHandler.send(Settings.ButtonManageTokens) - router.push(AppRoute.ManageTokens(Source.SETTINGS, PortfolioId(userWalletId))) - }, + onClick = onManageTokensClick, ).let(::add) } 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/common/preview/WalletScreenPreviewData.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt index 50569e441a..abedb142a7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt @@ -106,6 +106,28 @@ internal object WalletScreenPreviewData { ), ) + private val emptyPortfolioContentState = WalletTokensListState.ContentState.PortfolioContent( + items = persistentListOf( + TokensListItemUM.Portfolio( + tokens = textContentTokensState.items.filterIsInstance().toPersistentList(), + isExpanded = false, + isCollapsable = true, + tokenItemUM = AccountItemPreviewData.accountItem + .copy(iconState = AccountItemPreviewData.accountLetterIcon), + ), + TokensListItemUM.Portfolio( + tokens = persistentListOf(), + isExpanded = true, + isCollapsable = true, + tokenItemUM = AccountItemPreviewData.accountItem, + ), + ), + organizeTokensButtonConfig = WalletTokensListState.OrganizeTokensButtonConfig( + isEnabled = true, + onClick = {}, + ), + ) + private val noteLockedCard by lazy { WalletCardState.LockedContent( id = UserWalletId(stringValue = "1"), @@ -208,4 +230,12 @@ internal object WalletScreenPreviewData { multiWalletState.copy(tokensListState = portfolioContentState), ), ) + + internal val accountScreenWithEmptyTokensState = + walletScreenState.copy( + wallets = persistentListOf( + singleWalletLockedState, + multiWalletState.copy(tokensListState = emptyPortfolioContentState), + ), + ) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index 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/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt index 9134b2295d..68827d346e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt @@ -100,6 +100,7 @@ internal class TokenListStateConverter( appCurrency = appCurrency, account = account, onItemClick = onItemClick, + priceChangeLce = this.priceChangeLce, ) val accountItem = converter.convert(tokenList.totalFiatBalance) val tokenConverter = tokenStatusConverter(account.accountId) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 073d32efd8..47408a1b03 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -69,6 +69,7 @@ import com.tangem.core.ui.utils.moveTo import com.tangem.core.ui.utils.toPx import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.common.preview.WalletScreenPreviewData.accountScreenState +import com.tangem.feature.wallet.presentation.common.preview.WalletScreenPreviewData.accountScreenWithEmptyTokensState import com.tangem.feature.wallet.presentation.common.preview.WalletScreenPreviewData.walletScreenState import com.tangem.feature.wallet.presentation.wallet.state.model.* import com.tangem.feature.wallet.presentation.wallet.state.model.holder.TxHistoryStateHolder @@ -142,6 +143,7 @@ private fun WalletContent( */ val selectedWalletIndex by remember(state.selectedWalletIndex) { mutableIntStateOf(state.selectedWalletIndex) } val selectedWallet = state.wallets.getOrElse(selectedWalletIndex) { state.wallets[state.selectedWalletIndex] } + val selectedWalletChanged = rememberChangedOnce(selectedWalletIndex) val listState = rememberLazyListState() @@ -239,6 +241,7 @@ private fun WalletContent( contentItems( state = selectedWallet, + selectedWalletChanged = selectedWalletChanged, txHistoryItems = txHistoryItems, isBalanceHidden = state.isHidingMode, modifier = movableItemModifier, @@ -289,6 +292,14 @@ private fun WalletContent( ) } +@Composable +private fun rememberChangedOnce(selectedWalletIndex: Int): Boolean { + var prev by remember { mutableIntStateOf(selectedWalletIndex) } + val changed = prev != selectedWalletIndex + SideEffect { prev = selectedWalletIndex } + return changed +} + @Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -743,6 +754,7 @@ private class WalletScreenPreviewProvider : PreviewParameterProvider?, isBalanceHidden: Boolean, modifier: Modifier = Modifier, + selectedWalletChanged: Boolean, ) { when (state) { is WalletState.MultiCurrency -> { - tokensListItems(state.tokensListState, modifier, isBalanceHidden) + tokensListItems(state.tokensListState, modifier, isBalanceHidden, selectedWalletChanged) } is WalletState.SingleCurrency -> { txHistoryItems(state.txHistoryState, txHistoryItems, isBalanceHidden, modifier) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt index 95126a8582..065c5aaa6f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt @@ -27,6 +27,7 @@ internal fun LazyListScope.portfolioContentItems( items: ImmutableList, modifier: Modifier = Modifier, isBalanceHidden: Boolean, + selectedWalletChanged: Boolean, ) { items.forEachIndexed { index, item -> portfolioTokensList( @@ -34,6 +35,7 @@ internal fun LazyListScope.portfolioContentItems( modifier = modifier, portfolioIndex = index, isBalanceHidden = isBalanceHidden, + selectedWalletChanged = selectedWalletChanged, ) } } @@ -43,6 +45,7 @@ internal fun LazyListScope.portfolioTokensList( modifier: Modifier, portfolioIndex: Int, isBalanceHidden: Boolean, + selectedWalletChanged: Boolean, ) { val tokens = portfolio.tokens val isExpanded = portfolio.isExpanded @@ -52,8 +55,28 @@ internal fun LazyListScope.portfolioTokensList( modifier = modifier, portfolioIndex = portfolioIndex, isBalanceHidden = isBalanceHidden, + selectedWalletChanged = selectedWalletChanged, ) if (!isExpanded) return + if (tokens.isEmpty()) { + item( + key = "$NON_CONTENT_TOKENS_LIST_KEY account-${portfolio.id}", + contentType = "$NON_CONTENT_TOKENS_LIST_KEY account-${portfolio.id}", + ) { + NonContentItemContent( + modifier = Modifier + .animateItem() + .roundedShapeItemDecoration( + radius = TangemTheme.dimens.radius14, + currentIndex = 1, + lastIndex = 1, + backgroundColor = TangemTheme.colors.background.primary, + ) + .padding(vertical = TangemTheme.dimens.spacing28), + ) + } + return + } itemsIndexed( items = tokens, key = { _, item -> item.id }, @@ -63,13 +86,14 @@ internal fun LazyListScope.portfolioTokensList( val lastIndex = tokens.lastIndex.inc() val isPreview = LocalInspectionMode.current val appear = remember { - MutableTransitionState(isPreview).apply { targetState = true } + MutableTransitionState(selectedWalletChanged || isPreview).apply { targetState = true } } SlideInItemVisibility( modifier = modifier .testModifier(indexWithHeader) .animateItem() .roundedShapeItemDecoration( + radius = TangemTheme.dimens.radius14, currentIndex = indexWithHeader, lastIndex = lastIndex, backgroundColor = TangemTheme.colors.background.primary, @@ -92,10 +116,17 @@ private fun LazyListScope.portfolioItem( modifier: Modifier, portfolioIndex: Int, isBalanceHidden: Boolean, + selectedWalletChanged: Boolean, ) { val tokens = portfolio.tokens val isExpanded = portfolio.isExpanded + val lastIndex = when { + isExpanded && tokens.isEmpty() -> 1 + isExpanded -> tokens.lastIndex.inc() + else -> 0 + } + item( key = "account-${portfolio.id}-isExpanded$isExpanded", contentType = "account-isExpanded$isExpanded", @@ -105,27 +136,24 @@ private fun LazyListScope.portfolioItem( .animateItem() .roundedShapeItemDecoration( currentIndex = 0, - lastIndex = if (isExpanded) tokens.lastIndex.inc() else 0, + radius = TangemTheme.dimens.radius14, + lastIndex = lastIndex, backgroundColor = TangemTheme.colors.background.primary, ) val isPreview = LocalInspectionMode.current val appear = remember { - MutableTransitionState(isPreview).apply { targetState = true } + MutableTransitionState(selectedWalletChanged || isPreview || tokens.isEmpty()) + .apply { targetState = true } } if (isExpanded) { SlideInItemVisibility( modifier = anchorModifier, visibleState = appear, ) { - val modifier = if (portfolio.tokens.isEmpty()) { - Modifier.padding(vertical = 8.dp) - } else { - Modifier.padding(top = 8.dp) - } PortfolioListItem( state = portfolio, isBalanceHidden = isBalanceHidden, - modifier = modifier, + modifier = Modifier.padding(vertical = 8.dp), ) } } else { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt index e8362d171b..edf6506e26 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -25,7 +26,7 @@ import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState import kotlinx.collections.immutable.ImmutableList -private const val NON_CONTENT_TOKENS_LIST_KEY = "NON_CONTENT_TOKENS_LIST" +internal const val NON_CONTENT_TOKENS_LIST_KEY = "NON_CONTENT_TOKENS_LIST" /** * LazyList extension for [WalletTokensListState] @@ -39,12 +40,14 @@ internal fun LazyListScope.tokensListItems( state: WalletTokensListState, modifier: Modifier = Modifier, isBalanceHidden: Boolean, + selectedWalletChanged: Boolean, ) { when (state) { is WalletTokensListState.ContentState.PortfolioContent -> portfolioContentItems( items = state.items, isBalanceHidden = isBalanceHidden, modifier = modifier, + selectedWalletChanged = selectedWalletChanged, ) is WalletTokensListState.ContentState.Content, is WalletTokensListState.ContentState.Loading, @@ -89,27 +92,34 @@ private fun LazyListScope.nonContentItem(modifier: Modifier = Modifier) { key = NON_CONTENT_TOKENS_LIST_KEY, contentType = NON_CONTENT_TOKENS_LIST_KEY, ) { - Column( + NonContentItemContent( modifier = modifier .animateItem(fadeInSpec = null, fadeOutSpec = null) .padding(top = TangemTheme.dimens.spacing96), - verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing16), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_empty_64), - contentDescription = null, - modifier = Modifier.size(size = TangemTheme.dimens.size64), - tint = TangemTheme.colors.icon.inactive, - ) + ) + } +} - Text( - text = stringResourceSafe(id = R.string.main_empty_tokens_list_message), - modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing48), - color = TangemTheme.colors.text.tertiary, - textAlign = TextAlign.Center, - style = TangemTheme.typography.caption2, - ) - } +@Composable +internal fun NonContentItemContent(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_empty_64), + contentDescription = null, + modifier = Modifier.size(size = TangemTheme.dimens.size64), + tint = TangemTheme.colors.icon.inactive, + ) + + Text( + text = stringResourceSafe(id = R.string.main_empty_tokens_list_message), + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing48), + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + style = TangemTheme.typography.caption2, + ) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt index f86073d09e..4fc2e6290b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt @@ -1,6 +1,5 @@ package com.tangem.feature.wallet.utils -import arrow.core.Either import com.tangem.common.ui.userwallet.converter.ArtworkUMConverter import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.core.ui.components.artwork.ArtworkUM @@ -47,12 +46,11 @@ class DefaultUserWalletImageFetcher @Inject constructor( override fun walletImage(walletId: UserWalletId, size: ArtworkSize): Flow = getUserWalletUseCase.invokeFlow(walletId) - .transform { either -> - if (either.isLeft()) { - emit(UserWalletItemUM.ImageState.Loading) - } else if (either is Either.Right) { - emitAll(walletImage(either.value, size)) - } + .transform { + it.fold( + ifLeft = { emit(UserWalletItemUM.ImageState.Loading) }, + ifRight = { wallet -> emitAll(walletImage(wallet, size)) }, + ) } .distinctUntilChanged() diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 03743696e3..6adbcc9cf1 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -1,5 +1,4 @@ # docs https://docs.gradle.org/current/userguide/platforms.html -# TODO: update versions refs https://tangem.atlassian.net/browse/AND-3195 [versions] # region Classpath 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 }